Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"

Expand Down
183 changes: 85 additions & 98 deletions bin/gitmem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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}/`);
Expand Down Expand Up @@ -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}/`);
Expand Down Expand Up @@ -732,6 +748,25 @@ function promptTelemetryOptIn() {
* Also cleans up legacy plugin directories and temp state.
* Use --client <claude|cursor> 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;
Expand Down Expand Up @@ -759,69 +794,21 @@ 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");
}
} else {
// 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}`));
}
}

Expand Down
130 changes: 130 additions & 0 deletions bin/hooks-merge.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading