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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)}"

Expand Down
34 changes: 34 additions & 0 deletions bin/gitmem-root.js
Original file line number Diff line number Diff line change
@@ -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 <cwd>/.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;
}
23 changes: 12 additions & 11 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 { storeRoot, displayPath } from "./gitmem-root.js";

const __dirname = dirname(fileURLToPath(import.meta.url));
const command = process.argv[2];
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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");
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