diff --git a/internal/setup/plugins/opencode/engram.ts b/internal/setup/plugins/opencode/engram.ts index c5567087..51ab1878 100644 --- a/internal/setup/plugins/opencode/engram.ts +++ b/internal/setup/plugins/opencode/engram.ts @@ -153,7 +153,12 @@ async function isEngramRunning(): Promise { // ─── Helpers ───────────────────────────────────────────────────────────────── -function extractProjectName(directory: string): string { +function basename(path: string): string { + const name = path.split(/[\\/]/).filter(Boolean).pop() + return name && !/^[A-Za-z]:$/.test(name) ? name : "unknown" +} + +function extractProjectNames(directory: string): { oldProject: string; project: string } { // Try git remote origin URL try { const result = Bun.spawnSync(["git", "-C", directory, "remote", "get-url", "origin"]) @@ -161,7 +166,7 @@ function extractProjectName(directory: string): string { const url = result.stdout?.toString().trim() if (url) { const name = url.replace(/\.git$/, "").split(/[/:]/).pop() - if (name) return name + if (name) return { oldProject: name, project: name } } } } catch {} @@ -171,12 +176,20 @@ function extractProjectName(directory: string): string { const result = Bun.spawnSync(["git", "-C", directory, "rev-parse", "--show-toplevel"]) if (result.exitCode === 0) { const root = result.stdout?.toString().trim() - if (root) return root.split("/").pop() ?? "unknown" + if (root) { + return { + oldProject: root.split("/").pop() ?? "unknown", + project: basename(root), + } + } } } catch {} // Final fallback: cwd basename - return directory.split("/").pop() ?? "unknown" + return { + oldProject: directory.split("/").pop() ?? "unknown", + project: basename(directory), + } } function truncate(str: string, max: number): string { @@ -197,8 +210,7 @@ function stripPrivateTags(str: string): string { // ─── Plugin Export ─────────────────────────────────────────────────────────── export const Engram: Plugin = async (ctx) => { - const oldProject = ctx.directory.split("/").pop() ?? "unknown" - const project = extractProjectName(ctx.directory) + const { oldProject, project } = extractProjectNames(ctx.directory) // Track tool counts per session (in-memory only, not critical) const toolCounts = new Map() diff --git a/plugin/opencode/engram.test.ts b/plugin/opencode/engram.test.ts new file mode 100644 index 00000000..1e953f14 --- /dev/null +++ b/plugin/opencode/engram.test.ts @@ -0,0 +1,158 @@ +import assert from "node:assert/strict" +import { test } from "node:test" + +import { Engram } from "./engram.ts" + +type SpawnResult = { + exitCode: number + stdout?: Buffer +} + +async function createPlugin( + directory: string, + spawnSync: (command: string[]) => SpawnResult, +) { + const requests: Array<{ path: string; body?: unknown }> = [] + + globalThis.Bun = { + spawnSync, + file: () => ({ exists: async () => false }), + } as typeof Bun + + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input.toString()) + const body = init?.body ? JSON.parse(init.body.toString()) : undefined + requests.push({ path: url.pathname, body }) + + return new Response(JSON.stringify({}), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + }) as typeof fetch + + const plugin = await Engram({ directory } as never) + return { plugin, requests } +} + +test("uses the Windows basename in compaction recovery outside Git", async () => { + const { plugin, requests } = await createPlugin("C:\\Users\\Blackie", () => ({ exitCode: 1 })) + const output = { context: [] as string[] } + + await plugin["experimental.session.compacting"]?.( + { sessionID: "session-652" } as never, + output, + ) + + assert.match(output.context.at(-1) ?? "", /Use project: 'Blackie'/) + assert.doesNotMatch(output.context.at(-1) ?? "", /C:\\Users\\Blackie/) + assert.deepEqual( + requests.find(({ path }) => path === "/projects/migrate")?.body, + { old_project: "C:\\Users\\Blackie", new_project: "Blackie" }, + ) +}) + +test("ignores trailing Windows separators in the basename fallback", async () => { + const { plugin, requests } = await createPlugin("C:\\Users\\Blackie\\", () => ({ exitCode: 1 })) + const output = { context: [] as string[] } + + await plugin["experimental.session.compacting"]?.( + { sessionID: "session-652-trailing" } as never, + output, + ) + + assert.match(output.context.at(-1) ?? "", /Use project: 'Blackie'/) + assert.deepEqual( + requests.find(({ path }) => path === "/projects/migrate")?.body, + { old_project: "C:\\Users\\Blackie\\", new_project: "Blackie" }, + ) +}) + +test("uses the Windows basename from the Git-root fallback", async () => { + const { plugin } = await createPlugin("C:\\Users\\Blackie\\project", (command) => { + if (command.includes("rev-parse")) { + return { + exitCode: 0, + stdout: Buffer.from("C:\\Users\\Blackie\\worktrees\\engram-652\n"), + } + } + return { exitCode: 1 } + }) + const output = { context: [] as string[] } + + await plugin["experimental.session.compacting"]?.( + { sessionID: "session-652-git-root" } as never, + output, + ) + + assert.match(output.context.at(-1) ?? "", /Use project: 'engram-652'/) +}) + +test("does not migrate when the legacy remote project is unchanged", async () => { + const { requests } = await createPlugin("C:\\Users\\Blackie", (command) => { + if (command.includes("get-url")) { + return { + exitCode: 0, + stdout: Buffer.from("https://github.com/Gentleman-Programming/engram.git\n"), + } + } + return { exitCode: 1 } + }) + + assert.equal(requests.some(({ path }) => path === "/projects/migrate"), false) +}) + +test("migrates the legacy Windows Git-root key from a nested directory", async () => { + const { requests } = await createPlugin("C:\\repos\\engram\\nested", (command) => { + if (command.includes("rev-parse")) { + return { + exitCode: 0, + stdout: Buffer.from("C:\\repos\\engram\n"), + } + } + return { exitCode: 1 } + }) + + assert.deepEqual( + requests.find(({ path }) => path === "/projects/migrate")?.body, + { old_project: "C:\\repos\\engram", new_project: "engram" }, + ) +}) + +test("preserves POSIX basename fallback behavior", async () => { + const { plugin } = await createPlugin("/home/blackie", () => ({ exitCode: 1 })) + const output = { context: [] as string[] } + + await plugin["experimental.session.compacting"]?.( + { sessionID: "session-posix" } as never, + output, + ) + + assert.match(output.context.at(-1) ?? "", /Use project: 'blackie'/) +}) + +test("migrates the legacy empty POSIX key for a trailing separator", async () => { + const { requests } = await createPlugin("/home/blackie/", () => ({ exitCode: 1 })) + + assert.deepEqual( + requests.find(({ path }) => path === "/projects/migrate")?.body, + { old_project: "", new_project: "blackie" }, + ) +}) + +for (const [directory, oldProject] of [["C:\\", "C:\\"], ["C:/", ""]]) { + test(`uses unknown for the Windows drive root ${directory}`, async () => { + const { plugin, requests } = await createPlugin(directory, () => ({ exitCode: 1 })) + const output = { context: [] as string[] } + + await plugin["experimental.session.compacting"]?.( + { sessionID: `session-drive-root-${directory}` } as never, + output, + ) + + assert.match(output.context.at(-1) ?? "", /Use project: 'unknown'/) + assert.deepEqual( + requests.find(({ path }) => path === "/projects/migrate")?.body, + { old_project: oldProject, new_project: "unknown" }, + ) + }) +} diff --git a/plugin/opencode/engram.ts b/plugin/opencode/engram.ts index c5567087..51ab1878 100644 --- a/plugin/opencode/engram.ts +++ b/plugin/opencode/engram.ts @@ -153,7 +153,12 @@ async function isEngramRunning(): Promise { // ─── Helpers ───────────────────────────────────────────────────────────────── -function extractProjectName(directory: string): string { +function basename(path: string): string { + const name = path.split(/[\\/]/).filter(Boolean).pop() + return name && !/^[A-Za-z]:$/.test(name) ? name : "unknown" +} + +function extractProjectNames(directory: string): { oldProject: string; project: string } { // Try git remote origin URL try { const result = Bun.spawnSync(["git", "-C", directory, "remote", "get-url", "origin"]) @@ -161,7 +166,7 @@ function extractProjectName(directory: string): string { const url = result.stdout?.toString().trim() if (url) { const name = url.replace(/\.git$/, "").split(/[/:]/).pop() - if (name) return name + if (name) return { oldProject: name, project: name } } } } catch {} @@ -171,12 +176,20 @@ function extractProjectName(directory: string): string { const result = Bun.spawnSync(["git", "-C", directory, "rev-parse", "--show-toplevel"]) if (result.exitCode === 0) { const root = result.stdout?.toString().trim() - if (root) return root.split("/").pop() ?? "unknown" + if (root) { + return { + oldProject: root.split("/").pop() ?? "unknown", + project: basename(root), + } + } } } catch {} // Final fallback: cwd basename - return directory.split("/").pop() ?? "unknown" + return { + oldProject: directory.split("/").pop() ?? "unknown", + project: basename(directory), + } } function truncate(str: string, max: number): string { @@ -197,8 +210,7 @@ function stripPrivateTags(str: string): string { // ─── Plugin Export ─────────────────────────────────────────────────────────── export const Engram: Plugin = async (ctx) => { - const oldProject = ctx.directory.split("/").pop() ?? "unknown" - const project = extractProjectName(ctx.directory) + const { oldProject, project } = extractProjectNames(ctx.directory) // Track tool counts per session (in-memory only, not critical) const toolCounts = new Map()