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
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,69 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [1.8.0] - 2026-08-09

**No one loses any information.** Nothing is deleted, nothing is moved, nothing is overwritten.

gitmem now reads one memory store — `~/.gitmem` — no matter which directory a process starts in.
That has been the default since v1.0.10 in February, so for almost everyone this changes nothing
visible. If you are one of the rare installs that still keeps memory in a project-local `.gitmem/`,
your first `session_start` after upgrading will show you exactly where it is, how many learnings,
threads and sessions are in it, and the one command that copies them across. `migrate-root` copies
and leaves the original untouched. Pro users' Supabase memory is unaffected either way.

The rest of this release is about a quieter problem: gitmem was reporting success for failures. A
session that survived an MCP restart was told it had none. A scar search that never reached the
store was answered with "proceed freely". Both are fixed, and both now say what actually happened.

### Fixed

- **A session no longer loses its identity when the MCP server restarts.** Identity was bound to
`process.pid` and looked up through the active-sessions registry, so any restart — an app update,
a rebuild, a relaunch — orphaned the entry and every session-required tool reported "No active
session" for the rest of the session, while writes continued to land correctly. Identity now
resolves from the durable per-session store on disk; PID is only a disambiguator, and the registry
is repaired from disk rather than gating access to it. A live session belonging to another server
is still never claimed, so concurrent sessions remain isolated. `session_close` also no longer
requires you to pass `session_id` — it resolves the session itself, which is the case a restart
exists to break. (GIT-89)
- **Scar retrieval failed on every call whenever the local index was cold.** The Supabase fallback
built its RPC name from the table prefix and a verb, producing a function that exists under no
prefix, on any deployment — so a `recall` issued before the in-memory index finished loading
returned nothing at all. That window includes the first `recall` of a session. The RPCs are now
called by their deployed names. (GIT-93)
- **`confirm_scars` reported a failed retrieval as a clean check.** With nothing surfaced it replied
"No recall-surfaced scars to confirm. Proceed freely" — the same answer whether the search had run
and matched nothing or had never reached the store. It now distinguishes the two and names the
underlying error, and the distinction survives a restart. This is why the retrieval defect above
could persist unnoticed. (GIT-93)
- **The pre-publish clean-room images could not build.** Every clean-room Dockerfile installed
`npm@latest` onto a Node 20 base, which stopped working once npm began requiring Node 22.22+. The
gate that tests the packaged tarball the way a user installs it had been failing silently, because
a gate only run by hand has no failure signal between uses. (GIT-91)

### Added

- **`npx gitmem-mcp migrate-root`** — copies a project-local memory store into `~/.gitmem`. It copies
rather than moves, never overwrites a file that already exists at the destination, and reports
every file it skipped and why. `--dry-run` shows the exact plan first. (GIT-91)
- **A first-run signpost for project-local stores.** If one is found, `session_start` names the path,
the record counts it holds, and the one command that copies it in. Detection only — the store is
never read from behind your back, and never silently unread either. (GIT-91)
- **`GITMEM_HOME`** — relocates the developer-scoped root without short-circuiting resolution the way
`GITMEM_DIR` does. Useful for containers and CI. (GIT-91)

### Changed

- **The `.gitmem` root no longer depends on the working directory.** Resolution used to walk up from
`process.cwd()`, which meant the MCP server and the SessionStart hook — which do not share a
directory — could bind one session to two different stores, with writes landing where identity
resolution never looked. Project-local stores are still fully supported and are now selected
explicitly with `GITMEM_DIR`. (GIT-91)
- **CI gates publishing on a real restart.** The release pipeline now runs an end-to-end test that
kills the MCP server process and drives a recovered session over the MCP protocol, so the identity
fix above cannot regress into a release. (GIT-89)

## [1.7.0] - 2026-08-07

