diff --git a/CHANGELOG.md b/CHANGELOG.md index 4da3706..4084a4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## Unreleased + +- Memory bridge: `exp new` symlinks `~/.claude/projects//memory` to the parent project's memory dir, so Claude auto-memory written inside a branch lands in the parent's bucket — no more orphaned entries when branches are trashed. (Originally shaped to use `autoMemoryDirectory` in `.claude/settings.local.json`, but verified empirically that Claude only honors that setting from user-level `~/.claude/settings.json` by design — symlink works below Claude's awareness.) +- New config key: `memory_bridge` (default `true`) / `EXP_MEMORY_BRIDGE` env var to disable +- New module: `core/memory-bridge.ts` exporting `claudeProjectSlug`, `claudeProjectDir`, `claudeMemoryDir`, `bridgeMemory` +- JSON output of `exp new` includes `memoryBridge: "linked" | "exists" | "skipped" | "off"` + ## v0.10.0 — 2026-04-20 - `exp trash` is now ~instant for any size branch via rename-and-defer: stages targets to `/.trash/` (atomic `mv` on same APFS volume) and hands the actual `rm -rf` to the shell wrapper to run disowned in the background. 10-branch `exp trash --done` drops from ~158s to ~50ms perceived. diff --git a/CLAUDE.md b/CLAUDE.md index 0a15332..282be09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,7 @@ src/ │ ├── project.ts # Project root detection │ ├── experiment.ts # Branch resolution, numbering, metadata, branch prefix │ ├── context.ts # Detect branch vs project context +│ ├── memory-bridge.ts # Bridge Claude auto-memory to parent project │ └── clone.ts # APFS clone with fallback └── utils/ # Shared helpers ├── colors.ts # Chalk colors + output helpers @@ -70,6 +71,7 @@ commands/ # (empty — /exp is now a global skill) - **Branch resolution:** By number (`1`), full name (`001-try-redis`), or partial match (`redis`) - **Auto-branch:** `exp new` creates `/` git branch (prefix from config, git first name, or "exp" fallback). `--branch` flag for exact names. - **Diverged size:** `exp ls` reports actual diverged bytes (changed/new files only), not misleading apparent size +- **Memory bridge:** On `exp new`, symlinks `~/.claude/projects//memory` to the parent project's `~/.claude/projects//memory`. Solves orphaned-memory: Claude Code's worktree memory-sharing relies on `git rev-parse --git-common-dir`, but exp branches are self-contained clones so it returns their own `.git` and each branch gets a separate memory bucket — entries get orphaned when the branch is trashed. Symlink works below Claude's awareness: Claude resolves its memory dir from cwd as usual, writes "to its own slug", and the bytes land at the parent via the symlink. Tried setting `autoMemoryDirectory` in `.claude/settings.local.json` first — verified empirically that Claude only honors that key from user-level `~/.claude/settings.json` by design (security). Slug rule (replicates Claude's): replace `/` and `.` with `-`. Disable via `memory_bridge=false`. The branch's own session jsonl files still land under the branch slug — only memory is bridged. - **Confirmations:** Interactive prompts via `@inquirer/prompts` (trash, nuke) - **Clone strategy:** `clone_strategy=fast` in config or `--strategy fast` flag. Root-scans the source, clonefiles everything except `defer_dirs` (default: `node_modules`), returns in ~577ms. The shell wrapper then spawns `cp -cR` in the background for deferred dirs — user gets their prompt immediately, `node_modules` appears seconds later. Symlink strategy was tried first but Turbopack rejects symlinks pointing outside the project root. diff --git a/src/commands/new.ts b/src/commands/new.ts index f1962a9..a3bb236 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -11,6 +11,7 @@ import { slugify, writeMetadata, } from "../core/experiment.ts"; +import { bridgeMemory } from "../core/memory-bridge.ts"; import { getProjectName, getProjectRoot } from "../core/project.ts"; import { writeCdTarget, writeDeferredClone } from "../utils/cd-file.ts"; import { c, dim, info, ok, warn } from "../utils/colors.ts"; @@ -148,6 +149,19 @@ export async function cmdNew(args: string[], config: ExpConfig) { number: Number.parseInt(num, 10), }); + // Bridge Claude Code's auto-memory back to the parent project. Memory + // written inside this branch's Claude sessions lands in the parent's + // memory dir instead of being orphaned under the branch's own slug + // (which dies when the branch is trashed). See core/memory-bridge.ts. + let memoryBridgeStatus: "linked" | "exists" | "skipped" | "off" = "off"; + if (config.memoryBridge) { + spinner.update("Bridging Claude memory..."); + memoryBridgeStatus = bridgeMemory(expDir, root); + if (memoryBridgeStatus === "skipped" && verbose) { + warn("Memory dir for this branch slug already exists with content — left untouched"); + } + } + // Add .exp to branch's .gitignore so metadata doesn't get committed spinner.update("Configuring gitignore..."); const gitignorePath = `${expDir}/.gitignore`; @@ -254,6 +268,7 @@ export async function cmdNew(args: string[], config: ExpConfig) { method, strategy, deferredPaths: deferredPaths.length > 0 ? deferredPaths : undefined, + memoryBridge: memoryBridgeStatus, terminal: terminalType, description, from: fromExpName ?? null, @@ -290,6 +305,10 @@ export async function cmdNew(args: string[], config: ExpConfig) { ); } + if (memoryBridgeStatus === "linked") { + dim(" Claude memory bridged to parent project"); + } + const hasNextConfig = existsSync(`${root}/next.config.js`) || existsSync(`${root}/next.config.mjs`) || diff --git a/src/core/config.ts b/src/core/config.ts index 715ef06..6f791df 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -15,6 +15,7 @@ export interface ExpConfig { json: boolean; cloneStrategy: CloneStrategy; deferDirs: string[]; + memoryBridge: boolean; } const DEFAULT_CLEAN = [".next", ".turbo"]; @@ -47,6 +48,11 @@ export function loadConfig(): ExpConfig { ? file.defer_dirs.split(" ").filter(Boolean) : DEFAULT_DEFER_DIRS; + // memory_bridge defaults to true. Disable via `memory_bridge=false` or + // EXP_MEMORY_BRIDGE=false to skip writing .claude/settings.local.json. + const memoryBridgeRaw = env.EXP_MEMORY_BRIDGE ?? file.memory_bridge ?? "true"; + const memoryBridge = memoryBridgeRaw !== "false"; + return { root: env.EXP_ROOT || file.root || null, terminal: (env.EXP_TERMINAL || file.terminal || "auto") as TerminalType | "auto", @@ -58,6 +64,7 @@ export function loadConfig(): ExpConfig { json: false, cloneStrategy, deferDirs, + memoryBridge, }; } diff --git a/src/core/memory-bridge.ts b/src/core/memory-bridge.ts new file mode 100644 index 0000000..fadb59d --- /dev/null +++ b/src/core/memory-bridge.ts @@ -0,0 +1,116 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readlinkSync, + rmdirSync, + symlinkSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** + * Compute Claude Code's per-project state slug for a filesystem path. + * + * Claude derives the slug by replacing both `/` and `.` with `-`, leaving + * other characters intact. The slug namespaces per-project state under + * `~/.claude/projects//`. + * + * Examples (verified empirically against ~/.claude/projects/): + * /Users/joel/Code/inkwell → -Users-joel-Code-inkwell + * /Users/joel/.claude/skills → -Users-joel--claude-skills + * /Users/joel/Code/.exp-inkwell/022 → -Users-joel-Code--exp-inkwell-022 + */ +export function claudeProjectSlug(absPath: string): string { + return absPath.replace(/[/.]/g, "-"); +} + +/** + * Absolute path to Claude's per-project state directory. + */ +export function claudeProjectDir(projectRoot: string): string { + return join(homedir(), ".claude", "projects", claudeProjectSlug(projectRoot)); +} + +/** + * Absolute path to Claude's auto-memory directory for a given project root. + */ +export function claudeMemoryDir(projectRoot: string): string { + return join(claudeProjectDir(projectRoot), "memory"); +} + +/** + * Bridge Claude's auto-memory from a branch back to the original project. + * + * Approach: Claude resolves its memory dir from cwd + * (`~/.claude/projects//memory/`) and does NOT honor + * `autoMemoryDirectory` from project-local settings (only user-level + * `~/.claude/settings.json`, by design). We work below Claude by + * symlinking the branch's would-be memory directory to the original + * project's memory directory — Claude writes "to its own slug" and the + * bytes land at the parent. + * + * Side effects: + * - Ensures `~/.claude/projects//memory/` exists. + * - Ensures `~/.claude/projects//` exists. + * - Creates `~/.claude/projects//memory` as a symlink + * pointing at the original's memory dir. + * + * Returns: + * "linked" — symlink created + * "exists" — correct symlink already in place; no-op + * "skipped" — branch's memory entry exists as a real directory or as a + * symlink to somewhere else. Left untouched to avoid data loss. + */ +export function bridgeMemory( + branchDir: string, + originalRoot: string, +): "linked" | "exists" | "skipped" { + const parentMem = claudeMemoryDir(originalRoot); + const branchProjDir = claudeProjectDir(branchDir); + const branchMem = join(branchProjDir, "memory"); + + if (!existsSync(parentMem)) { + mkdirSync(parentMem, { recursive: true }); + } + if (!existsSync(branchProjDir)) { + mkdirSync(branchProjDir, { recursive: true }); + } + + // Inspect existing entry at branchMem, if any. Use lstat to see the + // symlink itself, not what it points at. + let entry: ReturnType | null = null; + try { + entry = lstatSync(branchMem); + } catch { + // ENOENT — entry doesn't exist, we'll create it below. + } + + if (entry) { + if (entry.isSymbolicLink()) { + const target = readlinkSync(branchMem); + if (target === parentMem) return "exists"; + return "skipped"; // symlink points somewhere else — don't clobber + } + if (entry.isDirectory()) { + // Real directory: if it's empty we could safely take it over, but + // even empty might indicate the user organized something. Skip + // rather than guess. Migration is a separate concern. + const contents = readdirSync(branchMem); + if (contents.length === 0) { + // Safe: remove the empty dir and symlink in its place. + try { + rmdirSync(branchMem); + } catch { + return "skipped"; + } + } else { + return "skipped"; + } + } + } + + symlinkSync(parentMem, branchMem); + return "linked"; +} diff --git a/tests/memory-bridge.test.ts b/tests/memory-bridge.test.ts new file mode 100644 index 0000000..e4ce97e --- /dev/null +++ b/tests/memory-bridge.test.ts @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { + existsSync, + lstatSync, + mkdirSync, + readlinkSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { + bridgeMemory, + claudeMemoryDir, + claudeProjectDir, + claudeProjectSlug, +} from "../src/core/memory-bridge.ts"; + +// Tests use the real ~/.claude/projects/ dir with unique test-scoped slugs. +// homedir() reads from passwd, not HOME env, so faking is impractical. +const STAMP = `exp-bridge-test-${process.pid}`; +const PARENT_FAKE = `/tmp/${STAMP}/parent`; +const BRANCH_FAKE = `/tmp/${STAMP}/.exp-parent/001-test`; +const ELSEWHERE = `/tmp/${STAMP}/elsewhere`; + +const projectsRoot = join(homedir(), ".claude", "projects"); + +function cleanup() { + for (const path of [claudeProjectDir(PARENT_FAKE), claudeProjectDir(BRANCH_FAKE)]) { + rmSync(path, { recursive: true, force: true }); + } + rmSync(`/tmp/${STAMP}`, { recursive: true, force: true }); +} + +beforeEach(() => { + cleanup(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("claudeProjectSlug", () => { + // Empirically verified against ~/.claude/projects/ entries. + test("plain project path", () => { + expect(claudeProjectSlug("/Users/joel/Code/inkwell")).toBe("-Users-joel-Code-inkwell"); + }); + + test("path containing a dot-directory", () => { + expect(claudeProjectSlug("/Users/joel/.claude/skills")).toBe("-Users-joel--claude-skills"); + }); + + test("exp branch path (dot + slash combo)", () => { + expect(claudeProjectSlug("/Users/joel/Code/.exp-inkwell/022-prefixed-tailwind")).toBe( + "-Users-joel-Code--exp-inkwell-022-prefixed-tailwind", + ); + }); + + test("root path", () => { + expect(claudeProjectSlug("/")).toBe("-"); + }); +}); + +describe("claudeMemoryDir / claudeProjectDir", () => { + test("constructs paths under ~/.claude/projects", () => { + expect(claudeProjectDir("/Users/joel/Code/inkwell")).toBe( + join(projectsRoot, "-Users-joel-Code-inkwell"), + ); + expect(claudeMemoryDir("/Users/joel/Code/inkwell")).toBe( + join(projectsRoot, "-Users-joel-Code-inkwell", "memory"), + ); + }); +}); + +describe("bridgeMemory", () => { + test("creates a symlink from branch memory to parent memory", () => { + const result = bridgeMemory(BRANCH_FAKE, PARENT_FAKE); + expect(result).toBe("linked"); + + const branchMem = claudeMemoryDir(BRANCH_FAKE); + const parentMem = claudeMemoryDir(PARENT_FAKE); + + expect(existsSync(branchMem)).toBe(true); + expect(lstatSync(branchMem).isSymbolicLink()).toBe(true); + expect(readlinkSync(branchMem)).toBe(parentMem); + }); + + test("creates parent memory dir if missing", () => { + const parentMem = claudeMemoryDir(PARENT_FAKE); + expect(existsSync(parentMem)).toBe(false); + bridgeMemory(BRANCH_FAKE, PARENT_FAKE); + expect(existsSync(parentMem)).toBe(true); + }); + + test("returns 'exists' when correct symlink already in place (idempotent)", () => { + expect(bridgeMemory(BRANCH_FAKE, PARENT_FAKE)).toBe("linked"); + expect(bridgeMemory(BRANCH_FAKE, PARENT_FAKE)).toBe("exists"); + }); + + test("returns 'skipped' when branch memory is a real dir with content", () => { + const branchMem = claudeMemoryDir(BRANCH_FAKE); + mkdirSync(branchMem, { recursive: true }); + writeFileSync(join(branchMem, "orphan.md"), "I'm already here"); + + const result = bridgeMemory(BRANCH_FAKE, PARENT_FAKE); + expect(result).toBe("skipped"); + + // The file should still be there — untouched + expect(existsSync(join(branchMem, "orphan.md"))).toBe(true); + expect(lstatSync(branchMem).isSymbolicLink()).toBe(false); + }); + + test("takes over an empty branch memory dir by linking it", () => { + const branchMem = claudeMemoryDir(BRANCH_FAKE); + mkdirSync(branchMem, { recursive: true }); + + const result = bridgeMemory(BRANCH_FAKE, PARENT_FAKE); + expect(result).toBe("linked"); + expect(lstatSync(branchMem).isSymbolicLink()).toBe(true); + }); + + test("returns 'skipped' when branch memory is a symlink to the wrong place", () => { + const branchMem = claudeMemoryDir(BRANCH_FAKE); + const branchProj = claudeProjectDir(BRANCH_FAKE); + mkdirSync(branchProj, { recursive: true }); + mkdirSync(ELSEWHERE, { recursive: true }); + + symlinkSync(ELSEWHERE, branchMem); + + const result = bridgeMemory(BRANCH_FAKE, PARENT_FAKE); + expect(result).toBe("skipped"); + expect(readlinkSync(branchMem)).toBe(ELSEWHERE); // unchanged + }); +});