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); + }); +});