**No destructive changes, no data loss, no migration.** But if you start seeing errors after
Expand Down
5 changes: 5 additions & 0 deletions bin/gitmem.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ Other commands:
npx gitmem-mcp setup Output SQL for Supabase schema setup (pro/dev tier)
npx gitmem-mcp configure Generate .mcp.json config for Claude Code / Cursor
npx gitmem-mcp check Run diagnostic health check
npx gitmem-mcp migrate-root Copy a project-scoped .gitmem into ~/.gitmem
npx gitmem-mcp migrate-root --dry-run Show what would be copied
npx gitmem-mcp check --full Full diagnostic with benchmarks
npx gitmem-mcp install-hooks Install hooks (standalone)
npx gitmem-mcp uninstall-hooks Remove hooks (standalone)
Expand Down Expand Up @@ -902,6 +904,9 @@ switch (command) {
case "telemetry":
import("../dist/commands/telemetry.js").then((m) => m.main(process.argv.slice(3)));
break;
case "migrate-root":
import("../dist/commands/migrate-root.js").then((m) => m.main(process.argv.slice(3)));
break;
case "install-hooks":
cmdInstallHooks();
break;
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "gitmem-mcp",
"version": "1.7.0",
"version": "1.8.0",
"mcpName": "io.github.gitmem-dev/gitmem",
"description": "Persistent learning memory for AI coding agents. Memory that compounds.",
"type": "module",
Expand Down
169 changes: 169 additions & 0 deletions src/commands/migrate-root.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
/**
* GIT-91: copy a project-scoped .gitmem store into the developer-scoped root.
*
* Before v1.0.10 gitmem stored data in <project>/.gitmem. That release moved the
* default to ~/.gitmem and kept a cwd walk-up so existing stores were still
* found. GIT-91 removed the walk-up: deriving the root from process.cwd() meant
* the MCP server and the SessionStart hook — which do not share a cwd — resolved
* different stores for one session.
*
* The consequence for anyone still on a pre-1.0.10 layout is that their store is
* no longer read. On the free tier that store IS the memory (learnings.json,
* threads.json), so "my scars vanished after an upgrade" is the experience this
* command exists to prevent.
*
* Design constraints, in order of importance:
*
* COPY, NEVER MOVE. The source is left byte-for-byte intact. If this command
* is wrong about anything, the user still has their data where it was. Moving
* would make a bad merge unrecoverable.
*
* NEVER OVERWRITE. A file that already exists at the destination wins. The
* destination is the live store; the source is, by definition, the one that
* has not been read recently. Clobbering current memory with stale memory is
* worse than skipping.
*
* REPORT EVERY SKIP. A silent partial migration would leave the user believing
* they had merged when they had not — the failure class GIT-93 was about.
*/

import * as fs from "fs";
import * as path from "path";
import { findStrandedProjectRoots, getHomeGitmemDir } from "../services/gitmem-dir.js";

interface MigrationPlan {
source: string;
destination: string;
copied: string[];
skipped: Array<{ file: string; reason: string }>;
}

/**
* Project-scoped roots holding live state. Delegates to the shared detector so
* this command and the session_start notice can never disagree about what
* counts as a store worth migrating.
*/
export function findProjectRoots(): string[] {
return findStrandedProjectRoots();
}

/**
* Recursively copy `from` into `to`, never overwriting an existing file.
*
* Returns what was copied and what was left alone, so the caller can report both
* rather than claiming a clean merge.
*/
function copyTree(
from: string,
to: string,
plan: MigrationPlan,
relative = ""
): void {
fs.mkdirSync(to, { recursive: true });
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
const rel = relative ? path.join(relative, entry.name) : entry.name;
const src = path.join(from, entry.name);
const dst = path.join(to, entry.name);

// Caches and license state are per-install, not memory. Copying them would
// move a license binding between roots, which is not this command's job.
if (relative === "" && (entry.name === "cache" || entry.name === "license-cache.json")) {
plan.skipped.push({ file: rel, reason: "per-install state, not memory" });
continue;
}

if (entry.isDirectory()) {
copyTree(src, dst, plan, rel);
continue;
}
if (fs.existsSync(dst)) {
plan.skipped.push({ file: rel, reason: "already exists in destination" });
continue;
}
fs.copyFileSync(src, dst);
plan.copied.push(rel);
}
}

export function migrateRoot(source: string, destination: string, dryRun: boolean): MigrationPlan {
const plan: MigrationPlan = { source, destination, copied: [], skipped: [] };

if (dryRun) {
// Walk the same tree without writing, so --dry-run reports the real plan
// rather than a guess at one.
const probe = (from: string, to: string, rel = ""): void => {
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
const r = rel ? path.join(rel, entry.name) : entry.name;
if (rel === "" && (entry.name === "cache" || entry.name === "license-cache.json")) {
plan.skipped.push({ file: r, reason: "per-install state, not memory" });
continue;
}
if (entry.isDirectory()) { probe(path.join(from, entry.name), path.join(to, entry.name), r); continue; }
if (fs.existsSync(path.join(to, entry.name))) {
plan.skipped.push({ file: r, reason: "already exists in destination" });
continue;
}
plan.copied.push(r);
}
};
probe(source, destination);
return plan;
}

copyTree(source, destination, plan);
return plan;
}

export function main(args: string[]): void {
const dryRun = args.includes("--dry-run");
// Must come from the resolver the server uses, not os.homedir() directly.
// GITMEM_HOME relocates the developer-scoped root, and computing the
// destination independently sent this command to a different store than the
// one gitmem reads — under a GITMEM_HOME override it copied into the real
// ~/.gitmem instead. A migration tool that writes somewhere the product does
// not read is worse than no tool.
const home = getHomeGitmemDir();

const explicitIdx = args.indexOf("--from");
const explicit = explicitIdx !== -1 ? args[explicitIdx + 1] : null;

const sources = explicit ? [path.resolve(explicit)] : findProjectRoots();

if (sources.length === 0) {
console.log("No project-scoped .gitmem store found above the current directory.");
console.log(`Nothing to migrate — ${home} is already the store gitmem reads.`);
return;
}

if (sources.length > 1) {
console.log(`Found ${sources.length} project-scoped stores:\n`);
sources.forEach((s) => console.log(` ${s}`));
console.log(`\nMigrate them one at a time so each result is reviewable:`);
console.log(` npx gitmem-mcp migrate-root --from ${sources[0]}`);
return;
}

const source = sources[0];
if (source === home) {
console.log(`Source and destination are the same (${home}). Nothing to do.`);
return;
}

console.log(`${dryRun ? "Would copy" : "Copying"} gitmem store`);
console.log(` from: ${source}`);
console.log(` to: ${home}\n`);

const plan = migrateRoot(source, home, dryRun);

console.log(`${plan.copied.length} file(s) ${dryRun ? "would be " : ""}copied.`);
if (plan.skipped.length > 0) {
console.log(`${plan.skipped.length} skipped:`);
for (const s of plan.skipped) console.log(` ${s.file} — ${s.reason}`);
}

console.log(
`\nThe source was NOT modified. ${source} is still intact — verify the result ` +
`before deleting anything.`
);
if (dryRun) console.log("\nRe-run without --dry-run to apply.");
}
Loading
Loading