From 21d64d5ce825e44313ed3672950e104582b8714b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 22 Sep 2026 20:49:12 -0700 Subject: [PATCH] fix: install-hooks/uninstall-hooks merge per event and keep foreign hooks (GIT-120) install-hooks replaced settings.hooks wholesale and uninstall-hooks deleted the key, so users' own hooks were lost. Both now work per event and per command: only commands gitmem installed are replaced or removed, a matcher group keeps its other commands, events install-hooks does not manage stay untouched, the file is backed up before every write, and the output lists what changed. An unparseable file is no longer overwritten "fresh". gitmem detection matches gitmem's script paths instead of any "gitmem" substring, which made a foreign hook under a gitmem-named directory look like an existing install. Tests: new CLI e2e (9) with foreign hooks present, wired into CI. Two cli-fresh-install assertions that pinned the old wipe are updated. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 5 + bin/gitmem.js | 183 ++++++++++++------------- bin/hooks-merge.js | 130 ++++++++++++++++++ tests/e2e/cli-fresh-install.test.ts | 10 +- tests/e2e/git-120-hooks-merge.test.ts | 187 ++++++++++++++++++++++++++ 5 files changed, 413 insertions(+), 102 deletions(-) create mode 100644 bin/hooks-merge.js create mode 100644 tests/e2e/git-120-hooks-merge.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a92f871..8d9fe5a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,11 @@ 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-120: install-hooks / uninstall-hooks keep foreign hooks. Runs the + # real CLI in a scratch repo; no server, Docker or credentials. + - name: E2E — hooks install/uninstall keep foreign hooks + run: npx vitest run --config vitest.e2e.config.ts tests/e2e/git-120-hooks-merge.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.js b/bin/gitmem.js index e2294bc..1dc71d6 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 { hasGitmemHooks, hooksShapeError, mergeGitmemHooks, removeGitmemHooks, backupFile } from "./hooks-merge.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const command = process.argv[2]; @@ -479,6 +480,39 @@ async function cmdSessionRefresh() { } } +/** + * GIT-120: read a hooks-bearing JSON file for an in-place edit. A file that + * exists but cannot be parsed, or whose hooks have an unexpected shape, is + * never rewritten — that used to replace the user's file with a fresh one. + */ +function readHooksFile(path, name) { + if (!existsSync(path)) return {}; + let cfg; + try { + cfg = JSON.parse(readFileSync(path, "utf-8")); + } catch (err) { + console.error(`Error: ${name} is not valid JSON (${err.message}). Nothing was changed.`); + console.error(`Fix or move ${name}, then run this again.`); + process.exit(1); + } + const shapeError = cfg && typeof cfg === "object" && !Array.isArray(cfg) ? hooksShapeError(cfg.hooks) : "file is not a JSON object"; + if (shapeError) { + console.error(`Error: ${name}: ${shapeError}. Nothing was changed.`); + process.exit(1); + } + return cfg; +} + +/** Back up, write, and print what changed. */ +function writeHooksFile(path, name, cfg, changes) { + const backup = backupFile(path); + writeFileSync(path, JSON.stringify(cfg, null, 2)); + console.log(`Changes to ${name}:`); + for (const line of changes) console.log(` ${line}`); + console.log(backup ? `Backup of the previous file: ${backup}` : "(new file, nothing to back up)"); + console.log(""); +} + /** * Install gitmem hooks as project-level hooks. * @@ -560,30 +594,21 @@ function cmdInstallHooks() { stop: [{ command: `bash ${relScripts}/session-close-check.sh`, timeout: 5000 }], }; - let config = {}; - if (existsSync(hooksPath)) { - try { - config = JSON.parse(readFileSync(hooksPath, "utf-8")); - } catch { - console.warn(" Warning: Could not parse existing .cursor/hooks.json, creating fresh"); - } - } else { - mkdirSync(cursorDir, { recursive: true }); - } + const config = readHooksFile(hooksPath, ".cursor/hooks.json"); + if (!existsSync(cursorDir)) mkdirSync(cursorDir, { recursive: true }); - if (config.hooks && !force) { - const hasGitmem = JSON.stringify(config.hooks).includes("gitmem"); - if (hasGitmem) { - console.log("GitMem hooks already installed in .cursor/hooks.json"); - console.log(""); - console.log("To reinstall (overwrite), run:"); - console.log(" npx gitmem-mcp install-hooks --client cursor --force"); - return; - } + if (!force && hasGitmemHooks(config.hooks)) { + console.log("GitMem hooks already installed in .cursor/hooks.json"); + console.log(""); + console.log("To reinstall (replaces only gitmem's hooks), run:"); + console.log(" npx gitmem-mcp install-hooks --client cursor --force"); + return; } - config.hooks = gitmemHooks; - writeFileSync(hooksPath, JSON.stringify(config, null, 2)); + // GIT-120: merge per event; other hooks stay as they are. + const merged = mergeGitmemHooks(config.hooks, gitmemHooks); + config.hooks = merged.hooks; + writeHooksFile(hooksPath, ".cursor/hooks.json", config, merged.changes); console.log("Hooks written to .cursor/hooks.json"); console.log(`Scripts at: ${relScripts}/`); @@ -634,30 +659,21 @@ function cmdInstallHooks() { ], }; - let settings = {}; - if (existsSync(settingsPath)) { - try { - settings = JSON.parse(readFileSync(settingsPath, "utf-8")); - } catch { - console.warn(" Warning: Could not parse existing .claude/settings.json, creating fresh"); - } - } else { - mkdirSync(claudeDir, { recursive: true }); - } + const settings = readHooksFile(settingsPath, ".claude/settings.json"); + if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true }); - if (settings.hooks && !force) { - const hasGitmem = JSON.stringify(settings.hooks).includes("gitmem"); - if (hasGitmem) { - console.log("GitMem hooks already installed in .claude/settings.json"); - console.log(""); - console.log("To reinstall (overwrite), run:"); - console.log(" npx gitmem-mcp install-hooks --force"); - return; - } + if (!force && hasGitmemHooks(settings.hooks)) { + console.log("GitMem hooks already installed in .claude/settings.json"); + console.log(""); + console.log("To reinstall (replaces only gitmem's hooks), run:"); + console.log(" npx gitmem-mcp install-hooks --force"); + return; } - settings.hooks = gitmemHooks; - writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); + // GIT-120: merge per event; other hooks stay as they are. + const merged = mergeGitmemHooks(settings.hooks, gitmemHooks); + settings.hooks = merged.hooks; + writeHooksFile(settingsPath, ".claude/settings.json", settings, merged.changes); console.log("Hooks written to .claude/settings.json"); console.log(`Scripts at: ${relScripts}/`); @@ -732,6 +748,25 @@ function promptTelemetryOptIn() { * Also cleans up legacy plugin directories and temp state. * Use --client to target a specific IDE. */ +/** + * GIT-120: take out only the commands gitmem installed. Other hooks, and the + * hooks key itself while anything is left in it, stay. + */ +function uninstallFrom(path, name, cfg, extraChanges = []) { + if (!cfg.hooks && extraChanges.length === 0) { + console.log(`[uninstall] No hooks found in ${name}`); + return; + } + const result = removeGitmemHooks(cfg.hooks); + if (result.removed === 0 && extraChanges.length === 0) { + console.log(`[uninstall] No gitmem hooks found in ${name}${result.kept ? ` (${result.kept} other hook(s) left untouched)` : ""}`); + return; + } + if (result.hooks) cfg.hooks = result.hooks; else delete cfg.hooks; + writeHooksFile(path, name, cfg, [...result.changes, ...extraChanges]); + console.log(`[uninstall] Removed ${result.removed} gitmem hook(s) from ${name}; kept ${result.kept} other`); +} + function cmdUninstallHooks() { const clientIdx = process.argv.indexOf("--client"); const clientArg = clientIdx !== -1 ? process.argv[clientIdx + 1]?.toLowerCase() : null; @@ -759,40 +794,8 @@ function cmdUninstallHooks() { // Remove hooks from .cursor/hooks.json const hooksPath = join(process.cwd(), ".cursor", "hooks.json"); if (existsSync(hooksPath)) { - try { - const cfg = JSON.parse(readFileSync(hooksPath, "utf-8")); - if (cfg.hooks) { - // Filter out gitmem hooks, preserve others - const cleaned = {}; - let removed = 0; - for (const [eventType, entries] of Object.entries(cfg.hooks)) { - if (!Array.isArray(entries)) continue; - const nonGitmem = entries.filter((e) => { - if (typeof e.command === "string" && e.command.includes("gitmem")) { - removed++; - return false; - } - return true; - }); - if (nonGitmem.length > 0) cleaned[eventType] = nonGitmem; - } - if (removed > 0) { - if (Object.keys(cleaned).length > 0) { - cfg.hooks = cleaned; - } else { - delete cfg.hooks; - } - writeFileSync(hooksPath, JSON.stringify(cfg, null, 2)); - console.log(`[uninstall] Removed ${removed} gitmem hooks from .cursor/hooks.json`); - } else { - console.log("[uninstall] No gitmem hooks found in .cursor/hooks.json"); - } - } else { - console.log("[uninstall] No hooks found in .cursor/hooks.json"); - } - } catch { - // ignore parse errors - } + const cfg = readHooksFile(hooksPath, ".cursor/hooks.json"); + uninstallFrom(hooksPath, ".cursor/hooks.json", cfg); } else { console.log("[uninstall] No .cursor/hooks.json found"); } @@ -800,28 +803,12 @@ function cmdUninstallHooks() { // Remove hooks from .claude/settings.json const settingsPath = join(process.cwd(), ".claude", "settings.json"); if (existsSync(settingsPath)) { - try { - const cfg = JSON.parse(readFileSync(settingsPath, "utf-8")); - if (cfg.hooks) { - delete cfg.hooks; - writeFileSync(settingsPath, JSON.stringify(cfg, null, 2)); - console.log("[uninstall] Removed hooks from .claude/settings.json"); - } else { - console.log("[uninstall] No hooks found in .claude/settings.json"); - } - // Also clean legacy enabledPlugins - if (cfg.enabledPlugins) { - for (const key of Object.keys(cfg.enabledPlugins)) { - if (key.startsWith("gitmem-hooks")) { - delete cfg.enabledPlugins[key]; - writeFileSync(settingsPath, JSON.stringify(cfg, null, 2)); - console.log("[cleanup] Removed legacy enabledPlugins entry"); - } - } - } - } catch { - // ignore parse errors - } + const cfg = readHooksFile(settingsPath, ".claude/settings.json"); + // Legacy enabledPlugins entries from the old plugin install. + const legacyPlugins = Object.keys(cfg.enabledPlugins || {}).filter((k) => k.startsWith("gitmem-hooks")); + for (const key of legacyPlugins) delete cfg.enabledPlugins[key]; + uninstallFrom(settingsPath, ".claude/settings.json", cfg, + legacyPlugins.map((k) => `enabledPlugins: removed legacy entry ${k}`)); } } 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/tests/e2e/cli-fresh-install.test.ts b/tests/e2e/cli-fresh-install.test.ts index d7d6e3e..eee3990 100644 --- a/tests/e2e/cli-fresh-install.test.ts +++ b/tests/e2e/cli-fresh-install.test.ts @@ -295,9 +295,10 @@ describe("Fresh Install: Hooks CLI", () => { // Permissions preserved expect(settings.permissions.allow).toContain("mcp__gitmem__*"); expect(settings.permissions.allow).toContain("custom_tool"); - // Hooks replaced with new ones + // gitmem's hooks added; the foreign "echo old" hook kept (GIT-120) const cmd = JSON.stringify(settings.hooks.SessionStart); expect(cmd).toContain("session-start.sh"); + expect(cmd).toContain("echo old"); }); it("uninstall-hooks removes hooks from settings", async () => { @@ -308,10 +309,11 @@ describe("Fresh Install: Hooks CLI", () => { expect(exitCode).toBe(0); expect(stdout).toContain("Uninstall complete"); - // settings.json still exists but hooks key removed + // settings.json still exists; only gitmem's hooks removed, the foreign + // "echo old" hook from the previous test kept (GIT-120) expect(existsSync(SETTINGS_PATH)).toBe(true); const settings = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8")); - expect(settings.hooks).toBeUndefined(); + expect(settings.hooks).toEqual({ SessionStart: [{ hooks: [{ type: "command", command: "echo old" }] }] }); // Permissions preserved expect(settings.permissions).toBeDefined(); @@ -323,7 +325,7 @@ describe("Fresh Install: Hooks CLI", () => { }); expect(exitCode).toBe(0); - expect(stdout).toContain("No hooks found"); + expect(stdout).toContain("No gitmem hooks found"); }); }); diff --git a/tests/e2e/git-120-hooks-merge.test.ts b/tests/e2e/git-120-hooks-merge.test.ts new file mode 100644 index 0000000..4cc995d --- /dev/null +++ b/tests/e2e/git-120-hooks-merge.test.ts @@ -0,0 +1,187 @@ +/** + * GIT-120: install-hooks and uninstall-hooks keep other people's hooks. + * + * install-hooks replaced settings.hooks wholesale and uninstall-hooks deleted + * the key, so a user's own hooks (or another tool's) vanished. These run the + * real CLI in a scratch repo that already has foreign hooks, and check that + * only gitmem's commands come and go, that the file is backed up first, and + * that the output says what changed. + * + * No server, no Supabase, no Docker: safe for CI. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { execFile as execFileCb } from "child_process"; +import { promisify } from "util"; +import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; + +const execFile = promisify(execFileCb); +const GITMEM_BIN = join(__dirname, "../../bin/gitmem.js"); + +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" }, + timeout: 30_000, + }); + return { stdout, stderr, code: 0 }; + } catch (e: any) { + return { stdout: e.stdout ?? "", stderr: e.stderr ?? "", code: typeof e.code === "number" ? e.code : 1 }; + } +} + +/** A user's own hooks, including one whose path happens to contain "gitmem". */ +const FOREIGN_CLAUDE = { + SessionStart: [{ hooks: [{ type: "command", command: "echo foreign-session-start" }] }], + PreToolUse: [ + { matcher: "Bash", hooks: [{ type: "command", command: "bash ~/code/gitmem-notes/lint.sh" }] }, + ], + Notification: [{ hooks: [{ type: "command", command: "say done" }] }], +}; + +const allCommands = (hooks: Record = {}) => + Object.values(hooks).flat().flatMap((e: any) => (Array.isArray(e.hooks) ? e.hooks : [e])).map((h: any) => h.command as string); +const gitmemCommands = (hooks?: Record) => allCommands(hooks).filter((c) => c.includes(".gitmem/hooks/")); +const foreignCommands = (hooks?: Record) => allCommands(hooks).filter((c) => !c.includes(".gitmem/hooks/")); +const backups = (dir: string, base: string) => readdirSync(dir).filter((f) => f.startsWith(`${base}.gitmem-backup-`)); + +let repo: string; +let home: string; +let settingsPath: string; + +beforeEach(() => { + repo = mkdtempSync(join(tmpdir(), "gitmem-git120-repo-")); + home = mkdtempSync(join(tmpdir(), "gitmem-git120-home-")); + mkdirSync(join(repo, ".claude")); + settingsPath = join(repo, ".claude", "settings.json"); +}); + +afterEach(() => { + rmSync(repo, { recursive: true, force: true }); + rmSync(home, { recursive: true, force: true }); +}); + +describe("GIT-120: Claude Code settings.json", () => { + const original = () => ({ permissions: { allow: ["Bash(ls:*)"] }, hooks: structuredClone(FOREIGN_CLAUDE) }); + + it("install-hooks keeps foreign hooks, backs up first, and prints what changed", async () => { + const before = JSON.stringify(original(), null, 2); + writeFileSync(settingsPath, before); + + const r = await gitmem(["install-hooks"], repo, home); + expect(r.code).toBe(0); + const after = JSON.parse(readFileSync(settingsPath, "utf-8")); + + expect(foreignCommands(after.hooks).sort()).toEqual(allCommands(FOREIGN_CLAUDE).sort()); + expect(after.hooks.Notification).toEqual(FOREIGN_CLAUDE.Notification); + expect(gitmemCommands(after.hooks).length).toBeGreaterThan(0); + expect(after.permissions).toEqual({ allow: ["Bash(ls:*)"] }); + + const b = backups(join(repo, ".claude"), "settings.json"); + expect(b).toHaveLength(1); + expect(readFileSync(join(repo, ".claude", b[0]), "utf-8")).toBe(before); + + expect(r.stdout).toContain("Changes to .claude/settings.json:"); + expect(r.stdout).toMatch(/SessionStart: added \d+ gitmem hook\(s\), kept 1 other/); + expect(r.stdout).toMatch(/Notification: kept 1 \(untouched\)/); + expect(r.stdout).toContain("Backup of the previous file:"); + }); + + it("install-hooks --force twice replaces gitmem's hooks without duplicating them", async () => { + writeFileSync(settingsPath, JSON.stringify(original(), null, 2)); + await gitmem(["install-hooks"], repo, home); + const once = gitmemCommands(JSON.parse(readFileSync(settingsPath, "utf-8")).hooks).length; + + const r = await gitmem(["install-hooks", "--force"], repo, home); + expect(r.code).toBe(0); + const after = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(gitmemCommands(after.hooks)).toHaveLength(once); + expect(foreignCommands(after.hooks).sort()).toEqual(allCommands(FOREIGN_CLAUDE).sort()); + expect(r.stdout).toMatch(/SessionStart: replaced \d+ gitmem hook\(s\) with \d+, kept 1 other/); + }); + + it("install-hooks without --force leaves an installed file alone", async () => { + writeFileSync(settingsPath, JSON.stringify(original(), null, 2)); + await gitmem(["install-hooks"], repo, home); + const snapshot = readFileSync(settingsPath, "utf-8"); + const r = await gitmem(["install-hooks"], repo, home); + expect(r.stdout).toContain("already installed"); + expect(readFileSync(settingsPath, "utf-8")).toBe(snapshot); + }); + + it("uninstall-hooks removes only gitmem's commands and leaves the foreign hooks as they were", async () => { + writeFileSync(settingsPath, JSON.stringify(original(), null, 2)); + await gitmem(["install-hooks"], repo, home); + + const r = await gitmem(["uninstall-hooks"], repo, home); + expect(r.code).toBe(0); + const after = JSON.parse(readFileSync(settingsPath, "utf-8")); + expect(after.hooks).toEqual(FOREIGN_CLAUDE); + expect(after.permissions).toEqual({ allow: ["Bash(ls:*)"] }); + expect(r.stdout).toMatch(/Removed \d+ gitmem hook\(s\) from \.claude\/settings\.json; kept 3 other/); + expect(backups(join(repo, ".claude"), "settings.json")).toHaveLength(2); + }); + + it("uninstall-hooks keeps a foreign command that shares a matcher group with gitmem's", async () => { + const mixed = { + hooks: { + PreToolUse: [{ matcher: "Bash", hooks: [ + { type: "command", command: "bash .gitmem/hooks/credential-guard.sh", timeout: 3000 }, + { type: "command", command: "echo mine" }, + ] }], + }, + }; + writeFileSync(settingsPath, JSON.stringify(mixed, null, 2)); + const r = await gitmem(["uninstall-hooks"], repo, home); + expect(r.code).toBe(0); + expect(JSON.parse(readFileSync(settingsPath, "utf-8")).hooks).toEqual({ + PreToolUse: [{ matcher: "Bash", hooks: [{ type: "command", command: "echo mine" }] }], + }); + }); + + it("install-hooks leaves events it does not manage alone, including init's auto-retrieve hook", async () => { + const autoRetrieve = { UserPromptSubmit: [{ hooks: [{ type: "command", command: "bash .gitmem/hooks/auto-retrieve-hook.sh", timeout: 3000 }] }] }; + writeFileSync(settingsPath, JSON.stringify({ hooks: autoRetrieve }, null, 2)); + const r = await gitmem(["install-hooks", "--force"], repo, home); + expect(r.code).toBe(0); + expect(JSON.parse(readFileSync(settingsPath, "utf-8")).hooks.UserPromptSubmit).toEqual(autoRetrieve.UserPromptSubmit); + }); + + it("uninstall-hooks drops the hooks key only when nothing else is left", async () => { + await gitmem(["install-hooks"], repo, home); + await gitmem(["uninstall-hooks"], repo, home); + expect(JSON.parse(readFileSync(settingsPath, "utf-8")).hooks).toBeUndefined(); + }); + + it("an unparseable settings.json is not overwritten", async () => { + writeFileSync(settingsPath, "{ not json"); + for (const cmd of ["install-hooks", "uninstall-hooks"]) { + const r = await gitmem([cmd], repo, home); + expect(r.code).toBe(1); + expect(r.stderr).toContain("Nothing was changed"); + expect(readFileSync(settingsPath, "utf-8")).toBe("{ not json"); + } + }); +}); + +describe("GIT-120: Cursor hooks.json", () => { + it("install and uninstall keep a foreign Cursor hook", async () => { + mkdirSync(join(repo, ".cursor")); + const hooksPath = join(repo, ".cursor", "hooks.json"); + const foreign = { version: 1, hooks: { stop: [{ command: "echo cursor-stop" }], afterFileEdit: [{ command: "prettier --write" }] } }; + writeFileSync(hooksPath, JSON.stringify(foreign, null, 2)); + + const i = await gitmem(["install-hooks", "--client", "cursor"], repo, home); + expect(i.code).toBe(0); + const installed = JSON.parse(readFileSync(hooksPath, "utf-8")); + expect(foreignCommands(installed.hooks).sort()).toEqual(["echo cursor-stop", "prettier --write"]); + expect(gitmemCommands(installed.hooks).length).toBeGreaterThan(0); + expect(installed.version).toBe(1); + + const u = await gitmem(["uninstall-hooks", "--client", "cursor"], repo, home); + expect(u.code).toBe(0); + expect(JSON.parse(readFileSync(hooksPath, "utf-8"))).toEqual(foreign); + }); +});