diff --git a/packages/opencode/rift-workspace.ts b/packages/opencode/rift-workspace.ts index 9ad0727..1e69beb 100644 --- a/packages/opencode/rift-workspace.ts +++ b/packages/opencode/rift-workspace.ts @@ -36,7 +36,7 @@ type RiftAdapterOptions = { type ExperimentalWorkspaceInput = { experimental_workspace?: { register(type: string, adapter: WorkspaceAdapter): void }; - project?: { id?: string; path?: string }; + project?: { id?: string; worktree?: string }; worktree?: string; directory?: string; }; @@ -70,6 +70,28 @@ function workspaceDirectory(sourceDirectory: string, name: string) { return path.join(managedRoot(sourceDirectory), name); } +function availableWorkspaceName(rift: RiftModule, sourceDirectory: string, requested: string) { + let existing: Set; + try { + existing = new Set(rift.list({ of: sourceDirectory }).map((directory) => path.basename(directory))); + } catch { + return requested; + } + if (!existing.has(requested)) return requested; + let suffix = 2; + while (existing.has(`${requested}-${suffix}`)) suffix += 1; + return `${requested}-${suffix}`; +} + +export function resolveRiftSourceDirectory(directory: string) { + const resolved = path.resolve(directory); + const marker = `${path.sep}.rifts${path.sep}`; + const markerIndex = resolved.indexOf(marker); + if (markerIndex < 0) return resolved; + const namespace = resolved.slice(markerIndex + marker.length).split(path.sep)[0]; + return namespace ? path.join(resolved.slice(0, markerIndex), namespace) : resolved; +} + function requireDirectory(config: WorkspaceInfo) { if (!config.directory) throw new Error("Rift workspace is missing a directory"); return config.directory; @@ -82,7 +104,7 @@ export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapte name: "Rift", description: "Create a copy-on-write Rift workspace", configure(config) { - const name = workspaceName(config); + const name = availableWorkspaceName(rift, sourceDirectory, workspaceName(config)); return { ...config, type: "rift", @@ -136,10 +158,11 @@ export function createRiftWorkspaceAdapter(rift: RiftModule, options: RiftAdapte export async function registerRiftWorkspaceAdapter(input: ExperimentalWorkspaceInput, logger: Logger) { const registrar = input.experimental_workspace; const projectID = input.project?.id; - // A plugin loaded inside a managed workspace still belongs to the stable project checkout. - // Using input.worktree here would recursively nest new Rifts below the current Rift. - const sourceDirectory = input.project?.path ?? input.directory ?? input.worktree; - if (!registrar || !projectID || !sourceDirectory) return; + // Plugin directory/worktree values may identify a managed workspace. The project's + // worktree is OpenCode's canonical checkout and remains stable across workspaces. + const sourceCandidate = input.project?.worktree ?? input.directory ?? input.worktree; + if (!registrar || !projectID || !sourceCandidate) return; + const sourceDirectory = resolveRiftSourceDirectory(sourceCandidate); let rift: RiftModule; try { diff --git a/packages/opencode/test/tool-registration.test.ts b/packages/opencode/test/tool-registration.test.ts index e615b48..e9fedc7 100644 --- a/packages/opencode/test/tool-registration.test.ts +++ b/packages/opencode/test/tool-registration.test.ts @@ -7,7 +7,7 @@ import path from "node:path"; import { promisify } from "node:util"; import { createOpenCodeTools, OpenCodeCompassPlugin } from "../index.ts"; -import { createRiftWorkspaceAdapter } from "../rift-workspace.ts"; +import { createRiftWorkspaceAdapter, resolveRiftSourceDirectory } from "../rift-workspace.ts"; const execFileAsync = promisify(execFile); @@ -145,7 +145,9 @@ describe("createOpenCodeTools", () => { }, list: (options) => { calls.push({ name: "list", options }); - return [path.join(path.dirname(sourceDirectory), ".rifts", path.basename(sourceDirectory), "parser-fix")]; + return calls.filter((call) => call.name === "list").length === 1 + ? [] + : [path.join(path.dirname(sourceDirectory), ".rifts", path.basename(sourceDirectory), "parser-fix")]; }, }, { sourceDirectory, projectID: "project-1" }); @@ -168,7 +170,7 @@ describe("createOpenCodeTools", () => { assert.deepEqual(target, { type: "local", directory: configured.directory }); assert.equal(listed?.[0]?.type, "rift"); assert.equal(listed?.[0]?.projectID, "project-1"); - assert.deepEqual(calls.map((call) => call.name), ["init", "create", "list", "remove"]); + assert.deepEqual(calls.map((call) => call.name), ["list", "init", "create", "list", "remove"]); assert.deepEqual(calls.find((call) => call.name === "create")?.options, { from: sourceDirectory, name: "parser-fix", @@ -199,6 +201,36 @@ describe("createOpenCodeTools", () => { assert.deepEqual(removed, [path.join(os.tmpdir(), "unexpected-rift")]); }); + test("Rift workspace adapter suffixes a registered workspace name", async () => { + const sourceDirectory = path.join(os.tmpdir(), "kompass-rift-source"); + const existing = path.join(path.dirname(sourceDirectory), ".rifts", path.basename(sourceDirectory), "parser-fix"); + const adapter = createRiftWorkspaceAdapter({ + init: () => null, + create: () => "", + remove: () => undefined, + list: () => [existing], + }, { sourceDirectory, projectID: "project-1" }); + const configured = await adapter.configure({ + id: "wrk_1", + type: "rift", + name: "Parser Fix", + branch: null, + directory: null, + extra: null, + projectID: "project-1", + }); + + assert.equal(configured.name, "parser-fix-2"); + assert.equal(configured.directory, `${existing}-2`); + }); + + test("Rift source resolution unwraps a removed managed workspace", () => { + assert.equal( + resolveRiftSourceDirectory("/projects/.rifts/repo/removed-workspace"), + "/projects/repo", + ); + }); + test("registers Navigator by default", async () => { await withTempHome(async () => { const navigatorClient = {