feat(core): refactor project copies for v2 (#31943)
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "abd2f920-b822-49af-b8a7-2e48367d424f",
|
||||
"prevIds": ["f25f9126-c7dc-4882-9ff4-af27e11d2da1"],
|
||||
"id": "169a0f0f-d58f-479f-b024-fa1c7b9a09db",
|
||||
"prevIds": [
|
||||
"abd2f920-b822-49af-b8a7-2e48367d424f"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "workspace",
|
||||
@@ -622,7 +624,7 @@
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
@@ -630,6 +632,16 @@
|
||||
"entityType": "columns",
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "strategy",
|
||||
"entityType": "columns",
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
@@ -1501,9 +1513,13 @@
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1512,9 +1528,13 @@
|
||||
"table": "workspace"
|
||||
},
|
||||
{
|
||||
"columns": ["active_account_id"],
|
||||
"columns": [
|
||||
"active_account_id"
|
||||
],
|
||||
"tableTo": "account",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "SET NULL",
|
||||
"nameExplicit": false,
|
||||
@@ -1523,9 +1543,13 @@
|
||||
"table": "account_state"
|
||||
},
|
||||
{
|
||||
"columns": ["aggregate_id"],
|
||||
"columns": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"tableTo": "event_sequence",
|
||||
"columnsTo": ["aggregate_id"],
|
||||
"columnsTo": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1534,9 +1558,13 @@
|
||||
"table": "event"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1545,9 +1573,13 @@
|
||||
"table": "permission"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1556,9 +1588,13 @@
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1567,9 +1603,13 @@
|
||||
"table": "message"
|
||||
},
|
||||
{
|
||||
"columns": ["message_id"],
|
||||
"columns": [
|
||||
"message_id"
|
||||
],
|
||||
"tableTo": "message",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1578,9 +1618,13 @@
|
||||
"table": "part"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1589,9 +1633,13 @@
|
||||
"table": "session_context_epoch"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1600,9 +1648,13 @@
|
||||
"table": "session_input"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1611,9 +1663,13 @@
|
||||
"table": "session_message"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id"],
|
||||
"columns": [
|
||||
"project_id"
|
||||
],
|
||||
"tableTo": "project",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1622,9 +1678,13 @@
|
||||
"table": "session"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1633,9 +1693,13 @@
|
||||
"table": "todo"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"tableTo": "session",
|
||||
"columnsTo": ["id"],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "NO ACTION",
|
||||
"onDelete": "CASCADE",
|
||||
"nameExplicit": false,
|
||||
@@ -1644,133 +1708,174 @@
|
||||
"table": "session_share"
|
||||
},
|
||||
{
|
||||
"columns": ["email", "url"],
|
||||
"columns": [
|
||||
"email",
|
||||
"url"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "control_account_pk",
|
||||
"entityType": "pks",
|
||||
"table": "control_account"
|
||||
},
|
||||
{
|
||||
"columns": ["project_id", "directory"],
|
||||
"columns": [
|
||||
"project_id",
|
||||
"directory"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "project_directory_pk",
|
||||
"entityType": "pks",
|
||||
"table": "project_directory"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id", "position"],
|
||||
"columns": [
|
||||
"session_id",
|
||||
"position"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "todo_pk",
|
||||
"entityType": "pks",
|
||||
"table": "todo"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "workspace_pk",
|
||||
"table": "workspace",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["name"],
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "data_migration_pk",
|
||||
"table": "data_migration",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "account_state_pk",
|
||||
"table": "account_state",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "account_pk",
|
||||
"table": "account",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "credential_pk",
|
||||
"table": "credential",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["aggregate_id"],
|
||||
"columns": [
|
||||
"aggregate_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "event_sequence_pk",
|
||||
"table": "event_sequence",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "event_pk",
|
||||
"table": "event",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "permission_pk",
|
||||
"table": "permission",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "project_pk",
|
||||
"table": "project",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "message_pk",
|
||||
"table": "message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "part_pk",
|
||||
"table": "part",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_context_epoch_pk",
|
||||
"table": "session_context_epoch",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_input_pk",
|
||||
"table": "session_input",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_message_pk",
|
||||
"table": "session_message",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["id"],
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_pk",
|
||||
"table": "session",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": ["session_id"],
|
||||
"columns": [
|
||||
"session_id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "session_share_pk",
|
||||
"table": "session_share",
|
||||
@@ -2088,4 +2193,4 @@
|
||||
}
|
||||
],
|
||||
"renames": []
|
||||
}
|
||||
}
|
||||
1
packages/core/src/database/migration.gen.ts
generated
1
packages/core/src/database/migration.gen.ts
generated
@@ -36,5 +36,6 @@ export const migrations = (
|
||||
import("./migration/20260605042240_add_context_epoch_agent"),
|
||||
import("./migration/20260611035744_credential"),
|
||||
import("./migration/20260611192811_lush_chimera"),
|
||||
import("./migration/20260612174303_project_dir_strategy"),
|
||||
])
|
||||
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration"
|
||||
|
||||
export default {
|
||||
id: "20260612174303_project_dir_strategy",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`project_directory\` ADD \`strategy\` text;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=OFF;`)
|
||||
yield* tx.run(`
|
||||
CREATE TABLE \`__new_project_directory\` (
|
||||
\`project_id\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`type\` text,
|
||||
\`strategy\` text,
|
||||
\`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
|
||||
);
|
||||
`)
|
||||
yield* tx.run(`INSERT INTO \`__new_project_directory\`(\`project_id\`, \`directory\`, \`type\`, \`time_created\`) SELECT \`project_id\`, \`directory\`, \`type\`, \`time_created\` FROM \`project_directory\`;`)
|
||||
yield* tx.run(`DROP TABLE \`project_directory\`;`)
|
||||
yield* tx.run(`ALTER TABLE \`__new_project_directory\` RENAME TO \`project_directory\`;`)
|
||||
yield* tx.run(`PRAGMA foreign_keys=ON;`)
|
||||
})
|
||||
},
|
||||
} satisfies DatabaseMigration.Migration
|
||||
@@ -101,7 +101,8 @@ export default {
|
||||
CREATE TABLE \`project_directory\` (
|
||||
\`project_id\` text NOT NULL,
|
||||
\`directory\` text NOT NULL,
|
||||
\`type\` text NOT NULL,
|
||||
\`type\` text,
|
||||
\`strategy\` text,
|
||||
\`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
|
||||
@@ -240,32 +241,16 @@ export default {
|
||||
`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`event_aggregate_seq_idx\` ON \`event\` (\`aggregate_id\`,\`seq\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`event_aggregate_type_seq_idx\` ON \`event\` (\`aggregate_id\`,\`type\`,\`seq\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`permission_project_action_resource_idx\` ON \`permission\` (\`project_id\`,\`action\`,\`resource\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`message_session_time_created_id_idx\` ON \`message\` (\`session_id\`,\`time_created\`,\`id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`part_message_id_id_idx\` ON \`part\` (\`message_id\`,\`id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`part_session_idx\` ON \`part\` (\`session_id\`);`)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`,
|
||||
)
|
||||
yield* tx.run(
|
||||
`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`,
|
||||
)
|
||||
yield* tx.run(`CREATE INDEX \`session_input_session_pending_delivery_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`,\`delivery\`,\`admitted_seq\`);`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_admitted_seq_idx\` ON \`session_input\` (\`session_id\`,\`admitted_seq\`);`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`session_input_session_promoted_seq_idx\` ON \`session_input\` (\`session_id\`,\`promoted_seq\`);`)
|
||||
yield* tx.run(`CREATE UNIQUE INDEX \`session_message_session_seq_idx\` ON \`session_message\` (\`session_id\`,\`seq\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_type_seq_idx\` ON \`session_message\` (\`session_id\`,\`type\`,\`seq\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_session_time_created_id_idx\` ON \`session_message\` (\`session_id\`,\`time_created\`,\`id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_message_time_created_idx\` ON \`session_message\` (\`time_created\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_project_idx\` ON \`session\` (\`project_id\`);`)
|
||||
yield* tx.run(`CREATE INDEX \`session_workspace_idx\` ON \`session\` (\`workspace_id\`);`)
|
||||
|
||||
@@ -9,11 +9,14 @@ import { CommandV2 } from "./command"
|
||||
import { AgentV2 } from "./agent"
|
||||
import { PluginBoot } from "./plugin/boot"
|
||||
import { Project } from "./project"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
import { EventV2 } from "./event"
|
||||
import { Credential } from "./credential"
|
||||
import { Npm } from "./npm"
|
||||
import { ModelsDev } from "./models-dev"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Git } from "./git"
|
||||
import { Global } from "./global"
|
||||
import { Database } from "./database/database"
|
||||
import { PermissionV2 } from "./permission"
|
||||
@@ -63,6 +66,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
CommandV2.locationLayer,
|
||||
AgentV2.locationLayer,
|
||||
PluginBoot.locationLayer,
|
||||
ProjectCopy.locationLayer,
|
||||
FileSystem.locationLayer,
|
||||
Watcher.locationLayer,
|
||||
Pty.locationLayer,
|
||||
@@ -98,6 +102,11 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Layer.provide(skillGuidance),
|
||||
Layer.provide(referenceGuidance),
|
||||
)
|
||||
|
||||
// Kick off a background project copy refresh to update locations now that we
|
||||
// have a location
|
||||
const projectCopyRefresh = Layer.effectDiscard(ProjectCopy.refreshAfterBoot).pipe(Layer.provide(services))
|
||||
|
||||
return Layer.mergeAll(
|
||||
boot,
|
||||
services,
|
||||
@@ -110,6 +119,7 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
runner,
|
||||
builtInTools,
|
||||
referenceGuidance,
|
||||
projectCopyRefresh,
|
||||
).pipe(Layer.fresh)
|
||||
},
|
||||
idleTimeToLive: "60 minutes",
|
||||
@@ -120,10 +130,12 @@ export class LocationServiceMap extends LayerMap.Service<LocationServiceMap>()("
|
||||
Npm.defaultLayer,
|
||||
ModelsDev.defaultLayer,
|
||||
FSUtil.defaultLayer,
|
||||
Git.defaultLayer,
|
||||
AppProcess.defaultLayer,
|
||||
Global.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
Database.defaultLayer,
|
||||
ProjectDirectories.defaultLayer,
|
||||
SessionStore.layer.pipe(Layer.provide(Database.defaultLayer)),
|
||||
PermissionSaved.defaultLayer,
|
||||
RepositoryCache.defaultLayer,
|
||||
|
||||
@@ -2,60 +2,41 @@ export * as ProjectV2 from "./project"
|
||||
export * as Project from "./project"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { asc, desc, eq } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath, withStatics } from "./schema"
|
||||
import { AbsolutePath } from "./schema"
|
||||
import { FSUtil } from "./fs-util"
|
||||
import { Database } from "./database/database"
|
||||
import { Git } from "./git"
|
||||
import { LayerNode } from "./effect/layer-node"
|
||||
import { Hash } from "./util/hash"
|
||||
import { ProjectDirectoryTable } from "./project/sql"
|
||||
import { ProjectDirectories } from "./project/directories"
|
||||
import { ProjectSchema } from "./project/schema"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Project.ID"),
|
||||
withStatics((schema) => ({
|
||||
global: schema.make("global"),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
export const ID = ProjectSchema.ID
|
||||
export type ID = ProjectSchema.ID
|
||||
|
||||
export const Vcs = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("git"),
|
||||
store: AbsolutePath,
|
||||
}),
|
||||
])
|
||||
export type Vcs = typeof Vcs.Type
|
||||
export const Vcs = ProjectSchema.Vcs
|
||||
export type Vcs = ProjectSchema.Vcs
|
||||
|
||||
export class Info extends Schema.Class<Info>("Project.Info")({
|
||||
id: ID,
|
||||
}) {}
|
||||
|
||||
export const DirectoriesInput = Schema.Struct({
|
||||
projectID: ID,
|
||||
}).annotate({ identifier: "Project.DirectoriesInput" })
|
||||
export const DirectoriesInput = ProjectDirectories.ListInput
|
||||
export type DirectoriesInput = typeof DirectoriesInput.Type
|
||||
|
||||
export const Directories = Schema.Array(
|
||||
Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["main", "root", "git_worktree"]),
|
||||
}),
|
||||
).annotate({ identifier: "Project.Directories" })
|
||||
export const Directories = ProjectDirectories.ListOutput
|
||||
export type Directories = typeof Directories.Type
|
||||
|
||||
export interface Resolved {
|
||||
readonly previous?: ID
|
||||
readonly id: ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly vcs?: Vcs
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly directories: (input: DirectoriesInput) => Effect.Effect<Directories>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<
|
||||
{
|
||||
previous?: ID
|
||||
id: ID
|
||||
directory: AbsolutePath
|
||||
vcs?: Vcs
|
||||
},
|
||||
never
|
||||
>
|
||||
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
|
||||
/**
|
||||
* Temporary bridge method for writing the resolved project ID to the repo-local cache.
|
||||
*
|
||||
@@ -73,19 +54,12 @@ 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 projectDirectories = yield* ProjectDirectories.Service
|
||||
|
||||
const directories = Effect.fn("Project.directories")(function* (input: DirectoriesInput) {
|
||||
const rows = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, input.projectID))
|
||||
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: AbsolutePath.make(row.directory), type: row.type }))
|
||||
return yield* projectDirectories.list(input.projectID)
|
||||
})
|
||||
|
||||
const cached = Effect.fnUntraced(function* (dir: string) {
|
||||
@@ -156,8 +130,8 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provideMerge(ProjectDirectories.defaultLayer),
|
||||
)
|
||||
export const node = LayerNode.make(layer, [Database.node, FSUtil.node, Git.node])
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node])
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
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"
|
||||
import { DirectoryUnavailableError, StrategyID, type ListEntry, type Strategy } from "./copy"
|
||||
|
||||
export function makeStrategies(input: {
|
||||
export function makeGitWorktreeStrategy(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",
|
||||
return {
|
||||
id: StrategyID.make("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) }
|
||||
@@ -30,18 +28,12 @@ export function makeStrategies(input: {
|
||||
const core = path.basename(found.store) === ".git" ? path.dirname(found.store) : found.store
|
||||
const entries = yield* input.git.worktreeList(found)
|
||||
return yield* Effect.forEach(entries, (entry) =>
|
||||
entry === core
|
||||
? Effect.succeed(undefined)
|
||||
: input.canonical(entry).pipe(
|
||||
Effect.map((directory) => ({ directory })),
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).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"))
|
||||
}),
|
||||
}
|
||||
input.canonical(entry).pipe(
|
||||
Effect.map((directory) => ({ directory, type: entry === core ? "root" : "copy" }) as const),
|
||||
Effect.catchTag("ProjectCopy.DirectoryUnavailableError", () => Effect.succeed(undefined)),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
|
||||
return new Map<StrategyID, Strategy>([[gitWorktree.id, gitWorktree]])
|
||||
}),
|
||||
} satisfies Strategy
|
||||
}
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
export * as ProjectCopy from "./copy"
|
||||
|
||||
import { and, eq, inArray } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "../schema"
|
||||
import { FSUtil } from "../fs-util"
|
||||
import { Git } from "../git"
|
||||
import { Database } from "../database/database"
|
||||
import { EventV2 } from "../event"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { Project } from "../project"
|
||||
import { ProjectDirectoryTable } from "./sql"
|
||||
import { makeStrategies } from "./copy-strategies"
|
||||
import { ProjectDirectories } from "./directories"
|
||||
import { makeGitWorktreeStrategy } from "./copy-strategies"
|
||||
import { Slug } from "../util/slug"
|
||||
import { EventV2 } from "../event"
|
||||
import { Database } from "../database/database"
|
||||
import { Location } from "../location"
|
||||
import { PluginBoot } from "../plugin/boot"
|
||||
|
||||
export const StrategyID = Schema.Literal("git_worktree")
|
||||
export const StrategyID = Schema.Trim.pipe(Schema.check(Schema.isNonEmpty()), Schema.brand("ProjectCopy.StrategyID"))
|
||||
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,
|
||||
name: Schema.optional(Schema.String),
|
||||
context: Schema.optional(Schema.String),
|
||||
}).annotate({ identifier: "ProjectCopy.CreateInput" })
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
@@ -44,12 +39,22 @@ export const RefreshInput = Schema.Struct({
|
||||
}).annotate({ identifier: "ProjectCopy.RefreshInput" })
|
||||
export type RefreshInput = typeof RefreshInput.Type
|
||||
|
||||
export const RefreshResult = Schema.Struct({
|
||||
updated: Schema.Array(AbsolutePath),
|
||||
removed: Schema.Array(AbsolutePath),
|
||||
}).annotate({ identifier: "ProjectCopy.RefreshResult" })
|
||||
export type RefreshResult = typeof RefreshResult.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 const ListEntry = Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
type: Schema.Literals(["root", "copy"]),
|
||||
}).annotate({ identifier: "ProjectCopy.ListEntry" })
|
||||
export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedErrorClass<SourceDirectoryNotFoundError>()(
|
||||
"ProjectCopy.SourceDirectoryNotFoundError",
|
||||
@@ -66,16 +71,27 @@ export class DirectoryUnavailableError extends Schema.TaggedErrorClass<Directory
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class StrategyNotFoundError extends Schema.TaggedErrorClass<StrategyNotFoundError>()(
|
||||
"ProjectCopy.StrategyNotFoundError",
|
||||
export class InvalidDirectoryError extends Schema.TaggedErrorClass<InvalidDirectoryError>()(
|
||||
"ProjectCopy.InvalidDirectoryError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export class StrategyUnavailableError extends Schema.TaggedErrorClass<StrategyUnavailableError>()(
|
||||
"ProjectCopy.StrategyUnavailableError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export class DuplicateStrategyError extends Schema.TaggedErrorClass<DuplicateStrategyError>()(
|
||||
"ProjectCopy.DuplicateStrategyError",
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| StrategyNotFoundError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
@@ -88,8 +104,7 @@ export interface Strategy {
|
||||
directory: AbsolutePath
|
||||
force: boolean
|
||||
}) => Effect.Effect<void, Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<Copy[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
readonly detect: (directory: AbsolutePath) => Effect.Effect<boolean>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<ListEntry[], Git.WorktreeError | DirectoryUnavailableError>
|
||||
}
|
||||
|
||||
export const Event = {
|
||||
@@ -100,21 +115,46 @@ export const Event = {
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly detect: (input: DetectInput) => Effect.Effect<StrategyID | undefined>
|
||||
readonly register: (strategy: Strategy) => Effect.Effect<void, DuplicateStrategyError>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Copy, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: (input: RefreshInput) => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectCopy") {}
|
||||
|
||||
export const refreshAfterBoot = Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const boot = yield* PluginBoot.Service
|
||||
const copies = yield* Service
|
||||
yield* Effect.gen(function* () {
|
||||
yield* boot.wait()
|
||||
yield* Effect.logInfo("project copy refresh started", { projectID: location.project.id })
|
||||
const result = yield* copies.refresh({ projectID: location.project.id })
|
||||
yield* Effect.logInfo("project copy refresh done", {
|
||||
projectID: location.project.id,
|
||||
updated: result.updated,
|
||||
removed: result.removed,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.catchCause((cause) => Effect.logWarning("project copy refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
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 directories = yield* ProjectDirectories.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const events = yield* EventV2.Service
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
|
||||
if (update) yield* events.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const canonical = Effect.fnUntraced(function* (input: AbsolutePath) {
|
||||
const resolved = AbsolutePath.make(FSUtil.resolve(input))
|
||||
@@ -122,76 +162,34 @@ export const layer = Layer.effect(
|
||||
return resolved
|
||||
})
|
||||
|
||||
const registry = makeStrategies({ git, fs, canonical })
|
||||
const registry = new Map<StrategyID, Strategy>()
|
||||
|
||||
const register = Effect.fn("ProjectCopy.register")(function* (strategy: Strategy) {
|
||||
if (registry.has(strategy.id)) return yield* new DuplicateStrategyError({ strategy: strategy.id })
|
||||
registry.set(strategy.id, strategy)
|
||||
})
|
||||
|
||||
// Register default strategies
|
||||
yield* register(makeGitWorktreeStrategy({ git, canonical })).pipe(Effect.orDie)
|
||||
|
||||
const strategies = () => Array.from(registry.values())
|
||||
|
||||
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 })
|
||||
if (!(yield* directories.contains({ projectID, directory: sourceDirectory })))
|
||||
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 getStrategy = Effect.fnUntraced(function* (id: StrategyID) {
|
||||
const found = registry.get(id)
|
||||
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("ProjectCopy.create")(function* (input: CreateInput) {
|
||||
const selected = yield* getStrategy(input.strategy)
|
||||
const sourceDirectory = yield* source(input.sourceDirectory, input.projectID)
|
||||
yield* fs.makeDirectory(input.directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
@@ -202,78 +200,100 @@ export const layer = Layer.effect(
|
||||
copyDirectory = AbsolutePath.make(path.join(input.directory, `${name}-${suffix}`))
|
||||
}
|
||||
|
||||
const result = yield* strategy(input.strategy).create({
|
||||
const result = yield* selected.create({
|
||||
directory: copyDirectory,
|
||||
sourceDirectory: yield* source(input.sourceDirectory, input.projectID),
|
||||
sourceDirectory,
|
||||
})
|
||||
yield* changed(input.projectID, yield* insert(input.projectID, result.directory, input.strategy))
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* directories.create({
|
||||
projectID: input.projectID,
|
||||
directory: result.directory,
|
||||
strategy: input.strategy,
|
||||
behavior: "replace",
|
||||
}),
|
||||
)
|
||||
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({ directory: copyDirectory, force: input.force })
|
||||
yield* changed(input.projectID, yield* removeStored(input.projectID, copyDirectory))
|
||||
const stored = yield* directories.get({ projectID: input.projectID, directory: copyDirectory })
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: copyDirectory })
|
||||
yield* (yield* getStrategy(StrategyID.make(stored.strategy))).remove({
|
||||
directory: copyDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
yield* changed(
|
||||
input.projectID,
|
||||
yield* directories.remove({ projectID: input.projectID, directory: 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 stored = yield* directories.list(input.projectID)
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const sourceDirectories = checked
|
||||
.filter((item) => item.strategy === undefined && item.exists)
|
||||
.map((item) => item.directory)
|
||||
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 })))),
|
||||
Effect.forEach(strategies(), (strategy) =>
|
||||
strategy.list(sourceDirectory).pipe(
|
||||
Effect.map((items) =>
|
||||
items.map((item) => ({
|
||||
directory: item.directory,
|
||||
strategy: item.type === "copy" ? strategy.id : undefined,
|
||||
})),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ 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)),
|
||||
const removed = checked.filter((item) => !item.exists).map((item) => item.directory)
|
||||
const result = yield* db
|
||||
.transaction((tx) =>
|
||||
Effect.all({
|
||||
updated: Effect.forEach(discovered, (item) =>
|
||||
directories.create(
|
||||
{
|
||||
projectID: input.projectID,
|
||||
directory: item.directory,
|
||||
strategy: item.strategy,
|
||||
behavior: "replace",
|
||||
},
|
||||
tx,
|
||||
),
|
||||
),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.some(Boolean)))
|
||||
yield* changed(input.projectID, inserted || removed)
|
||||
removed: Effect.forEach(removed, (directory) =>
|
||||
directories.remove({ projectID: input.projectID, directory }, tx),
|
||||
),
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
const changes = {
|
||||
updated: discovered.filter((_, index) => result.updated[index]).map((item) => item.directory),
|
||||
removed: removed.filter((_, index) => result.removed[index]),
|
||||
}
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
|
||||
return Service.of({ detect, create, remove, refresh })
|
||||
return Service.of({
|
||||
register,
|
||||
create,
|
||||
remove,
|
||||
refresh,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Database.defaultLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(EventV2.defaultLayer),
|
||||
)
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, EventV2.node, Database.node])
|
||||
export const locationLayer = layer
|
||||
export const node = LayerNode.make(layer, [FSUtil.node, Git.node, ProjectDirectories.node, EventV2.node, Database.node])
|
||||
|
||||
162
packages/core/src/project/directories.ts
Normal file
162
packages/core/src/project/directories.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
export * as ProjectDirectories from "./directories"
|
||||
|
||||
import { and, asc, desc, eq, isNotNull, isNull, ne, or } from "drizzle-orm"
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "../database/database"
|
||||
import { LayerNode } from "../effect/layer-node"
|
||||
import { AbsolutePath, optionalOmitUndefined } from "../schema"
|
||||
import { ProjectSchema } from "./schema"
|
||||
import { ProjectDirectoryTable } from "./sql"
|
||||
import type { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
|
||||
|
||||
export interface Directory {
|
||||
readonly directory: AbsolutePath
|
||||
readonly strategy?: string
|
||||
}
|
||||
|
||||
export const CreateInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
directory: AbsolutePath,
|
||||
strategy: Schema.optional(Schema.String),
|
||||
behavior: Schema.Literals(["ignore", "replace"]).pipe(Schema.optional),
|
||||
})
|
||||
export type CreateInput = typeof CreateInput.Type
|
||||
|
||||
export const RemoveInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
directory: AbsolutePath,
|
||||
})
|
||||
export type RemoveInput = typeof RemoveInput.Type
|
||||
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
export type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export const ListInput = Schema.Struct({
|
||||
projectID: ProjectSchema.ID,
|
||||
}).annotate({ identifier: "Project.DirectoriesInput" })
|
||||
export type ListInput = typeof ListInput.Type
|
||||
|
||||
export const ListOutput = Schema.Array(
|
||||
Schema.Struct({
|
||||
directory: AbsolutePath,
|
||||
strategy: optionalOmitUndefined(Schema.String),
|
||||
}),
|
||||
).annotate({ identifier: "Project.Directories" })
|
||||
export type ListOutput = typeof ListOutput.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (projectID: ProjectSchema.ID) => Effect.Effect<ReadonlyArray<Directory>>
|
||||
readonly get: (input: {
|
||||
projectID: ProjectSchema.ID
|
||||
directory: AbsolutePath
|
||||
}) => Effect.Effect<Directory | undefined>
|
||||
readonly contains: (input: { projectID: ProjectSchema.ID; directory: AbsolutePath }) => Effect.Effect<boolean>
|
||||
readonly create: (input: CreateInput, tx?: Transaction) => Effect.Effect<boolean>
|
||||
readonly remove: (input: RemoveInput, tx?: Transaction) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ProjectDirectories") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const db = (yield* Database.Service).db
|
||||
|
||||
const create = Effect.fn("ProjectDirectories.create")(function* (input: CreateInput, tx?: Transaction) {
|
||||
const insert = (tx ?? db)
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: input.directory, strategy: input.strategy })
|
||||
const query =
|
||||
input.behavior === "replace"
|
||||
? insert.onConflictDoUpdate({
|
||||
target: [ProjectDirectoryTable.project_id, ProjectDirectoryTable.directory],
|
||||
set: { strategy: input.strategy ?? null },
|
||||
setWhere: input.strategy
|
||||
? or(isNull(ProjectDirectoryTable.strategy), ne(ProjectDirectoryTable.strategy, input.strategy))
|
||||
: isNotNull(ProjectDirectoryTable.strategy),
|
||||
})
|
||||
: insert.onConflictDoNothing()
|
||||
return (
|
||||
(yield* query
|
||||
.returning({ directory: ProjectDirectoryTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const remove = Effect.fn("ProjectDirectories.remove")(function* (input: RemoveInput, tx?: Transaction) {
|
||||
return (
|
||||
(yield* (tx ?? db)
|
||||
.delete(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
||||
eq(ProjectDirectoryTable.directory, input.directory),
|
||||
),
|
||||
)
|
||||
.returning({ directory: ProjectDirectoryTable.directory })
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const list = Effect.fn("ProjectDirectories.list")(function* (projectID: ProjectSchema.ID) {
|
||||
const rows = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.orderBy(desc(ProjectDirectoryTable.time_created), asc(ProjectDirectoryTable.directory))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return rows.map((row) => ({ directory: row.directory, strategy: row.strategy ?? undefined }))
|
||||
})
|
||||
|
||||
const contains = Effect.fn("ProjectDirectories.contains")(function* (input: {
|
||||
projectID: ProjectSchema.ID
|
||||
directory: AbsolutePath
|
||||
}) {
|
||||
return (
|
||||
(yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
||||
eq(ProjectDirectoryTable.directory, input.directory),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)) !== undefined
|
||||
)
|
||||
})
|
||||
|
||||
const get = Effect.fn("ProjectDirectories.get")(function* (input: {
|
||||
projectID: ProjectSchema.ID
|
||||
directory: AbsolutePath
|
||||
}) {
|
||||
const row = yield* db
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(
|
||||
and(
|
||||
eq(ProjectDirectoryTable.project_id, input.projectID),
|
||||
eq(ProjectDirectoryTable.directory, input.directory),
|
||||
),
|
||||
)
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
return row ? { directory: row.directory, strategy: row.strategy ?? undefined } : undefined
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
list,
|
||||
get,
|
||||
contains,
|
||||
create,
|
||||
remove,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Database.defaultLayer))
|
||||
export const node = LayerNode.make(layer, [Database.node])
|
||||
20
packages/core/src/project/schema.ts
Normal file
20
packages/core/src/project/schema.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export * as ProjectSchema from "./schema"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { AbsolutePath, withStatics } from "../schema"
|
||||
|
||||
export const ID = Schema.String.pipe(
|
||||
Schema.brand("Project.ID"),
|
||||
withStatics((schema) => ({
|
||||
global: schema.make("global"),
|
||||
})),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Vcs = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("git"),
|
||||
store: AbsolutePath,
|
||||
}),
|
||||
])
|
||||
export type Vcs = typeof Vcs.Type
|
||||
@@ -1,10 +1,10 @@
|
||||
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"
|
||||
import { ProjectSchema } from "./schema"
|
||||
|
||||
export const ProjectTable = sqliteTable("project", {
|
||||
id: text().$type<ProjectV2.ID>().primaryKey(),
|
||||
id: text().$type<ProjectSchema.ID>().primaryKey(),
|
||||
worktree: DatabasePath.absoluteColumn().notNull(),
|
||||
vcs: text(),
|
||||
name: text(),
|
||||
@@ -21,11 +21,12 @@ export const ProjectDirectoryTable = sqliteTable(
|
||||
"project_directory",
|
||||
{
|
||||
project_id: text()
|
||||
.$type<ProjectV2.ID>()
|
||||
.$type<ProjectSchema.ID>()
|
||||
.notNull()
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
directory: text().notNull(),
|
||||
type: text().$type<"main" | "root" | "git_worktree">().notNull(),
|
||||
directory: DatabasePath.absoluteColumn().notNull(),
|
||||
type: text().$type<"main" | "root" | "git_worktree">(),
|
||||
strategy: text(),
|
||||
time_created: integer()
|
||||
.notNull()
|
||||
.$default(() => Date.now()),
|
||||
|
||||
@@ -27,7 +27,7 @@ import { ApplicationTools } from "../src/tool/application-tools"
|
||||
const applicationTools = ApplicationTools.layer
|
||||
const it = testEffect(
|
||||
Layer.merge(
|
||||
applicationTools,
|
||||
Layer.mergeAll(applicationTools, Database.defaultLayer, EventV2.defaultLayer),
|
||||
LocationServiceMap.layer.pipe(
|
||||
Layer.provide(applicationTools),
|
||||
Layer.provide(
|
||||
@@ -135,4 +135,5 @@ describe("LocationServiceMap", () => {
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Git } from "@opencode-ai/core/git"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
@@ -22,11 +23,13 @@ import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
|
||||
const projector = SessionProjector.layer.pipe(Layer.provide(database), Layer.provide(events))
|
||||
const project = Project.layer.pipe(
|
||||
Layer.provide(database),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(directories),
|
||||
)
|
||||
const store = SessionStore.layer.pipe(Layer.provide(database))
|
||||
const sessions = SessionV2.layer.pipe(
|
||||
@@ -45,7 +48,7 @@ const layer = MoveSession.layer.pipe(
|
||||
Layer.provide(sessions),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(layer, database, events, project, projector, store, SessionExecution.noopLayer, sessions),
|
||||
Layer.mergeAll(layer, database, events, directories, project, projector, store, SessionExecution.noopLayer, sessions),
|
||||
)
|
||||
|
||||
function abs(input: string) {
|
||||
|
||||
@@ -12,23 +12,28 @@ 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 { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const databaseLayer = Database.layerFromPath(":memory:")
|
||||
const eventLayer = EventV2.layer.pipe(Layer.provide(databaseLayer))
|
||||
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
|
||||
const copyLayer = ProjectCopy.layer.pipe(
|
||||
Layer.provide(databaseLayer),
|
||||
Layer.provide(directoriesLayer),
|
||||
Layer.provide(eventLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer))
|
||||
const it = testEffect(Layer.mergeAll(copyLayer, databaseLayer, eventLayer, directoriesLayer))
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
}
|
||||
|
||||
const gitWorktree = ProjectCopy.StrategyID.make("git_worktree")
|
||||
|
||||
async function initRepo(directory: string) {
|
||||
await $`git init`.cwd(directory).quiet()
|
||||
await $`git config core.fsmonitor false`.cwd(directory).quiet()
|
||||
@@ -55,7 +60,7 @@ function setup() {
|
||||
.pipe(Effect.orDie)
|
||||
yield* db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: projectID, directory: sourceDirectory, type: "main" })
|
||||
.values({ project_id: projectID, directory: sourceDirectory })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
return { root, sourceDirectory, projectID, db }
|
||||
@@ -65,7 +70,7 @@ function setup() {
|
||||
function stored(projectID: Project.ID) {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.select({ directory: ProjectDirectoryTable.directory, type: ProjectDirectoryTable.type })
|
||||
.select({ directory: ProjectDirectoryTable.directory, strategy: ProjectDirectoryTable.strategy })
|
||||
.from(ProjectDirectoryTable)
|
||||
.where(eq(ProjectDirectoryTable.project_id, projectID))
|
||||
.all()
|
||||
@@ -77,18 +82,40 @@ function stored(projectID: Project.ID) {
|
||||
}
|
||||
|
||||
describe("ProjectCopy", () => {
|
||||
it.live("detects linked git worktrees but not root checkouts", () =>
|
||||
it.effect("accepts arbitrary non-empty strategy ids", () =>
|
||||
Effect.sync(() => {
|
||||
expect(String(ProjectCopy.StrategyID.make("acme/snapshot"))).toBe("acme/snapshot")
|
||||
expect(() => ProjectCopy.StrategyID.make(" acme/snapshot ")).toThrow()
|
||||
expect(() => ProjectCopy.StrategyID.make(" ")).toThrow()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects duplicate strategies and reports unavailable ids", () =>
|
||||
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())
|
||||
const strategy: ProjectCopy.Strategy = {
|
||||
id: ProjectCopy.StrategyID.make("test/duplicate"),
|
||||
create: () => Effect.die("unused"),
|
||||
remove: () => Effect.die("unused"),
|
||||
list: () => Effect.succeed([]),
|
||||
}
|
||||
|
||||
expect(yield* copy.detect({ directory: input.sourceDirectory })).toBeUndefined()
|
||||
expect(yield* copy.detect({ directory: target })).toBe("git_worktree")
|
||||
yield* copy.register(strategy)
|
||||
expect(yield* copy.register(strategy).pipe(Effect.flip)).toBeInstanceOf(ProjectCopy.DuplicateStrategyError)
|
||||
|
||||
const unavailable = ProjectCopy.StrategyID.make("acme/missing")
|
||||
const error = yield* copy
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: unavailable,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: abs(`${input.root.path}-missing-strategy`),
|
||||
name: "copy",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
|
||||
if (error instanceof ProjectCopy.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -110,7 +137,7 @@ describe("ProjectCopy", () => {
|
||||
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: "git_worktree",
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "copy",
|
||||
@@ -118,15 +145,15 @@ describe("ProjectCopy", () => {
|
||||
expect(created.directory).toBe(target)
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, type: "main" as const },
|
||||
{ directory: created.directory, type: "git_worktree" as const },
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: created.directory, strategy: "git_worktree" },
|
||||
].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, force: false })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, type: "main" as const }])
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
expect(yield* Effect.promise(() => Bun.file(target).exists())).toBe(false)
|
||||
}),
|
||||
)
|
||||
@@ -142,7 +169,7 @@ describe("ProjectCopy", () => {
|
||||
)
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: "git_worktree",
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "copy",
|
||||
@@ -158,7 +185,7 @@ describe("ProjectCopy", () => {
|
||||
expect(error.operation).toBe("remove")
|
||||
expect(error.forceRequired).toBe(true)
|
||||
}
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, type: "git_worktree" })
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: created.directory, strategy: "git_worktree" })
|
||||
expect(yield* Effect.promise(() => Bun.file(path.join(created.directory, "dirty.txt")).exists())).toBe(true)
|
||||
|
||||
yield* copy.remove({ projectID: input.projectID, directory: created.directory, force: true })
|
||||
@@ -166,6 +193,28 @@ describe("ProjectCopy", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("preserves copies whose stored strategy is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const copy = yield* ProjectCopy.Service
|
||||
const unavailable = abs(`${input.root.path}-copy-unavailable`)
|
||||
yield* Effect.promise(() => fs.mkdir(unavailable))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
|
||||
yield* input.db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: unavailable, strategy: "acme/missing" })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const error = yield* copy
|
||||
.remove({ projectID: input.projectID, directory: unavailable, force: false })
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ProjectCopy.StrategyUnavailableError)
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: unavailable, strategy: "acme/missing" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("adds a numeric suffix when a copy directory already exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
@@ -181,7 +230,7 @@ describe("ProjectCopy", () => {
|
||||
|
||||
const created = yield* copy.create({
|
||||
projectID: input.projectID,
|
||||
strategy: "git_worktree",
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "copy",
|
||||
@@ -219,7 +268,7 @@ describe("ProjectCopy", () => {
|
||||
const error = yield* copy
|
||||
.create({
|
||||
projectID: input.projectID,
|
||||
strategy: "git_worktree",
|
||||
strategy: gitWorktree,
|
||||
sourceDirectory: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "copy",
|
||||
@@ -227,7 +276,8 @@ describe("ProjectCopy", () => {
|
||||
.pipe(Effect.flip)
|
||||
|
||||
expect(error).toBeInstanceOf(ProjectCopy.DestinationExistsError)
|
||||
expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
|
||||
if (error instanceof ProjectCopy.DestinationExistsError)
|
||||
expect(error.directory).toBe(abs(path.join(parent, "copy-10")))
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -263,25 +313,30 @@ describe("ProjectCopy", () => {
|
||||
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())
|
||||
yield* input.db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: target })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
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* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [discovered], removed: [] })
|
||||
|
||||
expect(yield* stored(input.projectID)).toEqual(
|
||||
[
|
||||
{ directory: input.sourceDirectory, type: "main" as const },
|
||||
{ directory: discovered, type: "git_worktree" as const },
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: discovered, strategy: "git_worktree" },
|
||||
].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 }])
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [discovered] })
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -303,8 +358,8 @@ describe("ProjectCopy", () => {
|
||||
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 },
|
||||
{ directory: input.sourceDirectory, strategy: null },
|
||||
{ directory: discovered, strategy: "git_worktree" },
|
||||
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
|
||||
)
|
||||
}),
|
||||
@@ -314,7 +369,27 @@ describe("ProjectCopy", () => {
|
||||
Effect.gen(function* () {
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
yield* copy.refresh({ projectID: Project.ID.make("missing-project") })
|
||||
expect(yield* copy.refresh({ projectID: Project.ID.make("missing-project") })).toEqual({
|
||||
updated: [],
|
||||
removed: [],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh removes missing ordinary checkouts", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const missing = abs(`${input.root.path}-missing-checkout`)
|
||||
yield* input.db
|
||||
.insert(ProjectDirectoryTable)
|
||||
.values({ project_id: input.projectID, directory: missing })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const copy = yield* ProjectCopy.Service
|
||||
|
||||
expect(yield* copy.refresh({ projectID: input.projectID })).toEqual({ updated: [], removed: [missing] })
|
||||
|
||||
expect(yield* stored(input.projectID)).not.toContainEqual({ directory: missing, strategy: null })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
69
packages/core/test/project-directories.test.ts
Normal file
69
packages/core/test/project-directories.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const database = Database.layerFromPath(":memory:")
|
||||
const events = EventV2.layer.pipe(Layer.provide(database))
|
||||
const directories = ProjectDirectories.layer.pipe(Layer.provide(database), Layer.provide(events))
|
||||
const it = testEffect(Layer.mergeAll(database, events, directories))
|
||||
|
||||
const projectID = Project.ID.make("project-directories")
|
||||
const directory = AbsolutePath.make("/tmp/project-directories")
|
||||
|
||||
function setup() {
|
||||
return Database.Service.use(({ db }) =>
|
||||
db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: projectID, worktree: directory, sandboxes: [], time_created: 1, time_updated: 1 })
|
||||
.onConflictDoNothing()
|
||||
.run()
|
||||
.pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
|
||||
describe("ProjectDirectories", () => {
|
||||
it.effect("decodes directory schemas", () =>
|
||||
Effect.sync(() => {
|
||||
expect(Schema.decodeUnknownSync(ProjectDirectories.ListInput)({ projectID })).toEqual({ projectID })
|
||||
expect(Schema.decodeUnknownSync(ProjectDirectories.ListOutput)([{ directory }])).toEqual([{ directory }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("creates once and ignores conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* ProjectDirectories.Service
|
||||
|
||||
expect(yield* service.create({ projectID, directory })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, strategy: "git_worktree" })).toBe(false)
|
||||
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: undefined }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replaces the strategy when requested", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* setup()
|
||||
const service = yield* ProjectDirectories.Service
|
||||
yield* service.create({ projectID, directory, strategy: "old/strategy" })
|
||||
|
||||
expect(
|
||||
yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" }),
|
||||
).toBe(true)
|
||||
expect(
|
||||
yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" }),
|
||||
).toBe(false)
|
||||
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(true)
|
||||
expect(yield* service.create({ projectID, directory, behavior: "replace" })).toBe(false)
|
||||
expect(
|
||||
yield* service.create({ projectID, directory, strategy: "new/strategy", behavior: "replace" }),
|
||||
).toBe(true)
|
||||
expect(yield* service.list(projectID)).toEqual([{ directory, strategy: "new/strategy" }])
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -4,24 +4,27 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
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 { ProjectDirectories } from "@opencode-ai/core/project/directories"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const databaseLayer = Database.layerFromPath(":memory:")
|
||||
const directoriesLayer = ProjectDirectories.layer.pipe(Layer.provide(databaseLayer))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
ProjectV2.layer.pipe(
|
||||
Layer.provide(databaseLayer),
|
||||
Layer.provide(FSUtil.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(directoriesLayer),
|
||||
Layer.provide(databaseLayer),
|
||||
),
|
||||
databaseLayer,
|
||||
directoriesLayer,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -51,54 +54,6 @@ 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)([
|
||||
{ directory: AbsolutePath.make("/tmp/project"), type: "main" },
|
||||
]),
|
||||
).toEqual([{ directory: AbsolutePath.make("/tmp/project"), type: "main" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lists stored project directories newest first 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", time_created: 2 },
|
||||
{ project_id: projectID, directory: AbsolutePath.make("/repo/a"), type: "main", time_created: 1 },
|
||||
{ project_id: otherID, directory: AbsolutePath.make("/other"), type: "main", time_created: 3 },
|
||||
])
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
expect(yield* project.directories({ projectID })).toEqual([
|
||||
{ directory: AbsolutePath.make("/repo/z"), type: "root" },
|
||||
{ directory: AbsolutePath.make("/repo/a"), type: "main" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("ProjectV2.resolve", () => {
|
||||
it.live("returns global for non-git directory", () =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
Reference in New Issue
Block a user