From d590566533b5a6d26cb0f7ae6b2f33d7f8ddc5e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 21:19:28 -0700 Subject: [PATCH] fix: init writes the store where the server reads it; repo keeps config + hooks (GIT-115) init seeded /.gitmem while the server reads ~/.gitmem (GIT-91), so a fresh install had no starter lessons and was told its store was "NOT being read". The wizard and init-scars now seed learnings, threads, collections and the closing payload + template into the root the server reads (GITMEM_DIR, then GITMEM_HOME/.gitmem, then ~/.gitmem). The repo's .gitmem/ keeps only config.json (project name) and the hook scripts. - A repo .gitmem/ with only config.json and hooks, or only what an older init seeded, is not a stranded store. Real records (a user's learning, thread or decision) now count, which main did not check at all. - The SessionStart hook reads the repo's project name without jq (node). - uninstall removes the repo's config.json and hooks; the shared store is kept unless --all or the user says delete. Hook removal and the wizard's hook merge use the GIT-120 module (bin/hooks-merge.js, identical to #55). - telemetry commands use the server's root. Tests: fresh-install e2e in a clean HOME (9, all fail on main), unit +5, hook suite +1. cli-fresh-install made hermetic (scratch HOME) and repaired; both e2e files wired into CI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 6 + bin/gitmem-root.js | 34 ++++ bin/gitmem.js | 23 +-- bin/hooks-merge.js | 130 ++++++++++++++ bin/init-wizard.js | 125 ++++++-------- bin/uninstall.js | 163 +++++++----------- hooks/scripts/session-start.sh | 10 +- hooks/tests/test-hooks.sh | 16 ++ src/commands/telemetry.ts | 11 +- src/services/gitmem-dir.ts | 50 +++++- tests/e2e/cli-fresh-install.test.ts | 36 ++-- tests/e2e/git-115-fresh-install.test.ts | 156 +++++++++++++++++ .../services/gitmem-root-resolution.test.ts | 32 +++- .../unit/services/gitmem-root-symlink.test.ts | 8 +- 14 files changed, 591 insertions(+), 209 deletions(-) create mode 100644 bin/gitmem-root.js create mode 100644 bin/hooks-merge.js create mode 100644 tests/e2e/git-115-fresh-install.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a92f871..74b46f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,12 @@ jobs: - name: E2E — session identity survives MCP restart run: npx vitest run --config vitest.e2e.config.ts tests/e2e/git-89-session-identity.test.ts + # GIT-115: a fresh install in a clean HOME — init, the SessionStart hook, + # the built server — plus the CLI/wizard suite. Both run the real CLI in + # scratch directories with a scratch HOME; no Docker or credentials. + - name: E2E — fresh install reads what init wrote; CLI and wizard + run: npx vitest run --config vitest.e2e.config.ts tests/e2e/git-115-fresh-install.test.ts tests/e2e/cli-fresh-install.test.ts + - name: Dependency audit (no unused deps) run: npx depcheck --ignores="@types/*" --json | node -e "const d=JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')); const unused=d.dependencies||[]; if(unused.length){console.error('Unused dependencies:',unused);process.exit(1)}" diff --git a/bin/gitmem-root.js b/bin/gitmem-root.js new file mode 100644 index 0000000..5ddbb61 --- /dev/null +++ b/bin/gitmem-root.js @@ -0,0 +1,34 @@ +/** + * GIT-115: where the CLI puts things, split the same way the server reads them. + * + * The store (learnings, threads, sessions, decisions, the closing payload and + * its template) belongs in the root the server reads. That is resolved exactly + * as src/services/gitmem-dir.ts does, without a running session: GITMEM_DIR, + * then GITMEM_HOME/.gitmem, then ~/.gitmem. `init` used to seed /.gitmem, + * which the server has not read since GIT-91, so a fresh install saw no + * starter lessons and was told its store was "NOT being read". + * + * The repo keeps its own .gitmem/ for two things only: config.json with the + * project name (the SessionStart hook reads it from the repo) and the hook + * scripts (committed settings refer to them by relative path). + */ + +import { join } from "path"; +import { homedir } from "os"; + +/** The root the server reads. */ +export function storeRoot() { + if (process.env.GITMEM_DIR) return process.env.GITMEM_DIR; + return join(process.env.GITMEM_HOME || homedir(), ".gitmem"); +} + +/** The repo's .gitmem/: config.json and hooks/ only. */ +export function repoGitmemDir(cwd = process.cwd()) { + return join(cwd, ".gitmem"); +} + +/** A path shortened to ~ for display. */ +export function displayPath(p) { + const home = homedir(); + return p === home || p.startsWith(home + "/") ? "~" + p.slice(home.length) : p; +} diff --git a/bin/gitmem.js b/bin/gitmem.js index e2294bc..bad3b47 100755 --- a/bin/gitmem.js +++ b/bin/gitmem.js @@ -28,6 +28,7 @@ import { import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { homedir } from "os"; +import { storeRoot, displayPath } from "./gitmem-root.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const command = process.argv[2]; @@ -42,7 +43,7 @@ Usage: npx gitmem-mcp init --dry-run Show what would be configured npx gitmem-mcp init --client cursor Set up for Cursor IDE npx gitmem-mcp uninstall Clean removal of gitmem from project - npx gitmem-mcp uninstall --all Also delete .gitmem/ data directory + npx gitmem-mcp uninstall --all Also delete the memory store (~/.gitmem, shared by all projects) Pro Tier Activation: npx gitmem-mcp activate Activate Pro tier (license key + credentials wizard) @@ -115,25 +116,25 @@ async function cmdInit() { } if (!supabaseUrl || !supabaseKey) { - // Free tier: copy starter scars to local .gitmem/ directory + // Free tier: copy starter scars into the store the server reads (GIT-115). console.log("No Supabase credentials found — initializing free tier (local storage)."); console.log(""); - const gitmemDir = join(process.cwd(), ".gitmem"); + const gitmemDir = storeRoot(); + const storeName = displayPath(gitmemDir); if (!existsSync(gitmemDir)) { mkdirSync(gitmemDir, { recursive: true }); } - // Write config.json (with project if specified via --project) - const configPath = join(gitmemDir, "config.json"); + // The project name stays in the repo's .gitmem/config.json, where the + // SessionStart hook reads it. + const repoDir = join(process.cwd(), ".gitmem"); + const configPath = join(repoDir, "config.json"); if (!existsSync(configPath)) { - const config = {}; - if (projectArg) config.project = projectArg; - writeFileSync(configPath, JSON.stringify(config, null, 2)); if (projectArg) { + mkdirSync(repoDir, { recursive: true }); + writeFileSync(configPath, JSON.stringify({ project: projectArg }, null, 2)); console.log(` + Created .gitmem/config.json (project: "${projectArg}")`); - } else { - console.log(" + Created .gitmem/config.json"); } } else if (projectArg) { // Config exists — update project field @@ -205,7 +206,7 @@ async function cmdInit() { } console.log(""); - console.log(`Done: ${added} new scars added to .gitmem/learnings.json`); + console.log(`Done: ${added} new scars added to ${storeName}/learnings.json`); console.log(""); console.log("Add .gitmem/ to your .gitignore:"); console.log(" echo '.gitmem/' >> .gitignore"); diff --git a/bin/hooks-merge.js b/bin/hooks-merge.js new file mode 100644 index 0000000..9a8b11c --- /dev/null +++ b/bin/hooks-merge.js @@ -0,0 +1,130 @@ +/** + * GIT-120: merge gitmem's hooks into a settings file without touching anyone + * else's. + * + * install-hooks used to replace `settings.hooks` wholesale and uninstall-hooks + * deleted the key, so every hook the user or another tool had configured was + * lost. These helpers work per event and per command: only commands gitmem + * wrote are replaced or removed, and the file is backed up before any write. + * + * Two shapes are supported: + * Claude Code hooks[event] = [{ matcher?, hooks: [{ type, command, ... }] }] + * Cursor hooks[event] = [{ command, ... }] + */ + +import { copyFileSync, existsSync } from "fs"; + +/** + * A command gitmem installed: the per-repo copy (.gitmem/hooks/), the older + * node_modules path, or the legacy plugin. A bare "gitmem" substring is not + * enough — a user's own script can live in a directory called gitmem. + */ +export function isGitmemCommand(command) { + if (typeof command !== "string") return false; + return /(^|[\s"'=/])\.gitmem\/hooks\/[\w.-]+\.sh\b/.test(command) + || /gitmem-mcp\/hooks\/scripts\/[\w.-]+\.sh\b/.test(command) + || /\bgitmem-hooks\b/.test(command); +} + +const isGroup = (entry) => entry && typeof entry === "object" && Array.isArray(entry.hooks); + +/** Commands in one event's entries, as [gitmem, other] counts. */ +function countEntries(entries) { + let gitmem = 0; + let other = 0; + for (const entry of Array.isArray(entries) ? entries : []) { + const commands = isGroup(entry) ? entry.hooks : [entry]; + for (const h of commands) { + if (isGitmemCommand(h?.command)) gitmem++; else other++; + } + } + return { gitmem, other }; +} + +/** + * One event's entries with gitmem's commands taken out. A Claude group keeps + * its other commands; a group left empty is dropped. + */ +function withoutGitmem(entries) { + const kept = []; + for (const entry of Array.isArray(entries) ? entries : []) { + if (isGroup(entry)) { + const others = entry.hooks.filter((h) => !isGitmemCommand(h?.command)); + if (others.length === entry.hooks.length) kept.push(entry); + else if (others.length > 0) kept.push({ ...entry, hooks: others }); + } else if (!isGitmemCommand(entry?.command)) { + kept.push(entry); + } + } + return kept; +} + +/** The hooks object must be a plain object of arrays, or absent. */ +export function hooksShapeError(hooks) { + if (hooks === undefined) return null; + if (!hooks || typeof hooks !== "object" || Array.isArray(hooks)) return "hooks is not an object"; + for (const [event, entries] of Object.entries(hooks)) { + if (!Array.isArray(entries)) return `hooks.${event} is not an array`; + } + return null; +} + +/** True when any command in the hooks is one gitmem installed. */ +export function hasGitmemHooks(hooks) { + return Object.values(hooks || {}).some((entries) => countEntries(entries).gitmem > 0); +} + +/** + * Merge gitmem's hooks in: per event, drop the gitmem commands already there, + * keep everything else, append gitmem's current entries. + * Returns the new hooks and one change line per event touched. + */ +export function mergeGitmemHooks(existing, gitmemHooks) { + const hooks = { ...(existing || {}) }; + const changes = []; + for (const [event, entries] of Object.entries(gitmemHooks)) { + const before = countEntries(hooks[event]); + const added = countEntries(entries).gitmem; + hooks[event] = [...withoutGitmem(hooks[event]), ...entries]; + changes.push(`${event}: ${before.gitmem ? `replaced ${before.gitmem} gitmem hook(s) with ${added}` : `added ${added} gitmem hook(s)`}${before.other ? `, kept ${before.other} other` : ""}`); + } + // Events this installer does not manage are left exactly as they are. That + // includes gitmem's own UserPromptSubmit hook, which `gitmem init` installs + // and install-hooks does not. + for (const [event, entries] of Object.entries(hooks)) { + if (event in gitmemHooks) continue; + const c = countEntries(entries); + if (c.gitmem + c.other) changes.push(`${event}: kept ${c.gitmem + c.other} (untouched)`); + } + return { hooks, changes }; +} + +/** + * Take gitmem's commands out of every event. Events left empty are dropped; + * hooks is undefined when nothing remains. + */ +export function removeGitmemHooks(existing) { + const hooks = {}; + const changes = []; + let removed = 0; + let kept = 0; + for (const [event, entries] of Object.entries(existing || {})) { + const c = countEntries(entries); + removed += c.gitmem; + kept += c.other; + const rest = withoutGitmem(entries); + if (rest.length) hooks[event] = rest; + if (c.gitmem) changes.push(`${event}: removed ${c.gitmem} gitmem hook(s)${c.other ? `, kept ${c.other} other` : ""}`); + else if (c.other) changes.push(`${event}: kept ${c.other} other (untouched)`); + } + return { hooks: Object.keys(hooks).length ? hooks : undefined, removed, kept, changes }; +} + +/** Copy a file aside before it is rewritten; returns the backup path or null. */ +export function backupFile(path) { + if (!existsSync(path)) return null; + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const backup = `${path}.gitmem-backup-${stamp}`; + copyFileSync(path, backup); + return backup; +} diff --git a/bin/init-wizard.js b/bin/init-wizard.js index 261d4f1..e54f115 100644 --- a/bin/init-wizard.js +++ b/bin/init-wizard.js @@ -20,6 +20,8 @@ import { import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { createInterface } from "readline/promises"; +import { storeRoot, repoGitmemDir, displayPath } from "./gitmem-root.js"; +import { hasGitmemHooks, mergeGitmemHooks, removeGitmemHooks, backupFile } from "./hooks-merge.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const cwd = process.cwd(); @@ -166,7 +168,11 @@ const CLIENT_CONFIGS = { }; // Shared paths -const gitmemDir = join(cwd, ".gitmem"); +// GIT-115: the store goes where the server reads it; the repo keeps only +// config.json (project name, read by the SessionStart hook) and the hooks. +const repoDir = repoGitmemDir(cwd); +const storeDir = storeRoot(); +const storeName = displayPath(storeDir); const gitignorePath = join(cwd, ".gitignore"); const starterScarsPath = join(__dirname, "..", "schema", "starter-scars.json"); const hooksScriptsDir = join(__dirname, "..", "hooks", "scripts"); @@ -351,14 +357,9 @@ function buildCursorHooks() { }; } -function isGitmemHook(entry) { - if (entry.hooks && Array.isArray(entry.hooks)) { - return entry.hooks.some((h) => typeof h.command === "string" && h.command.includes("gitmem")); - } - if (typeof entry.command === "string") { - return entry.command.includes("gitmem"); - } - return false; +/** Commands in a hooks object that are not gitmem's (GIT-120 matching). */ +function countOtherHooks(hooks) { + return removeGitmemHooks(hooks).kept; } function getInstructionsTemplate() { @@ -370,7 +371,7 @@ function getInstructionsTemplate() { } function copyHookScripts() { - const destHooksDir = join(gitmemDir, "hooks"); + const destHooksDir = join(repoDir, "hooks"); if (!existsSync(destHooksDir)) { mkdirSync(destHooksDir, { recursive: true }); } @@ -393,8 +394,22 @@ function copyHookScripts() { // ── Steps ── // Each returns { done: bool } so main can track progress +/** + * GIT-115: the repo's .gitmem/config.json carries the project name, which the + * SessionStart hook reads from the repo. It is not the store's config.json. + */ +function writeRepoConfig() { + if (dryRun) return; + const configPath = join(repoDir, "config.json"); + const config = readJson(configPath) || {}; + if (projectName) config.project = projectName; + else if (existsSync(configPath)) return; + mkdirSync(repoDir, { recursive: true }); + writeJson(configPath, config); +} + async function stepMemoryStore() { - const learningsPath = join(gitmemDir, "learnings.json"); + const learningsPath = join(storeDir, "learnings.json"); const exists = existsSync(learningsPath); let existingCount = 0; if (exists) { @@ -412,15 +427,16 @@ async function stepMemoryStore() { } if (exists && existingCount >= starterScars.length) { - log(CHECK, `Memory store already set up ${C.dim}(${existingCount} lessons in .gitmem/)${C.reset}`); + writeRepoConfig(); + log(CHECK, `Memory store already set up ${C.dim}(${existingCount} lessons in ${storeName})${C.reset}`); return { done: false }; } // Needs merge — prompt if existing data OR interactive mode if (exists || interactive) { const prompt = exists - ? `Merge ${starterScars.length} lessons into .gitmem/? (${existingCount} existing)` - : `Create .gitmem/ with ${starterScars.length} starter lessons?`; + ? `Merge ${starterScars.length} lessons into ${storeName}? (${existingCount} existing)` + : `Create ${storeName} with ${starterScars.length} starter lessons?`; if (!(await confirm(prompt))) { log(SKIP, "Memory store skipped"); return { done: false }; @@ -428,28 +444,18 @@ async function stepMemoryStore() { } if (dryRun) { - log(CHECK, `Would create .gitmem/ with ${starterScars.length} starter lessons`, "[dry-run]"); + log(CHECK, `Would create ${storeName} with ${starterScars.length} starter lessons`, "[dry-run]"); return { done: true }; } - if (!existsSync(gitmemDir)) { - mkdirSync(gitmemDir, { recursive: true }); + if (!existsSync(storeDir)) { + mkdirSync(storeDir, { recursive: true }); } - // Config - const configPath = join(gitmemDir, "config.json"); - if (!existsSync(configPath)) { - const config = { feedback_enabled: false, telemetry_enabled: false }; - if (projectName) config.project = projectName; - writeJson(configPath, config); - } else if (projectName) { - const config = readJson(configPath) || {}; - config.project = projectName; - writeJson(configPath, config); - } + writeRepoConfig(); // Closing payload template (prevents permission prompt on first session close) - const payloadPath = join(gitmemDir, "closing-payload.json"); + const payloadPath = join(storeDir, "closing-payload.json"); if (!existsSync(payloadPath)) { writeJson(payloadPath, { closing_reflection: { @@ -495,7 +501,7 @@ async function stepMemoryStore() { writeJson(learningsPath, existing); // Starter thread — nudges user to add their own project-specific scar - const threadsPath = join(gitmemDir, "threads.json"); + const threadsPath = join(storeDir, "threads.json"); if (!existsSync(threadsPath)) { writeJson(threadsPath, [ { @@ -509,14 +515,14 @@ async function stepMemoryStore() { // Empty collection files for (const file of ["sessions.json", "decisions.json", "scar-usage.json"]) { - const filePath = join(gitmemDir, file); + const filePath = join(storeDir, file); if (!existsSync(filePath)) { writeFileSync(filePath, "[]"); } } // Closing payload template - const templatePath = join(gitmemDir, "closing-payload-template.json"); + const templatePath = join(storeDir, "closing-payload-template.json"); if (!existsSync(templatePath)) { writeJson(templatePath, { closing_reflection: { @@ -541,7 +547,7 @@ async function stepMemoryStore() { : ""; log(CHECK, - "Created .gitmem/ \u2014 your local memory store", + `Created ${storeName} \u2014 your local memory store`, `${starterScars.length} lessons from common mistakes included${mergeNote}` ); return { done: true }; @@ -722,19 +728,14 @@ async function stepHooks() { async function stepHooksClaude() { const existing = readJson(cc.settingsFile); const hooks = existing?.hooks || {}; - const hasGitmem = JSON.stringify(hooks).includes("gitmem"); + const hasGitmem = hasGitmemHooks(hooks); if (hasGitmem) { log(CHECK, `Automatic memory hooks already configured`); return { done: false }; } - let existingHookCount = 0; - for (const entries of Object.values(hooks)) { - if (Array.isArray(entries)) { - existingHookCount += entries.filter((e) => !isGitmemHook(e)).length; - } - } + const existingHookCount = countOtherHooks(hooks); // Existing hooks — prompt for merge if (existingHookCount > 0 || interactive) { @@ -759,16 +760,9 @@ async function stepHooksClaude() { mkdirSync(cc.configDir, { recursive: true }); } - const gitmemHooks = buildClaudeHooks(); - const merged = { ...(settings.hooks || {}) }; - - for (const [eventType, gitmemEntries] of Object.entries(gitmemHooks)) { - const existingEntries = merged[eventType] || []; - const nonGitmem = existingEntries.filter((e) => !isGitmemHook(e)); - merged[eventType] = [...nonGitmem, ...gitmemEntries]; - } - - settings.hooks = merged; + // GIT-120 merge: per event and per command; other hooks untouched. + settings.hooks = mergeGitmemHooks(settings.hooks, buildClaudeHooks()).hooks; + backupFile(cc.settingsFile); writeJson(cc.settingsFile, settings); const preserveNote = existingHookCount > 0 @@ -795,21 +789,14 @@ async function stepHooksCursor() { const hooksName = cc.hooksFileName; const existing = readJson(hooksPath); - const hasGitmem = existing ? JSON.stringify(existing).includes("gitmem") : false; + const hasGitmem = hasGitmemHooks(existing?.hooks); if (hasGitmem) { log(CHECK, `Automatic memory hooks already configured ${C.dim}(${hooksName})${C.reset}`); return { done: false }; } - let existingHookCount = 0; - if (existing?.hooks) { - for (const entries of Object.values(existing.hooks)) { - if (Array.isArray(entries)) { - existingHookCount += entries.filter((e) => !isGitmemHook(e)).length; - } - } - } + const existingHookCount = countOtherHooks(existing?.hooks); if (existingHookCount > 0 || interactive) { const prompt = existingHookCount > 0 @@ -832,17 +819,9 @@ async function stepHooksCursor() { mkdirSync(cc.configDir, { recursive: true }); } - const gitmemHooks = buildCursorHooks(); const config = existing || {}; - const merged = { ...(config.hooks || {}) }; - - for (const [eventType, gitmemEntries] of Object.entries(gitmemHooks)) { - const existingEntries = merged[eventType] || []; - const nonGitmem = existingEntries.filter((e) => !isGitmemHook(e)); - merged[eventType] = [...nonGitmem, ...gitmemEntries]; - } - - config.hooks = merged; + config.hooks = mergeGitmemHooks(config.hooks, buildCursorHooks()).hooks; + backupFile(hooksPath); writeJson(hooksPath, config); const preserveNote = existingHookCount > 0 @@ -937,7 +916,8 @@ async function stepAgentsMd() { } async function stepFeedbackOptIn() { - const configPath = join(gitmemDir, "config.json"); + // The server reads feedback_enabled from the store's config.json. + const configPath = join(storeDir, "config.json"); const config = readJson(configPath) || {}; // Already opted in — skip @@ -950,7 +930,7 @@ async function stepFeedbackOptIn() { if (!interactive && autoYes) { log(CHECK, "Anonymous feedback sharing is off", - "Run with --interactive to enable, or set feedback_enabled in .gitmem/config.json" + `Run with --interactive to enable, or set feedback_enabled in ${storeName}/config.json` ); return { done: false }; } @@ -964,7 +944,7 @@ async function stepFeedbackOptIn() { if (!accepted) { log(CHECK, "Anonymous feedback sharing is off", - "You can enable it later in .gitmem/config.json" + `You can enable it later in ${storeName}/config.json` ); return { done: false }; } @@ -975,6 +955,7 @@ async function stepFeedbackOptIn() { } config.feedback_enabled = true; + mkdirSync(storeDir, { recursive: true }); writeJson(configPath, config); log(CHECK, diff --git a/bin/uninstall.js b/bin/uninstall.js index f4b12ff..02c5224 100644 --- a/bin/uninstall.js +++ b/bin/uninstall.js @@ -14,10 +14,13 @@ import { writeFileSync, existsSync, rmSync, + readdirSync, } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; import { createInterface } from "readline/promises"; +import { storeRoot, repoGitmemDir, displayPath } from "./gitmem-root.js"; +import { removeGitmemHooks, backupFile } from "./hooks-merge.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const cwd = process.cwd(); @@ -122,7 +125,10 @@ function detectClient() { const client = detectClient(); const cc = CLIENT_CONFIGS[client]; -const gitmemDir = join(cwd, ".gitmem"); +// GIT-115: the repo's .gitmem/ (config.json + hooks) and the store the +// server reads are separate, and handled separately below. +const repoDir = repoGitmemDir(cwd); +const storeDir = storeRoot(); const gitignorePath = join(cwd, ".gitignore"); let rl; @@ -153,18 +159,6 @@ function writeJson(path, data) { writeFileSync(path, JSON.stringify(data, null, 2) + "\n"); } -function isGitmemHook(entry) { - if (entry.hooks && Array.isArray(entry.hooks)) { - return entry.hooks.some( - (h) => typeof h.command === "string" && h.command.includes("gitmem") - ); - } - if (typeof entry.command === "string") { - return entry.command.includes("gitmem"); - } - return false; -} - // ── Steps ── function stepInstructions() { @@ -232,94 +226,34 @@ function stepHooks() { return stepHooksCursor(); } -function stepHooksClaude() { - const settings = readJson(cc.settingsFile); - if (!settings?.hooks) { - log(SKIP, "No hooks in .claude/settings.json"); +/** + * GIT-120 matching: remove only the commands gitmem installed. A matcher group + * keeps its other commands; the hooks key goes only when nothing is left. + */ +function removeHooksFrom(file, name) { + const cfg = readJson(file); + if (!cfg?.hooks) { + log(SKIP, `No hooks in ${name}`); return; } - - let removed = 0; - let preserved = 0; - const cleaned = {}; - - for (const [eventType, entries] of Object.entries(settings.hooks)) { - if (!Array.isArray(entries)) continue; - const nonGitmem = entries.filter((e) => { - if (isGitmemHook(e)) { - removed++; - return false; - } - preserved++; - return true; - }); - if (nonGitmem.length > 0) { - cleaned[eventType] = nonGitmem; - } - } - - if (removed === 0) { + const result = removeGitmemHooks(cfg.hooks); + if (result.removed === 0) { log(SKIP, "No gitmem hooks found"); return; } + if (result.hooks) cfg.hooks = result.hooks; else delete cfg.hooks; + const backup = backupFile(file); + writeJson(file, cfg); + const kept = result.kept > 0 ? `(${result.kept} other hook${result.kept !== 1 ? "s" : ""} preserved)` : ""; + log(CHECK, "Removed automatic memory hooks", [kept, backup ? `backup: ${displayPath(backup)}` : ""].filter(Boolean).join(" ")); +} - if (Object.keys(cleaned).length > 0) { - settings.hooks = cleaned; - } else { - delete settings.hooks; - } - - writeJson(cc.settingsFile, settings); - if (preserved > 0) { - log(CHECK, "Removed automatic memory hooks", `(${preserved} other hook${preserved !== 1 ? "s" : ""} preserved)`); - } else { - log(CHECK, "Removed automatic memory hooks"); - } +function stepHooksClaude() { + removeHooksFrom(cc.settingsFile, ".claude/settings.json"); } function stepHooksCursor() { - const config = readJson(cc.hooksFile); - if (!config?.hooks) { - log(SKIP, `No hooks in ${cc.hooksFileName}`); - return; - } - - let removed = 0; - let preserved = 0; - const cleaned = {}; - - for (const [eventType, entries] of Object.entries(config.hooks)) { - if (!Array.isArray(entries)) continue; - const nonGitmem = entries.filter((e) => { - if (isGitmemHook(e)) { - removed++; - return false; - } - preserved++; - return true; - }); - if (nonGitmem.length > 0) { - cleaned[eventType] = nonGitmem; - } - } - - if (removed === 0) { - log(SKIP, "No gitmem hooks found"); - return; - } - - if (Object.keys(cleaned).length > 0) { - config.hooks = cleaned; - } else { - delete config.hooks; - } - - writeJson(cc.hooksFile, config); - if (preserved > 0) { - log(CHECK, "Removed automatic memory hooks", `(${preserved} other hook${preserved !== 1 ? "s" : ""} preserved)`); - } else { - log(CHECK, "Removed automatic memory hooks"); - } + removeHooksFrom(cc.hooksFile, cc.hooksFileName); } function stepPermissions() { @@ -359,23 +293,51 @@ function stepPermissions() { log(CHECK, "Removed tool permissions"); } +/** + * GIT-115: the repo's .gitmem/ holds gitmem's config.json and hooks/, which + * are this repo's integration and go with it. Anything else in there (a store + * from an older install) is left alone. + */ +function stepRepoDir() { + if (!existsSync(repoDir)) { + log(SKIP, "No .gitmem/ in this repo"); + return; + } + for (const entry of ["config.json", "hooks"]) { + rmSync(join(repoDir, entry), { recursive: true, force: true }); + } + const rest = readdirSync(repoDir); + if (rest.length === 0) { + rmSync(repoDir, { recursive: true, force: true }); + log(CHECK, "Removed .gitmem/ from this repo", "(config.json and hooks)"); + } else { + log(CHECK, "Removed .gitmem/config.json and .gitmem/hooks/", + `(${rest.length} other file${rest.length !== 1 ? "s" : ""} left in .gitmem/)`); + } +} + +/** + * The memory store is shared by every project on this machine, so it is kept + * unless --all is given or the user says to delete it. + */ async function stepGitmemDir() { - if (!existsSync(gitmemDir)) { - log(SKIP, "No .gitmem/ directory"); + const name = displayPath(storeDir); + if (!existsSync(storeDir)) { + log(SKIP, `No memory store at ${name}`); return; } if (deleteAll) { - rmSync(gitmemDir, { recursive: true, force: true }); - log(CHECK, "Deleted .gitmem/ directory"); + rmSync(storeDir, { recursive: true, force: true }); + log(CHECK, `Deleted memory store ${name}`); return; } - if (await confirm("Keep .gitmem/ memory data for future use?", true)) { - log(CHECK, ".gitmem/ preserved — your memories will be here if you reinstall"); + if (await confirm(`Keep memory store ${name}? (shared by every project on this machine)`, true)) { + log(CHECK, `${name} preserved — your memories will be there if you reinstall`); } else { - rmSync(gitmemDir, { recursive: true, force: true }); - log(CHECK, "Deleted .gitmem/ directory"); + rmSync(storeDir, { recursive: true, force: true }); + log(CHECK, `Deleted memory store ${name}`); } } @@ -406,6 +368,7 @@ async function main() { stepMcpJson(); stepHooks(); stepPermissions(); + stepRepoDir(); await stepGitmemDir(); stepGitignore(); diff --git a/hooks/scripts/session-start.sh b/hooks/scripts/session-start.sh index 8e11fbc..d50f77b 100755 --- a/hooks/scripts/session-start.sh +++ b/hooks/scripts/session-start.sh @@ -191,9 +191,15 @@ if [ "$GITMEM_DETECTED" = "true" ]; then # # Read project from .gitmem/config.json if available. # Fallback: let session_start default (reads config.json server-side too). + # GIT-115: this is the repo's config.json, not the store's; it stays in the + # repo so each repo keeps its own project. node when jq is absent. GITMEM_PROJECT="" - if [ -f ".gitmem/config.json" ] && command -v jq &>/dev/null; then - GITMEM_PROJECT=$(jq -r '.project // empty' .gitmem/config.json 2>/dev/null || true) + if [ -f ".gitmem/config.json" ]; then + if command -v jq &>/dev/null; then + GITMEM_PROJECT=$(jq -r '.project // empty' .gitmem/config.json 2>/dev/null || true) + elif command -v node &>/dev/null; then + GITMEM_PROJECT=$(node -e "try{const p=JSON.parse(require('fs').readFileSync('.gitmem/config.json','utf8')).project;if(typeof p==='string')process.stdout.write(p)}catch(e){}" 2>/dev/null || true) + fi fi if [ -n "$GITMEM_PROJECT" ]; then diff --git a/hooks/tests/test-hooks.sh b/hooks/tests/test-hooks.sh index 95d5df8..c2b0d75 100755 --- a/hooks/tests/test-hooks.sh +++ b/hooks/tests/test-hooks.sh @@ -128,6 +128,22 @@ else pass "Output is plain text, not JSON" fi +# Test 1.2b (GIT-115): the project hint comes from the repo's .gitmem/config.json, +# with or without jq (node fallback). +# A repo of its own: the suite's GITMEM_DIR ($TMPDIR/.gitmem) is the store. +mkdir -p "$TMPDIR/repo-git115/.gitmem" +cp "$TMPDIR/.mcp.json" "$TMPDIR/repo-git115/.mcp.json" +echo '{"project":"acme-hooks"}' > "$TMPDIR/repo-git115/.gitmem/config.json" +OUTPUT=$(cd "$TMPDIR/repo-git115" && echo '{}' | bash "$SCRIPT_DIR/scripts/session-start.sh" 2>/dev/null) +if echo "$OUTPUT" | grep -q 'session_start(project: "acme-hooks")'; then + pass "Project hint read from the repo's .gitmem/config.json" +else + fail "Project hint read from the repo's .gitmem/config.json" \ + 'Contains session_start(project: "acme-hooks")' \ + "$OUTPUT" +fi +rm -rf "$TMPDIR/repo-git115" + # Test 1.3: Gitmem NOT in .mcp.json (may still detect via disk fallback) rm "$TMPDIR/.mcp.json" echo '{"mcpServers":{"other-tool":{}}}' > "$TMPDIR/.mcp.json" diff --git a/src/commands/telemetry.ts b/src/commands/telemetry.ts index 80068a5..b01838c 100644 --- a/src/commands/telemetry.ts +++ b/src/commands/telemetry.ts @@ -9,6 +9,8 @@ */ import { getTelemetry, Telemetry } from "../lib/telemetry.js"; +// GIT-115: telemetry state lives in the store the server reads, not /.gitmem. +import { getGitmemDir } from "../services/gitmem-dir.js"; import { join } from "path"; import { readFileSync } from "fs"; @@ -23,9 +25,6 @@ function getPackageVersion(): string { } } -function getGitmemDir(): string { - return join(process.cwd(), ".gitmem"); -} export function main(args: string[]): void { const subcommand = args[0]; @@ -117,7 +116,7 @@ function cmdEnable(telemetry: Telemetry): void { console.log(" ✗ IP addresses or persistent IDs"); console.log(""); console.log("Transparency:"); - console.log(" • All events logged to .gitmem/telemetry.log"); + console.log(` • All events logged to ${join(getGitmemDir(), "telemetry.log")}`); console.log(" • View before sending: gitmem telemetry show"); console.log(" • Disable anytime: gitmem telemetry disable"); console.log(""); @@ -139,7 +138,7 @@ function cmdEnable(telemetry: Telemetry): void { telemetry.enable(); console.log(""); console.log("\x1b[32m✓\x1b[0m Telemetry enabled"); - console.log(" Data logged to: .gitmem/telemetry.log"); + console.log(` Data logged to: ${join(getGitmemDir(), "telemetry.log")}`); console.log(" Review anytime: gitmem telemetry show"); console.log(" Disable anytime: gitmem telemetry disable"); } else { @@ -160,7 +159,7 @@ function cmdDisable(telemetry: Telemetry): void { console.log("==========================\n"); console.log("\x1b[32m✓\x1b[0m Telemetry disabled"); console.log(" Pending events: will not be sent"); - console.log(" Local logs: preserved at .gitmem/telemetry.log"); + console.log(` Local logs: preserved at ${join(getGitmemDir(), "telemetry.log")}`); console.log(""); console.log("To re-enable: gitmem telemetry enable"); } diff --git a/src/services/gitmem-dir.ts b/src/services/gitmem-dir.ts index feba098..3f11d8d 100644 --- a/src/services/gitmem-dir.ts +++ b/src/services/gitmem-dir.ts @@ -15,6 +15,7 @@ import * as path from "path"; import * as fs from "fs"; import * as os from "os"; +import { fileURLToPath } from "url"; /** * Validate a string intended for use as a single path component (directory name or filename). @@ -272,9 +273,16 @@ function warnAboutStrandedProjectRoots(home: string): void { * without ever having held a session. * * Any ONE of these counts: - * - config.json a deliberate project-scoped install * - a registered session the registry names at least one * - a real session dir sessions//session.json parses with a session_id + * - a real record a learning, thread or decision that `init` did not seed + * + * GIT-115: config.json no longer counts. A repo's .gitmem/ now holds its + * config.json (the project name the SessionStart hook reads) and the hook + * scripts on purpose, while the store lives in the root the server reads. A + * repo set up that way is not a stranded store. Starter lessons and the + * welcome thread are not evidence either: every older `init` seeded them into + * the repo, and the root being read has its own copy. * * Exported for tests and diagnostics; the resolution path is the only caller * that matters. @@ -283,8 +291,7 @@ export function isLiveGitmemRoot(candidate: string): boolean { try { if (!fs.existsSync(candidate)) return false; - // A project-scoped install is deliberate — honour it even when idle. - if (fs.existsSync(path.join(candidate, "config.json"))) return true; + if (holdsRealRecords(candidate)) return true; const registryPath = path.join(candidate, "active-sessions.json"); if (fs.existsSync(registryPath)) { @@ -317,6 +324,43 @@ export function isLiveGitmemRoot(candidate: string): boolean { } } +/** What `init` seeds: starter lesson ids and the welcome thread (GIT-115). */ +const WELCOME_THREAD_ID = "t-welcome01"; +let starterIds: Set | null = null; +function starterLearningIds(): Set { + if (starterIds) return starterIds; + starterIds = new Set(); + try { + // src/services or dist/services → /schema/starter-scars.json + const here = path.dirname(fileURLToPath(import.meta.url)); + const file = path.join(here, "..", "..", "schema", "starter-scars.json"); + for (const s of JSON.parse(fs.readFileSync(file, "utf-8"))) if (s?.id) starterIds.add(String(s.id)); + } catch { + // Unreadable: every learning then counts, which errs toward reporting. + } + return starterIds; +} + +/** Entries of a store file that may be a bare array or {key: array}. */ +function readCollection(file: string, key: string): Array> { + try { + if (!fs.existsSync(file)) return []; + const parsed = JSON.parse(fs.readFileSync(file, "utf-8")); + const list = Array.isArray(parsed) ? parsed : parsed?.[key]; + return Array.isArray(list) ? list : []; + } catch { + return []; + } +} + +/** GIT-115: a learning, thread or decision that `init` did not seed. */ +function holdsRealRecords(root: string): boolean { + const starters = starterLearningIds(); + if (readCollection(path.join(root, "learnings.json"), "learnings").some((l) => !starters.has(String(l?.id)))) return true; + if (readCollection(path.join(root, "threads.json"), "threads").some((t) => String(t?.id) !== WELCOME_THREAD_ID)) return true; + return readCollection(path.join(root, "decisions.json"), "decisions").length > 0; +} + /** * Get a file path within the .gitmem directory */ diff --git a/tests/e2e/cli-fresh-install.test.ts b/tests/e2e/cli-fresh-install.test.ts index d7d6e3e..49f81fd 100644 --- a/tests/e2e/cli-fresh-install.test.ts +++ b/tests/e2e/cli-fresh-install.test.ts @@ -17,6 +17,7 @@ import { execFile as execFileCb } from "child_process"; import { promisify } from "util"; import { mkdirSync, + mkdtempSync, rmSync, existsSync, readFileSync, @@ -38,6 +39,11 @@ import { } from "./mcp-client.js"; const execFile = promisify(execFileCb); + +// GIT-115: init writes the store to the root the server reads (~/.gitmem). +// Every CLI call here runs against a scratch HOME, never the developer's. +const SANDBOX_HOME = mkdtempSync(join(tmpdir(), "gitmem-cli-home-")); +afterAll(() => rmSync(SANDBOX_HOME, { recursive: true, force: true })); const GITMEM_BIN = join(__dirname, "../../bin/gitmem.js"); /** @@ -52,6 +58,9 @@ async function runGitmem( ): Promise<{ stdout: string; stderr: string; exitCode: number }> { const env = { ...process.env, + HOME: SANDBOX_HOME, + GITMEM_DIR: "", + GITMEM_HOME: "", // Ensure free tier unless overridden SUPABASE_URL: "", SUPABASE_SERVICE_ROLE_KEY: "", @@ -90,15 +99,16 @@ describe("Fresh Install: Free Tier CLI", () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); }); - it("gitmem init creates .gitmem/ with starter scars", async () => { + it("gitmem init creates the store in ~/.gitmem with starter scars", async () => { const { stdout, exitCode } = await runGitmem(["init", "--yes"], { cwd: TEST_DIR }); expect(exitCode).toBe(0); - expect(stdout.toLowerCase()).toContain("free"); + expect(stdout).toContain("Created ~/.gitmem"); - // .gitmem/ directory created - const gitmemDir = join(TEST_DIR, ".gitmem"); + // GIT-115: the store is where the server reads it, not /.gitmem + const gitmemDir = join(SANDBOX_HOME, ".gitmem"); expect(existsSync(gitmemDir)).toBe(true); + expect(existsSync(join(TEST_DIR, ".gitmem", "learnings.json"))).toBe(false); // learnings.json has starter scars const learningsPath = join(gitmemDir, "learnings.json"); @@ -112,20 +122,20 @@ describe("Fresh Install: Free Tier CLI", () => { expect(existsSync(join(gitmemDir, "scar-usage.json"))).toBe(true); // stdout shows scar count - const countMatch = stdout.match(/(\d+) starter scars/) || stdout.match(/(\d+) new scars added/); + const countMatch = stdout.match(/(\d+) lessons from common mistakes/); expect(countMatch).not.toBeNull(); expect(parseInt(countMatch![1])).toBeGreaterThan(0); }); it("gitmem init is idempotent (no duplicates on re-run)", async () => { // Count scars before second run - const learningsPath = join(TEST_DIR, ".gitmem", "learnings.json"); + const learningsPath = join(SANDBOX_HOME, ".gitmem", "learnings.json"); const beforeCount = JSON.parse(readFileSync(learningsPath, "utf-8")).length; const { stdout, exitCode } = await runGitmem(["init", "--yes"], { cwd: TEST_DIR }); expect(exitCode).toBe(0); - expect(stdout.toLowerCase()).toContain("already configured"); + expect(stdout.toLowerCase()).toContain("already set up"); // Same count — no duplicates const afterCount = JSON.parse(readFileSync(learningsPath, "utf-8")).length; @@ -338,15 +348,16 @@ describe("Fresh Install: MCP Server Lifecycle", () => { if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); mkdirSync(TEST_DIR, { recursive: true }); - // Run init to populate .gitmem/ + // Run init to populate the store (GIT-115: ~/.gitmem, here the sandbox HOME) await runGitmem(["init", "--yes"], { cwd: TEST_DIR }); - // Start MCP server with CWD in the initialized directory - // so it walks up and finds .gitmem/ with starter scars + // Start the MCP server on the same HOME, so it reads what init wrote mcp = await createMcpClient( { ...createTierEnv("free"), - HOME: TEST_DIR, + HOME: SANDBOX_HOME, + GITMEM_DIR: "", + GITMEM_HOME: "", }, { cwd: TEST_DIR } ); @@ -556,6 +567,9 @@ describe("Fresh Install: Hook Script Output", () => { ...process.env, CLAUDE_SESSION_ID: sessionId, HOME: TEST_DIR, + // GIT-99: the hook reads the registry from the server's root, not + // its cwd; name the test's root explicitly. + GITMEM_DIR: join(sessionDir, ".gitmem"), }, timeout: 10_000, } diff --git a/tests/e2e/git-115-fresh-install.test.ts b/tests/e2e/git-115-fresh-install.test.ts new file mode 100644 index 0000000..429c80d --- /dev/null +++ b/tests/e2e/git-115-fresh-install.test.ts @@ -0,0 +1,156 @@ +/** + * GIT-115: a fresh install reads what init wrote. + * + * init seeded /.gitmem while the server reads ~/.gitmem (GIT-91), so a + * new user's first session had no starter lessons and was told its store was + * "NOT being read". Now the store goes to the root the server reads, and the + * repo keeps only config.json (the project name) and the hook scripts. + * + * Clean HOME, clean repo, the real CLI, the real SessionStart hook and the + * built server. No Supabase, no Docker: safe for CI. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { execFile as execFileCb } from "child_process"; +import { promisify } from "util"; +import { mkdtempSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, existsSync, realpathSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { createMcpClient, callTool, getToolResultText, isToolError, type McpTestClient } from "./mcp-client.js"; + +const execFile = promisify(execFileCb); +const GITMEM_BIN = join(__dirname, "../../bin/gitmem.js"); +const STARTER_COUNT = JSON.parse(readFileSync(join(__dirname, "../../schema/starter-scars.json"), "utf-8")).length; +const PROJECT = "acme-git115"; + +let home: string; +let repo: string; +let mcp: McpTestClient | undefined; + +/** The environment of a new user: nothing gitmem-specific set. */ +function cleanEnv(): Record { + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (v !== undefined && !/^(GITMEM_|SUPABASE_|OPENAI_|OPENROUTER_)/.test(k)) env[k] = v; + } + // GITMEM_DIR/GITMEM_HOME blank as well: createMcpClient layers process.env underneath. + return { ...env, HOME: home, GITMEM_DIR: "", GITMEM_HOME: "", NO_COLOR: "1", GITMEM_TIER: "free", SUPABASE_URL: "", SUPABASE_SERVICE_ROLE_KEY: "" }; +} + +async function run(cmd: string, args: string[], input?: string) { + const p = execFile(cmd, args, { cwd: repo, env: cleanEnv(), timeout: 60_000 }); + if (input !== undefined) { p.child.stdin?.end(input); } + try { + const { stdout, stderr } = await p; + return { stdout, stderr, code: 0 }; + } catch (e: any) { + return { stdout: e.stdout ?? "", stderr: e.stderr ?? "", code: typeof e.code === "number" ? e.code : 1 }; + } +} + +let initOut = ""; +let hookOut = ""; +let startText = ""; +let recallText = ""; + +beforeAll(async () => { + home = realpathSync(mkdtempSync(join(tmpdir(), "gitmem-git115-home-"))); + repo = realpathSync(mkdtempSync(join(tmpdir(), "gitmem-git115-repo-"))); + + const init = await run("node", [GITMEM_BIN, "init", "--yes", "--project", PROJECT, "--client", "claude"]); + initOut = init.stdout + init.stderr; + + // The SessionStart hook, as Claude Code runs it: from the repo, JSON on stdin. + hookOut = (await run("bash", [".gitmem/hooks/session-start.sh"], "{}")).stdout; + + // The project the hook tells the agent to use, straight from the repo config. + const hinted = (hookOut.match(/session_start\(project: "([^"]+)"\)/) || [])[1]; + + mcp = await createMcpClient(cleanEnv(), { cwd: repo }); + const result = await callTool(mcp.client, "session_start", { + agent_identity: "cli", force: true, ...(hinted ? { project: hinted } : {}), + }); + startText = isToolError(result) ? `ERROR ${getToolResultText(result)}` : getToolResultText(result); + const rc = await callTool(mcp.client, "recall", { plan: "run the database migration without a rollback plan", project: PROJECT }); + recallText = isToolError(rc) ? `ERROR ${getToolResultText(rc)}` : getToolResultText(rc); +}, 120_000); + +afterAll(async () => { + if (mcp) await mcp.cleanup(); + rmSync(home, { recursive: true, force: true }); + rmSync(repo, { recursive: true, force: true }); +}); + +describe("GIT-115: fresh install, clean HOME", () => { + it("init seeds the starter lessons into the root the server reads", () => { + const learnings = JSON.parse(readFileSync(join(home, ".gitmem", "learnings.json"), "utf-8")); + expect(learnings).toHaveLength(STARTER_COUNT); + expect(existsSync(join(home, ".gitmem", "threads.json"))).toBe(true); + expect(existsSync(join(home, ".gitmem", "closing-payload-template.json"))).toBe(true); + expect(initOut).toContain("~/.gitmem"); + }); + + it("the repo keeps only config.json (with the project) and the hooks", () => { + expect(readdirSync(join(repo, ".gitmem")).sort()).toEqual(["config.json", "hooks"]); + expect(JSON.parse(readFileSync(join(repo, ".gitmem", "config.json"), "utf-8"))).toEqual({ project: PROJECT }); + }); + + it("the SessionStart hook names the project from the repo config", () => { + expect(hookOut).toContain(`session_start(project: "${PROJECT}")`); + }); + + it("session_start reads what init seeded, under the repo's project", () => { + expect(startText).not.toMatch(/^ERROR/); + expect(startText).toContain(`· ${PROJECT}`); + // The welcome thread init wrote into the store. + expect(startText).toContain("Add your first project-specific scar"); + }); + + it("recall in that session returns a starter scar", () => { + expect(recallText).not.toMatch(/^ERROR/); + expect(recallText).toContain("Database Migration Without Rollback Plan"); + }); + + it("session_start does not report the repo's .gitmem as a store NOT being read", () => { + expect(startText).not.toContain("NOT being read"); + }); +}); + +describe("GIT-115: uninstall follows the same split", () => { + it("init installs its hooks beside a foreign hook under a gitmem-named path (GIT-120 matching)", async () => { + // The wizard's old substring match took this hook for gitmem's own and + // skipped installing gitmem's hooks altogether. + const other = realpathSync(mkdtempSync(join(tmpdir(), "gitmem-git115-repo2-"))); + try { + const foreign = { hooks: { PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "bash ~/code/gitmem-notes/lint.sh" }] }] } }; + mkdirSync(join(other, ".claude")); + writeFileSync(join(other, ".claude", "settings.json"), JSON.stringify(foreign, null, 2)); + const r = await execFile("node", [GITMEM_BIN, "init", "--yes", "--client", "claude"], { cwd: other, env: cleanEnv(), timeout: 60_000 }); + expect(r.stdout).toContain("Added automatic memory hooks"); + const text = readFileSync(join(other, ".claude", "settings.json"), "utf-8"); + expect(text).toContain("gitmem-notes/lint.sh"); + expect(text).toContain(".gitmem/hooks/session-start.sh"); + } finally { + rmSync(other, { recursive: true, force: true }); + } + }); + + it("uninstall removes the repo's config and hooks, keeps foreign hooks, and keeps the shared store", async () => { + const settingsPath = join(repo, ".claude", "settings.json"); + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); + settings.hooks.PreToolUse.find((g: any) => g.matcher === "Bash").hooks.push({ type: "command", command: "bash ~/code/gitmem-notes/lint.sh" }); + writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + const r = await run("node", [GITMEM_BIN, "uninstall", "--yes", "--client", "claude"]); + expect(r.code).toBe(0); + expect(existsSync(join(repo, ".gitmem"))).toBe(false); + const hooks = JSON.parse(readFileSync(join(repo, ".claude", "settings.json"), "utf-8")).hooks; + expect(JSON.stringify(hooks)).toContain("gitmem-notes/lint.sh"); + expect(JSON.stringify(hooks)).not.toContain(".gitmem/hooks/"); + expect(existsSync(join(home, ".gitmem", "learnings.json"))).toBe(true); + }); + + it("uninstall --all deletes the store", async () => { + const r = await run("node", [GITMEM_BIN, "uninstall", "--yes", "--all", "--client", "claude"]); + expect(r.code).toBe(0); + expect(existsSync(join(home, ".gitmem"))).toBe(false); + }); +}); diff --git a/tests/unit/services/gitmem-root-resolution.test.ts b/tests/unit/services/gitmem-root-resolution.test.ts index cdd086e..1bf6259 100644 --- a/tests/unit/services/gitmem-root-resolution.test.ts +++ b/tests/unit/services/gitmem-root-resolution.test.ts @@ -140,10 +140,38 @@ describe("GIT-91: isLiveGitmemRoot only counts real evidence", () => { vi.restoreAllMocks(); }); - it("counts a deliberate project-scoped install (config.json)", () => { + // GIT-115: a repo's .gitmem/ holds config.json (the project name) and the + // hook scripts on purpose; the store lives in the root the server reads. + it("does not count a repo .gitmem/ holding only config.json and hooks", () => { const gitmem = path.join(tmp, ".gitmem"); - fs.mkdirSync(gitmem, { recursive: true }); + fs.mkdirSync(path.join(gitmem, "hooks"), { recursive: true }); fs.writeFileSync(path.join(gitmem, "config.json"), JSON.stringify({ project: "x" })); + fs.writeFileSync(path.join(gitmem, "hooks", "session-start.sh"), "#!/bin/bash\n"); + + expect(isLiveGitmemRoot(gitmem)).toBe(false); + }); + + it("does not count what an older init seeded: starter lessons and the welcome thread", () => { + const gitmem = path.join(tmp, ".gitmem"); + fs.mkdirSync(gitmem, { recursive: true }); + const starters = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../schema/starter-scars.json"), "utf-8")); + fs.writeFileSync(path.join(gitmem, "learnings.json"), JSON.stringify(starters)); + fs.writeFileSync(path.join(gitmem, "threads.json"), JSON.stringify([{ id: "t-welcome01", text: "welcome" }])); + fs.writeFileSync(path.join(gitmem, "decisions.json"), "[]"); + fs.writeFileSync(path.join(gitmem, "config.json"), "{}"); + + expect(isLiveGitmemRoot(gitmem)).toBe(false); + }); + + it.each([ + ["a learning of the user's own", "learnings.json", [{ id: "user-learning-1", title: "mine" }]], + ["a thread of the user's own", "threads.json", [{ id: "t-abc12345", text: "mine" }]], + ["a decision", "decisions.json", [{ id: "d-1", title: "chose X" }]], + ["records in the {key: array} shape", "learnings.json", { learnings: [{ id: "user-learning-2" }] }], + ])("counts %s", (_label, file, content) => { + const gitmem = path.join(tmp, ".gitmem"); + fs.mkdirSync(gitmem, { recursive: true }); + fs.writeFileSync(path.join(gitmem, file), JSON.stringify(content)); expect(isLiveGitmemRoot(gitmem)).toBe(true); }); diff --git a/tests/unit/services/gitmem-root-symlink.test.ts b/tests/unit/services/gitmem-root-symlink.test.ts index a7ec2cc..19eca86 100644 --- a/tests/unit/services/gitmem-root-symlink.test.ts +++ b/tests/unit/services/gitmem-root-symlink.test.ts @@ -15,11 +15,15 @@ import { findStrandedProjectRoots, sameDirectory, canonicalPath, clearGitmemDirC 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. */ +/** + * A live project-scoped store: it holds a learning of the user's own. + * (GIT-115: config.json alone no longer marks one; a repo keeps its + * config.json and hooks in .gitmem/ while the store lives elsewhere.) + */ 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" })); + fs.writeFileSync(path.join(g, "learnings.json"), JSON.stringify([{ id: "user-learning", title: "mine" }])); return g; }