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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Running the e2e suite no longer deletes your real `~/.gitmem`. `cursor-cross-tool.test.ts` ran
`gitmem uninstall --all` with your real HOME, and `--all` removes the memory store (session
registry, per-session files, local `threads.json`, closing payloads). Those tests now run against
a scratch HOME, and every e2e helper is guarded: any gitmem CLI or server process whose store
resolves outside `os.tmpdir()` is refused before it starts. The other e2e files that used your
real store (`pro-fresh`, `pro-mature`, `user-journey`, `organic-discovery`, `git-120`) are
sandboxed too, and the cursor tests assert the GIT-115 layout. Test-only change; the product
and `uninstall --all` are unchanged. (GIT-123)
- `session_close` no longer reports every open thread as failed when a session is closed a second
time (or closed after the server restarted). Threads stored on the session row come back as JSON
strings, and they were handed to the Supabase thread sync without being parsed, so each one had no
Expand Down
77 changes: 44 additions & 33 deletions tests/e2e/cursor-cross-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ import { tmpdir } from "os";
const execFile = promisify(execFileCb);
const GITMEM_BIN = join(__dirname, "../../bin/gitmem.js");

/**
* GIT-123: every call runs against a scratch HOME inside its own cwd, so
* `init`/`uninstall --all` act on <cwd>/.home/.gitmem and never on the
* developer's ~/.gitmem. The store (learnings, threads, ...) lives there; the
* repo's own .gitmem/ holds config.json and hooks only (GIT-115 layout).
*/
const storeOf = (cwd: string) => join(cwd, ".home", ".gitmem");

/**
* Run a gitmem CLI command as a child process
*/
Expand All @@ -44,8 +52,12 @@ async function runGitmem(
SUPABASE_SERVICE_ROLE_KEY: "",
GITMEM_TIER: "free",
NO_COLOR: "1",
...(options.cwd ? { HOME: join(options.cwd, ".home") } : {}),
GITMEM_HOME: "",
GITMEM_DIR: "",
...options.env,
};
if (options.cwd) mkdirSync(join(options.cwd, ".home"), { recursive: true });

