Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions claims.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@
"networkMillis": 300
},
"tests": {
"count": 1503,
"assertions": 7211,
"files": 235
"count": 1505,
"assertions": 7218,
"files": 236
}
}
56 changes: 56 additions & 0 deletions src/cli/branch.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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)`);
}
}
149 changes: 149 additions & 0 deletions src/store/branch.ts
Original file line number Diff line number Diff line change
@@ -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<BranchOverlay>;
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;
}
53 changes: 53 additions & 0 deletions test/branch_overlays.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
Loading