From 1b8f4a7eff0e6f2146eea9c579586761fa61ec64 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:52:07 -0700 Subject: [PATCH 1/2] fix: compare .gitmem roots by real path, not string (GIT-107) findStrandedProjectRoots compared candidate and home as strings. On macOS /var is a symlink to /private/var: process.cwd() reports /private/var/... and a home or GITMEM_DIR under $TMPDIR is spelled /var/..., so session_start showed "Memory store found but NOT being read" about the store it was reading. Any symlink in either path did the same. - canonicalPath() resolves with fs.realpathSync.native; a path that does not exist yet resolves through its nearest existing ancestor. - A candidate is not stranded if it is the same directory as the home root or an explicit GITMEM_DIR (the root actually being read). - migrate-root's "source and destination are the same" check uses it too. +5 tests (symlinked home, a different store still reported, GITMEM_DIR, sameDirectory spellings, and the macOS /var case); 4 fail on origin/main. Co-Authored-By: Claude Opus 5 --- src/commands/migrate-root.ts | 4 +- src/services/gitmem-dir.ts | 41 ++++++- .../unit/services/gitmem-root-symlink.test.ts | 103 ++++++++++++++++++ 3 files changed, 144 insertions(+), 4 deletions(-) create mode 100644 tests/unit/services/gitmem-root-symlink.test.ts diff --git a/src/commands/migrate-root.ts b/src/commands/migrate-root.ts index faa4241..919be0c 100644 --- a/src/commands/migrate-root.ts +++ b/src/commands/migrate-root.ts @@ -29,7 +29,7 @@ import * as fs from "fs"; import * as path from "path"; -import { findStrandedProjectRoots, getHomeGitmemDir } from "../services/gitmem-dir.js"; +import { findStrandedProjectRoots, getHomeGitmemDir, sameDirectory } from "../services/gitmem-dir.js"; interface MigrationPlan { source: string; @@ -144,7 +144,7 @@ export function main(args: string[]): void { } const source = sources[0]; - if (source === home) { + if (sameDirectory(source, home)) { // GIT-107: symlinks, /var vs /private/var console.log(`Source and destination are the same (${home}). Nothing to do.`); return; } diff --git a/src/services/gitmem-dir.ts b/src/services/gitmem-dir.ts index 4451b7e..feba098 100644 --- a/src/services/gitmem-dir.ts +++ b/src/services/gitmem-dir.ts @@ -179,15 +179,52 @@ export function describeGitmemRoot(root: string): GitmemRootContents { }; } +/** + * GIT-107: a path's canonical spelling, for comparing directories. + * + * Roots used to be compared as strings, but one directory has many spellings: + * on macOS /var is a symlink to /private/var, so a home under $TMPDIR + * (/var/folders/...) and a cwd reported as /private/var/folders/... named the + * same store and still read as different — and session_start announced + * "Memory store found but NOT being read" about the store it was reading. + * + * realpathSync.native resolves every symlink. A path that does not exist yet + * (a destination root before its first write) resolves through its nearest + * existing ancestor, so /var/x/.gitmem and /private/var/x/.gitmem still agree. + */ +export function canonicalPath(p: string): string { + const abs = path.resolve(p); + const rest: string[] = []; + let dir = abs; + for (;;) { + try { + return path.join(fs.realpathSync.native(dir), ...rest.reverse()); + } catch { + const parent = path.dirname(dir); + if (parent === dir) return abs; // nothing on the path exists + rest.push(path.basename(dir)); + dir = parent; + } + } +} + +/** GIT-107: the same directory, however it is spelled. */ +export function sameDirectory(a: string, b: string): boolean { + return canonicalPath(a) === canonicalPath(b); +} + export function findStrandedProjectRoots(): string[] { try { - const home = getHomeGitmemDir(); + // A root is only stranded if it is not the one being read: neither the + // developer-scoped home nor an explicit GITMEM_DIR. (process.env, not + // getGitmemDir(): that call is what triggers this scan.) + const readRoots = [getHomeGitmemDir(), ...(process.env.GITMEM_DIR ? [process.env.GITMEM_DIR] : [])]; const stranded: string[] = []; let dir = process.cwd(); const fsRoot = path.parse(dir).root; while (dir !== fsRoot) { const candidate = path.join(dir, ".gitmem"); - if (candidate !== home && isLiveGitmemRoot(candidate)) stranded.push(candidate); + if (!readRoots.some((r) => sameDirectory(candidate, r)) && isLiveGitmemRoot(candidate)) stranded.push(candidate); dir = path.dirname(dir); } return stranded; diff --git a/tests/unit/services/gitmem-root-symlink.test.ts b/tests/unit/services/gitmem-root-symlink.test.ts new file mode 100644 index 0000000..a7ec2cc --- /dev/null +++ b/tests/unit/services/gitmem-root-symlink.test.ts @@ -0,0 +1,103 @@ +/** + * GIT-107: roots are compared as directories, not strings. + * + * On macOS /var is a symlink to /private/var. process.cwd() reports the + * resolved /private/var/... while a home under $TMPDIR is spelled /var/..., so + * the store being read was announced as "Memory store found but NOT being + * read". Any symlink in either path does the same. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { findStrandedProjectRoots, sameDirectory, canonicalPath, clearGitmemDirCache } from "../../../src/services/gitmem-dir.js"; + +let tmp: string; +const saved = { home: process.env.GITMEM_HOME, dir: process.env.GITMEM_DIR }; + +/** A live project-scoped store: config.json marks a deliberate install. */ +function liveStore(dir: string): string { + const g = path.join(dir, ".gitmem"); + fs.mkdirSync(g, { recursive: true }); + fs.writeFileSync(path.join(g, "config.json"), JSON.stringify({ project: "p" })); + return g; +} + +beforeEach(() => { + tmp = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "gitmem-git107-"))); + delete process.env.GITMEM_DIR; + clearGitmemDirCache(); + vi.spyOn(console, "error").mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); + if (saved.home === undefined) delete process.env.GITMEM_HOME; else process.env.GITMEM_HOME = saved.home; + if (saved.dir === undefined) delete process.env.GITMEM_DIR; else process.env.GITMEM_DIR = saved.dir; + clearGitmemDirCache(); + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +describe("GIT-107: root comparison survives symlinks", () => { + it("home reached through a symlink is not reported as a stranded store", () => { + const real = path.join(tmp, "real-home"); + fs.mkdirSync(real); + liveStore(real); // the store gitmem reads + const link = path.join(tmp, "link-home"); + fs.symlinkSync(real, link, "dir"); + + process.env.GITMEM_HOME = link; // home spelled via the symlink + vi.spyOn(process, "cwd").mockReturnValue(path.join(real, "work")); // cwd spelled resolved + + expect(findStrandedProjectRoots()).toEqual([]); + }); + + it("a genuinely different live store is still reported", () => { + const home = path.join(tmp, "home"); + fs.mkdirSync(home); + const project = path.join(tmp, "project"); + const stranded = liveStore(project); + process.env.GITMEM_HOME = home; + vi.spyOn(process, "cwd").mockReturnValue(path.join(project, "src")); + + expect(findStrandedProjectRoots()).toEqual([stranded]); + }); + + it("an explicit GITMEM_DIR naming the project store is the store being read, not a stranded one", () => { + const project = path.join(tmp, "project"); + const store = liveStore(project); + process.env.GITMEM_HOME = path.join(tmp, "home"); + process.env.GITMEM_DIR = path.join(tmp, "project", ".", ".gitmem"); + vi.spyOn(process, "cwd").mockReturnValue(project); + + expect(findStrandedProjectRoots()).not.toContain(store); + }); + + it("sameDirectory: symlinks, trailing slashes, dot segments and not-yet-existing leaves", () => { + const real = path.join(tmp, "r"); + fs.mkdirSync(real); + const link = path.join(tmp, "l"); + fs.symlinkSync(real, link, "dir"); + + expect(sameDirectory(link, real)).toBe(true); + expect(sameDirectory(`${real}/`, path.join(real, ".", "."))).toBe(true); + // Destination roots may not exist yet; they resolve through their parent. + expect(sameDirectory(path.join(link, ".gitmem"), path.join(real, ".gitmem"))).toBe(true); + expect(canonicalPath(path.join(link, "a", "b"))).toBe(path.join(real, "a", "b")); + expect(sameDirectory(path.join(tmp, "x"), path.join(tmp, "y"))).toBe(false); + }); + + // The reported case: macOS /var -> /private/var. + const tmpdirReal = fs.realpathSync.native(os.tmpdir()); + it.skipIf(tmpdirReal === path.resolve(os.tmpdir()))("macOS: /var/... and /private/var/... are one store", () => { + const spelledTmp = path.join(os.tmpdir(), path.basename(tmp)); // /var/folders/... spelling + const real = path.join(tmp, "home"); + fs.mkdirSync(real); + liveStore(real); + process.env.GITMEM_HOME = path.join(spelledTmp, "home"); + vi.spyOn(process, "cwd").mockReturnValue(real); // what process.cwd() reports: /private/var/... + + expect(process.env.GITMEM_HOME).not.toBe(real); + expect(findStrandedProjectRoots()).toEqual([]); + }); +}); From b80d11bf63b3bf79ffb5482f95997070977e5867 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 20:52:07 -0700 Subject: [PATCH 2/2] test: venue flow fails on a false "NOT being read" notice (GIT-107) The driver's store lives under $TMPDIR, so 1.10.0 shows the notice on every macOS run (exit 1); exits 0 with the fix. Co-Authored-By: Claude Opus 5 --- tests/e2e/blank-supabase.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/e2e/blank-supabase.mjs b/tests/e2e/blank-supabase.mjs index 84b343b..745b824 100644 --- a/tests/e2e/blank-supabase.mjs +++ b/tests/e2e/blank-supabase.mjs @@ -408,6 +408,9 @@ async function flow() { remote_sessions_after: await count("gitmem_sessions"), remote_scar_usage_after: await count("gitmem_scar_usage"), session_close_persisted: closedRows.length === 1 && closedRows[0].closing_reflection != null, + // GIT-107: the driver creates no stranded store; its GITMEM_DIR lives under + // $TMPDIR, i.e. /var/... vs /private/var/... on macOS. + false_stranded_notice: /NOT being read/.test(ss), relevance_readable: relevance.some((r) => scarIds.some((id) => (r.memories_applied || []).includes(id) && r.memory_relevance?.[id])), relevance, health_failed_total: failed, @@ -520,6 +523,9 @@ const failureCheck = checkFailures(allVenueRequests); const edgeCalls = allVenueRequests.filter((r) => r.path.startsWith("/functions/v1/")) .map((r) => `${r.method} ${r.path} -> ${r.status}`); if (edgeCalls.length) failureCheck.unexpected.push(...edgeCalls.map((c) => `edge function called (GIT-97): ${c}`)); +if (mode === "flow" && result.false_stranded_notice) { + failureCheck.unexpected.push("session_start reported the store it reads as 'NOT being read' (GIT-107)"); +} if (mode === "flow" && !result.session_close_persisted) { failureCheck.unexpected.push(`session_close did not persist session ${result.session_id} (closing_reflection missing)`); }