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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## Unreleased

- Memory bridge: `exp new` symlinks `~/.claude/projects/<branch-slug>/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 `<base>/.trash/<uuid>` (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.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 `<prefix>/<slug>` 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/<branch-slug>/memory` to the parent project's `~/.claude/projects/<parent-slug>/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.
Expand Down
19 changes: 19 additions & 0 deletions src/commands/new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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`;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`) ||
Expand Down
7 changes: 7 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface ExpConfig {
json: boolean;
cloneStrategy: CloneStrategy;
deferDirs: string[];
memoryBridge: boolean;
}

const DEFAULT_CLEAN = [".next", ".turbo"];
Expand Down Expand Up @@ -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",
Expand All @@ -58,6 +64,7 @@ export function loadConfig(): ExpConfig {
json: false,
cloneStrategy,
deferDirs,
memoryBridge,
};
}

Expand Down
116 changes: 116 additions & 0 deletions src/core/memory-bridge.ts
Original file line number Diff line number Diff line change
@@ -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/<slug>/`.
*
* 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/<slug-of-cwd>/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/<original-slug>/memory/` exists.
* - Ensures `~/.claude/projects/<branch-slug>/` exists.
* - Creates `~/.claude/projects/<branch-slug>/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<typeof lstatSync> | 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";
}
135 changes: 135 additions & 0 deletions tests/memory-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
Loading