try {
const { stdout, stderr } = await execFile("node", [GITMEM_BIN, ...args], {
Expand Down Expand Up @@ -92,16 +104,16 @@ describe("Cursor: Init Clean Room", () => {
);

expect(exitCode).toBe(0);
expect(stdout).toContain("Setup for Cursor");
expect(stdout).toContain("Step 1/5");
expect(stdout).toContain("Step 5/5");
// No step 6 (no permissions step for Cursor)
expect(stdout).not.toContain("Step 6/");
expect(stdout).toContain("Setting up for Cursor");
expect(stdout).toContain("Created ~/.gitmem");
expect(stdout).toContain("Created .cursorrules");
expect(stdout).toContain("Added automatic memory hooks");

// .gitmem/ created with starter scars
// Starter scars land in the store (GIT-115: ~/.gitmem, here the sandbox HOME);
// the repo's .gitmem/ holds config and hooks only.
expect(existsSync(join(TEST_DIR, ".gitmem"))).toBe(true);
expect(existsSync(join(TEST_DIR, ".gitmem", "learnings.json"))).toBe(true);
const learnings = readJson(join(TEST_DIR, ".gitmem", "learnings.json"));
expect(existsSync(join(storeOf(TEST_DIR), "learnings.json"))).toBe(true);
const learnings = readJson(join(storeOf(TEST_DIR), "learnings.json"));
expect(learnings.length).toBeGreaterThan(0);

// .cursor/mcp.json created
Expand Down Expand Up @@ -179,11 +191,12 @@ describe("Cursor: Init Clean Room", () => {
);

expect(exitCode).toBe(0);
// Every step should say "Already configured" or "Skipping"
expect(stdout).toContain("Already configured");
// Every step should say it is already set up
expect(stdout).toContain("already set up");
expect(stdout).toContain("already configured");
// Should not create anything new
expect(stdout).not.toContain("Created .gitmem/");
expect(stdout).not.toContain("Added gitmem entry");
expect(stdout).not.toMatch(/Created .*\.gitmem/);
expect(stdout).not.toContain("Configured MCP server");
expect(stdout).not.toContain("Created .cursorrules");
});

Expand Down Expand Up @@ -221,9 +234,7 @@ describe("Claude: Init Control Group", () => {
);

expect(exitCode).toBe(0);
expect(stdout).toContain("Setup for Claude Code");
expect(stdout).toContain("Step 1/6");
expect(stdout).toContain("Step 6/6");
expect(stdout).toContain("Setting up for Claude Code");

// .mcp.json at project root
const mcpConfig = readJson(join(TEST_DIR, ".mcp.json"));
Expand Down Expand Up @@ -312,7 +323,7 @@ describe("Cross-Contamination Prevention", () => {

// Single shared .gitmem/ directory
expect(existsSync(join(TEST_DIR, ".gitmem"))).toBe(true);
expect(existsSync(join(TEST_DIR, ".gitmem", "learnings.json"))).toBe(true);
expect(existsSync(join(storeOf(TEST_DIR), "learnings.json"))).toBe(true);

// Verify each config is correct format
const cursorHooks = readJson(join(TEST_DIR, ".cursor", "hooks.json"));
Expand All @@ -338,8 +349,8 @@ describe("Cross-Contamination Prevention", () => {
// Init Cursor in DIR_B
await runGitmem(["init", "--client", "cursor", "--yes"], { cwd: DIR_B });

const scarsA = readJson(join(DIR_A, ".gitmem", "learnings.json"));
const scarsB = readJson(join(DIR_B, ".gitmem", "learnings.json"));
const scarsA = readJson(join(storeOf(DIR_A), "learnings.json"));
const scarsB = readJson(join(storeOf(DIR_B), "learnings.json"));

// Same number of starter scars
expect(scarsA.length).toBe(scarsB.length);
Expand Down Expand Up @@ -367,7 +378,7 @@ describe("Client Auto-Detection", () => {

expect(exitCode).toBe(0);
expect(stdout).toContain("auto-detected");
expect(stdout).toContain("Setup for Cursor");
expect(stdout).toContain("Setting up for Cursor");
// Should create Cursor files
expect(existsSync(join(TEST_DIR, ".cursor", "mcp.json"))).toBe(true);
expect(existsSync(join(TEST_DIR, ".cursorrules"))).toBe(true);
Expand All @@ -385,7 +396,7 @@ describe("Client Auto-Detection", () => {

expect(exitCode).toBe(0);
expect(stdout).toContain("auto-detected");
expect(stdout).toContain("Setup for Claude Code");
expect(stdout).toContain("Setting up for Claude Code");
expect(existsSync(join(TEST_DIR, ".mcp.json"))).toBe(true);
expect(existsSync(join(TEST_DIR, "CLAUDE.md"))).toBe(true);
} finally {
Expand All @@ -402,7 +413,7 @@ describe("Client Auto-Detection", () => {
const { stdout, exitCode } = await runGitmem(["init", "--yes"], { cwd: TEST_DIR });

expect(exitCode).toBe(0);
expect(stdout).toContain("Setup for Cursor");
expect(stdout).toContain("Setting up for Cursor");
} finally {
rmSync(TEST_DIR, { recursive: true, force: true });
}
Expand All @@ -417,7 +428,7 @@ describe("Client Auto-Detection", () => {
const { stdout, exitCode } = await runGitmem(["init", "--yes"], { cwd: TEST_DIR });

expect(exitCode).toBe(0);
expect(stdout).toContain("Setup for Claude Code");
expect(stdout).toContain("Setting up for Claude Code");
} finally {
rmSync(TEST_DIR, { recursive: true, force: true });
}
Expand All @@ -431,7 +442,7 @@ describe("Client Auto-Detection", () => {
const { stdout, exitCode } = await runGitmem(["init", "--yes"], { cwd: TEST_DIR });

expect(exitCode).toBe(0);
expect(stdout).toContain("Setup for Claude Code");
expect(stdout).toContain("Setting up for Claude Code");
} finally {
rmSync(TEST_DIR, { recursive: true, force: true });
}
Expand All @@ -449,8 +460,8 @@ describe("Client Auto-Detection", () => {
);

expect(exitCode).toBe(0);
expect(stdout).toContain("via --client flag");
expect(stdout).toContain("Setup for Cursor");
expect(stdout).not.toContain("auto-detected");
expect(stdout).toContain("Setting up for Cursor");
// Should create Cursor files despite .claude/ existing
expect(existsSync(join(TEST_DIR, ".cursor", "mcp.json"))).toBe(true);
} finally {
Expand All @@ -464,7 +475,7 @@ describe("Client Auto-Detection", () => {

try {
const { stderr, exitCode } = await runGitmem(
["init", "--client", "vscode", "--yes"],
["init", "--client", "emacs", "--yes"],
{ cwd: TEST_DIR }
);

Expand Down Expand Up @@ -504,13 +515,15 @@ describe("Cursor: Uninstall", () => {
);

expect(exitCode).toBe(0);
expect(stdout).toContain("Uninstall (Cursor)");
expect(stdout).toContain("Uninstall complete");
expect(stdout).toContain("Removing gitmem from Cursor");
expect(stdout).toContain("gitmem-mcp has been removed");

// .cursorrules removed (was gitmem-only)
expect(existsSync(join(TEST_DIR, ".cursorrules"))).toBe(false);

// .gitmem/ deleted (--all flag)
// The memory store is deleted (--all) — the sandbox one — and the repo's
// .gitmem/ (config + hooks) goes with it.
expect(existsSync(storeOf(TEST_DIR))).toBe(false);
expect(existsSync(join(TEST_DIR, ".gitmem"))).toBe(false);
});

Expand All @@ -523,10 +536,8 @@ describe("Cursor: Uninstall", () => {
{ cwd: TEST_DIR }
);

// Cursor has 4 steps (no permissions), Claude has 5
expect(stdout).toContain("Step 4/4");
expect(stdout).not.toContain("Step 5/");
// Cursor uninstall has no permissions step — 4 steps total (verified above)
// Cursor has no permissions step
expect(stdout).toContain("Not needed for Cursor");
});
});

Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/git-120-hooks-merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ async function gitmem(args: string[], cwd: string, home: string) {
try {
const { stdout, stderr } = await execFile("node", [GITMEM_BIN, ...args], {
cwd,
env: { ...process.env, HOME: home, NO_COLOR: "1", SUPABASE_URL: "", SUPABASE_SERVICE_ROLE_KEY: "", GITMEM_TIER: "free" },
env: { ...process.env, HOME: home, GITMEM_DIR: "", GITMEM_HOME: "", NO_COLOR: "1", SUPABASE_URL: "", SUPABASE_SERVICE_ROLE_KEY: "", GITMEM_TIER: "free" },
timeout: 30_000,
});
return { stdout, stderr, code: 0 };
Expand Down
4 changes: 4 additions & 0 deletions tests/e2e/mcp-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
import { spawn, type ChildProcess } from "child_process";
import { assertSandboxedStore } from "../helpers/e2e-store-guard.js";
import { join } from "path";

/**
Expand Down Expand Up @@ -57,6 +58,9 @@ export async function createMcpClient(
NODE_ENV: "test",
};

// GIT-123: never start a server whose store is the developer's own ~/.gitmem
assertSandboxedStore("node", [serverPath], serverEnv);

// Create transport that spawns the server
const transport = new StdioClientTransport({
command: "node",
Expand Down
11 changes: 10 additions & 1 deletion tests/e2e/organic-discovery/organic-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,7 +326,7 @@ function setupTestDir(configId: string, runNumber: number, hooksConfig: HooksCon
gitmem: {
command: "node",
args: [join(GITMEM_ROOT, "dist/index.js")],
env: { GITMEM_TIER: "free" },
env: { GITMEM_TIER: "free", GITMEM_DIR: join(testDir, ".store") },
},
},
};
Expand Down Expand Up @@ -380,6 +380,13 @@ async function runConfigChain(
const testDir = setupTestDir(config.id, runNumber, hooksConfig);
const memoryMdPath = join(testDir, "MEMORY.md");

// GIT-123: the Claude session, its MCP server and its hooks use a store inside
// the test dir, never the developer's ~/.gitmem. The session inherits
// process.env, so it is set for the chain and restored in the finally.
const savedStoreEnv = { dir: process.env.GITMEM_DIR, home: process.env.GITMEM_HOME };
process.env.GITMEM_DIR = join(testDir, ".store");
process.env.GITMEM_HOME = "";

const sessionResults: SessionMetrics[] = [];

try {
Expand Down Expand Up @@ -414,6 +421,8 @@ async function runConfigChain(
sessionResults.push(metrics);
}
} finally {
if (savedStoreEnv.dir === undefined) delete process.env.GITMEM_DIR; else process.env.GITMEM_DIR = savedStoreEnv.dir;
if (savedStoreEnv.home === undefined) delete process.env.GITMEM_HOME; else process.env.GITMEM_HOME = savedStoreEnv.home;
cleanupTestDir(testDir);
}

Expand Down
10 changes: 9 additions & 1 deletion tests/e2e/pro-fresh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import {
} from "./mcp-client.js";
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Client } from "pg";
import { readFileSync } from "fs";
import { readFileSync, mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";

// This test requires Docker - probe for a working container runtime
Expand All @@ -42,6 +43,7 @@ try {
describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Fresh Install E2E", () => {
let container: StartedPostgreSqlContainer;
let pgClient: Client;
let sandboxHome = "";
let mcpClient: McpTestClient;

beforeAll(async () => {
Expand Down Expand Up @@ -80,7 +82,12 @@ describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Fresh Install E2E", () => {
console.log("[e2e] Schema loaded and starter scars seeded");

// Create MCP client pointing to the test database
// GIT-123: the server's store is a scratch dir, never the developer's ~/.gitmem
sandboxHome = mkdtempSync(join(tmpdir(), "gitmem-pro-home-"));
mcpClient = await createMcpClient({
HOME: sandboxHome,
GITMEM_HOME: "",
GITMEM_DIR: join(sandboxHome, ".gitmem"),
SUPABASE_URL: container.getConnectionUri(),
SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
GITMEM_TIER: "pro",
Expand All @@ -92,6 +99,7 @@ describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Fresh Install E2E", () => {
if (mcpClient) {
await mcpClient.cleanup();
}
if (sandboxHome) rmSync(sandboxHome, { recursive: true, force: true });
if (pgClient) {
await pgClient.end();
}
Expand Down
10 changes: 9 additions & 1 deletion tests/e2e/pro-mature.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ import {
} from "./mcp-client.js";
import { PostgreSqlContainer, type StartedPostgreSqlContainer } from "@testcontainers/postgresql";
import { Client } from "pg";
import { readFileSync } from "fs";
import { readFileSync, mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { BASELINES } from "../performance/baselines.js";

Expand All @@ -39,6 +40,7 @@ try {
describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Mature System E2E", () => {
let container: StartedPostgreSqlContainer;
let pgClient: Client;
let sandboxHome = "";
let mcpClient: McpTestClient;

beforeAll(async () => {
Expand Down Expand Up @@ -80,7 +82,12 @@ describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Mature System E2E", () => {
await pgClient.query("ANALYZE gitmem_decisions");

// Create MCP client
// GIT-123: the server's store is a scratch dir, never the developer's ~/.gitmem
sandboxHome = mkdtempSync(join(tmpdir(), "gitmem-pro-home-"));
mcpClient = await createMcpClient({
HOME: sandboxHome,
GITMEM_HOME: "",
GITMEM_DIR: join(sandboxHome, ".gitmem"),
SUPABASE_URL: container.getConnectionUri(),
SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key",
GITMEM_TIER: "pro",
Expand All @@ -92,6 +99,7 @@ describe.skipIf(!DOCKER_AVAILABLE)("Pro Tier - Mature System E2E", () => {
if (mcpClient) {
await mcpClient.cleanup();
}
if (sandboxHome) rmSync(sandboxHome, { recursive: true, force: true });
if (pgClient) {
await pgClient.end();
}
Expand Down
42 changes: 42 additions & 0 deletions tests/e2e/setup-store-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* GIT-123: applies the e2e store guard to every child process an e2e test
* starts, whatever helper it uses to start it.
*/

import cp from "child_process";
import { syncBuiltinESMExports } from "module";
import { promisify } from "util";
import { assertSandboxedStore } from "../helpers/e2e-store-guard.js";

type AnyFn = (...a: any[]) => any;

/** Split spawn-style arguments into command, args, env. */
function parts(kind: "spawn" | "exec" | "fork", a: any[]): { command: string; args: string[]; env?: Record<string, string> } {
const args = Array.isArray(a[1]) ? (a[1] as string[]) : [];
const options = [a[1], a[2], a[3]].find((x) => x && typeof x === "object" && !Array.isArray(x));
const env = options?.env;
if (kind === "fork") return { command: process.execPath, args: [String(a[0]), ...args], env };
if (kind === "exec") return { command: String(a[0]), args: [], env };
return { command: String(a[0]), args, env };
}

function guarded(name: string, kind: "spawn" | "exec" | "fork"): void {
const original = (cp as any)[name] as AnyFn;
const check = (a: any[]) => {
const { command, args, env } = parts(kind, a);
// exec/execSync take one command string: match on the whole string.
assertSandboxedStore(kind === "exec" ? "gitmem-shell" : command, kind === "exec" ? [command] : args, env);
};
const wrapped: AnyFn = (...a) => { check(a); return original(...a); };
const custom = (original as any)[promisify.custom];
if (custom) (wrapped as any)[promisify.custom] = (...a: any[]) => {
try { check(a); } catch (e) { return Promise.reject(e); }
return custom(...a);
};
(cp as any)[name] = wrapped;
}

for (const n of ["spawn", "spawnSync", "execFile", "execFileSync"]) guarded(n, "spawn");
for (const n of ["exec", "execSync"]) guarded(n, "exec");
guarded("fork", "fork");
syncBuiltinESMExports();
Loading
Loading