diff --git a/claims.json b/claims.json index f601fdb..d54a3d4 100644 --- a/claims.json +++ b/claims.json @@ -14,8 +14,8 @@ "networkMillis": 300 }, "tests": { - "count": 1503, - "assertions": 7211, - "files": 235 + "count": 1505, + "assertions": 7218, + "files": 236 } } diff --git a/src/cli/branch.ts b/src/cli/branch.ts new file mode 100644 index 0000000..c98fd1a --- /dev/null +++ b/src/cli/branch.ts @@ -0,0 +1,56 @@ +import { requireRoot, usageError } from "./shared.ts"; +import type { ParsedArgs } from "./shared.ts"; +import { + createBranchOverlay, + currentGitBranch, + dropBranchOverlay, + listBranchMemories, + loadBranchOverlay, + mergeBranchOverlay, +} from "../store/branch.ts"; + +export async function handleBranchCommand({ positional, flags }: ParsedArgs): Promise { + const ctx = requireRoot(flags); + if (!ctx) return 1; + const sub = positional[0] ?? "show"; + const branch = positional[1] ?? flags["branch"] ?? currentGitBranch(ctx.root) ?? undefined; + if (!branch && sub !== "list") return usageError("branch requires a branch name or a git checkout"); + + switch (sub) { + case "list": { + const branchName = currentGitBranch(ctx.root); + const overlay = branchName ? loadBranchOverlay(ctx.memoryDir, branchName) : null; + console.log(branchName ?? "(detached)"); + if (overlay) console.log(`overlay: ${overlay.entries.length} entry(ies), ${overlay.deleted_ids.length} deletion(s)`); + return 0; + } + case "create": { + const overlay = createBranchOverlay(ctx.store, { branch, baseBranch: flags["base"] }); + console.log(`branch overlay ready: ${overlay.meta.branch}`); + return 0; + } + case "show": { + const overlay = loadBranchOverlay(ctx.memoryDir, branch!); + if (!overlay) return usageError(`no overlay for '${branch}'`); + console.log(`${overlay.meta.branch} (base ${overlay.meta.base_branch ?? "?"})`); + for (const entry of overlay.entries) console.log(` ${entry.id} ${entry.status} ${entry.title}`); + return 0; + } + case "merge": { + const report = mergeBranchOverlay(ctx.store, branch!); + console.log(`merged ${report.branch}: ${report.applied.length} applied, ${report.deleted.length} archived`); + return 0; + } + case "drop": { + if (!dropBranchOverlay(ctx.store, branch!)) return usageError(`no overlay for '${branch}'`); + console.log(`dropped ${branch}`); + return 0; + } + case "entries": { + for (const entry of listBranchMemories(ctx.store, branch!)) console.log(`${entry.id} ${entry.status} ${entry.title}`); + return 0; + } + default: + return usageError(`unknown branch subcommand '${sub}' (expected list | create | show | entries | merge | drop)`); + } +} diff --git a/src/store/branch.ts b/src/store/branch.ts new file mode 100644 index 0000000..6c5c139 --- /dev/null +++ b/src/store/branch.ts @@ -0,0 +1,149 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import type { Store } from "../store.ts"; +import { list, get, save } from "../store.ts"; +import type { MemoryEntry } from "../types.ts"; + +export interface BranchOverlayMeta { + branch: string; + base_branch?: string; + created_at: string; + updated_at: string; +} + +export interface BranchOverlay { + meta: BranchOverlayMeta; + entries: MemoryEntry[]; + deleted_ids: string[]; +} + +function safeName(branch: string): string { + return branch.replace(/[^A-Za-z0-9._-]/g, "_"); +} + +export function branchOverlayPath(memoryDir: string, branch: string): string { + return join(memoryDir, "branches", `${safeName(branch)}.json`); +} + +export function currentGitBranch(cwd: string = process.cwd()): string | null { + try { + return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim() || null; + } catch { + return null; + } +} + +export function loadBranchOverlay(memoryDir: string, branch: string): BranchOverlay | null { + const path = branchOverlayPath(memoryDir, branch); + if (!existsSync(path)) return null; + const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial; + if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.entries)) { + throw new Error(`Invalid branch overlay at ${path}`); + } + return { + meta: { + branch: parsed.meta?.branch ?? branch, + base_branch: parsed.meta?.base_branch, + created_at: parsed.meta?.created_at ?? new Date().toISOString(), + updated_at: parsed.meta?.updated_at ?? new Date().toISOString(), + }, + entries: parsed.entries as MemoryEntry[], + deleted_ids: Array.isArray(parsed.deleted_ids) ? parsed.deleted_ids : [], + }; +} + +function writeOverlay(memoryDir: string, overlay: BranchOverlay): void { + const dir = join(memoryDir, "branches"); + mkdirSync(dir, { recursive: true }); + const path = branchOverlayPath(memoryDir, overlay.meta.branch); + const tmp = `${path}.${process.pid}.tmp`; + writeFileSync(tmp, JSON.stringify(overlay, null, 2) + "\n", "utf8"); + renameSync(tmp, path); +} + +export function createBranchOverlay( + store: Store, + options: { branch?: string; baseBranch?: string } = {}, +): BranchOverlay { + const branch = options.branch ?? currentGitBranch(store.dir); + if (!branch) throw new Error("Cannot determine branch: pass --branch"); + const existing = loadBranchOverlay(store.memoryDir ?? store.dir, branch); + if (existing) return existing; + const now = new Date().toISOString(); + const overlay: BranchOverlay = { + meta: { branch, base_branch: options.baseBranch, created_at: now, updated_at: now }, + entries: [], + deleted_ids: [], + }; + writeOverlay(store.memoryDir ?? store.dir, overlay); + return overlay; +} + +/** Base store plus branch overlay, with overlay entries winning by id. */ +export function listBranchMemories(store: Store, branch: string): MemoryEntry[] { + const overlay = loadBranchOverlay(store.memoryDir ?? store.dir, branch); + if (!overlay) return list(store); + const deleted = new Set(overlay.deleted_ids); + const byId = new Map(list(store).filter((entry) => !deleted.has(entry.id)).map((entry) => [entry.id, entry])); + for (const entry of overlay.entries) byId.set(entry.id, entry); + return [...byId.values()]; +} + +export function writeBranchEntry(store: Store, branch: string, entry: MemoryEntry): BranchOverlay { + const memoryDir = store.memoryDir ?? store.dir; + const overlay = loadBranchOverlay(memoryDir, branch) ?? createBranchOverlay(store, { branch }); + overlay.entries = overlay.entries.filter((candidate) => candidate.id !== entry.id); + overlay.entries.push(entry); + overlay.deleted_ids = overlay.deleted_ids.filter((id) => id !== entry.id); + overlay.meta.updated_at = new Date().toISOString(); + writeOverlay(memoryDir, overlay); + return overlay; +} + +export function deleteBranchEntry(store: Store, branch: string, id: string): BranchOverlay { + const memoryDir = store.memoryDir ?? store.dir; + const overlay = loadBranchOverlay(memoryDir, branch) ?? createBranchOverlay(store, { branch }); + overlay.entries = overlay.entries.filter((entry) => entry.id !== id); + if (!overlay.deleted_ids.includes(id)) overlay.deleted_ids.push(id); + overlay.meta.updated_at = new Date().toISOString(); + writeOverlay(memoryDir, overlay); + return overlay; +} + +export interface MergeReport { + branch: string; + applied: string[]; + deleted: string[]; +} + +/** Apply an overlay to the base store and clear it. */ +export function mergeBranchOverlay(store: Store, branch: string): MergeReport { + const memoryDir = store.memoryDir ?? store.dir; + const overlay = loadBranchOverlay(memoryDir, branch); + if (!overlay) return { branch, applied: [], deleted: [] }; + const applied: string[] = []; + for (const entry of overlay.entries) { + const current = get(store, entry.id); + save(store, current ? { ...current, ...entry } : entry); + applied.push(entry.id); + } + for (const id of overlay.deleted_ids) { + const current = get(store, id); + if (current) save(store, { ...current, status: "archived" }); + } + const path = branchOverlayPath(memoryDir, branch); + if (existsSync(path)) renameSync(path, `${path}.merged`); + return { branch, applied, deleted: overlay.deleted_ids }; +} + +export function dropBranchOverlay(store: Store, branch: string): boolean { + const path = branchOverlayPath(store.memoryDir ?? store.dir, branch); + if (!existsSync(path)) return false; + renameSync(path, `${path}.dropped`); + return true; +} diff --git a/test/branch_overlays.test.ts b/test/branch_overlays.test.ts new file mode 100644 index 0000000..e476496 --- /dev/null +++ b/test/branch_overlays.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { openStore, propose, list, get } from "../src/store.ts"; +import { + createBranchOverlay, + writeBranchEntry, + listBranchMemories, + mergeBranchOverlay, + dropBranchOverlay, + loadBranchOverlay, +} from "../src/store/branch.ts"; + +describe("T-436: branchable memory overlays", () => { + test("isolates branch writes from the base store and merges them back", () => { + const memoryDir = mkdtempSync(join(tmpdir(), "muse-branch-")); + try { + const store = openStore(memoryDir); + const base = propose(store, { content: "base rule", project: "p", type: "constraint", confirmed: true }); + createBranchOverlay(store, { branch: "feat/x", baseBranch: "dev" }); + const overlayEntry = { + ...base, + id: "m_9999999999999_overlayrule", + content: "overlay rule", + title: "overlay rule", + }; + writeBranchEntry(store, "feat/x", overlayEntry); + + expect(list(store).map((entry) => entry.id)).toEqual([base.id]); + expect(listBranchMemories(store, "feat/x").map((entry) => entry.id).sort()).toEqual([base.id, overlayEntry.id].sort()); + + const report = mergeBranchOverlay(store, "feat/x"); + expect(report.applied).toEqual([overlayEntry.id]); + expect(get(store, overlayEntry.id)?.content).toBe("overlay rule"); + expect(loadBranchOverlay(memoryDir, "feat/x")).toBeNull(); + } finally { + rmSync(memoryDir, { recursive: true, force: true }); + } + }); + + test("drops an overlay without touching the base store", () => { + const memoryDir = mkdtempSync(join(tmpdir(), "muse-branch-drop-")); + try { + const store = openStore(memoryDir); + createBranchOverlay(store, { branch: "feat/y" }); + expect(dropBranchOverlay(store, "feat/y")).toBe(true); + expect(loadBranchOverlay(memoryDir, "feat/y")).toBeNull(); + } finally { + rmSync(memoryDir, { recursive: true, force: true }); + } + }); +});