@fonlan/dsh-gitmemo 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js CHANGED
@@ -5,6 +5,11 @@
5
5
  * dsh-gitmemo migrate --project-root <path> [--dry-run|--apply]
6
6
  * [--source <legacy-mem-dir>] [--out <baseline-file>] [--baseline <file>]
7
7
  *
8
+ * Migration is AUTOMATIC inside the engine (the first operation on a legacy
9
+ * `.mem` repo migrates it in place). This CLI remains for explicit control:
10
+ * non-default --source directories, baseline review, and repos where
11
+ * automatic migration is blocked (conflicts, dirty worktree, ...).
12
+ *
8
13
  * Dry-run scans the legacy `.mem` repo without touching any ref/index/work
9
14
  * tree state and prints the plan (dedupe, conflicts, estimated commits).
10
15
  * `--apply` re-verifies the baseline refs, then migrates inside the
package/lib/index.js CHANGED
@@ -382,7 +382,7 @@ function registerPromptSection(ctx) {
382
382
  name: "memory:gitmemo",
383
383
  order: 150,
384
384
  text: [
385
- "This deployment provides gitmemo long-term memory: a local .mem git repository at the project root stores past task outcomes as immutable markdown entries; git is the only dependency. Use the dedicated tools only (mem_search / mem_read / mem_write / mem_delete / mem_replace — all auto-initialize); never shell out to git or read .mem files directly.",
385
+ "This deployment provides gitmemo long-term memory: a local .mem git repository at the project root stores past task outcomes as immutable markdown entries; git is the only dependency. Use the dedicated tools only (mem_search / mem_read / mem_write / mem_delete / mem_replace — all auto-initialize, and legacy-format .mem repos migrate automatically on first use); never shell out to git or read .mem files directly.",
386
386
  "- BEFORE WORK — search: 仓库相关任务开始前提取 1–12 个中英文关键词调用 mem_search(含中英文同义词);纯闲聊和通用问答无需搜索。",
387
387
  "- RESULT PRESELECT — 根据 title、summary、keywords、score 和 matched keywords 选择最多 5 条执行 mem_read,复用相关结论;无相关结果时用返回的 snapshot + skip 翻页。",
388
388
  "- END-OF-SESSION CHECKPOINT — the ONLY write path: 会话结束时回顾全程,仅当结论有价值、可复用或用户明确要求记住时 mem_write;纯问答、未完成任务、非仓库工作、纯操作类 git 动作(仅 commit/push)不写;避免语义重复。",
package/lib/mem.d.ts CHANGED
@@ -262,16 +262,29 @@ export declare function rollbackJournal(memDir: string, journal: TransactionJour
262
262
  export declare function ensureInit(root: string, memDir: string, gitTimeoutMs: number): Promise<void>;
263
263
  /**
264
264
  * Git-backed long-term memory engine operating on one project root.
265
- * Every operation auto-initializes a new-format repo; legacy repos are
266
- * read-only for search/read until migrated via the CLI.
265
+ * Every operation auto-initializes a new-format repo; legacy `.mem` repos
266
+ * are migrated automatically on first use (no external CLI needed). Only
267
+ * when automatic migration is blocked (conflicts, dirty worktree, ...) do
268
+ * legacy repos stay read-only for search/read with a clear warning.
267
269
  */
268
270
  export declare class GitMemo {
269
271
  readonly root: string;
270
272
  readonly memDir: string;
271
273
  private readonly config;
272
274
  constructor(root: string, config?: GitMemoConfig);
273
- /** Initialize the memory repo when missing (idempotent). */
275
+ /** Initialize the memory repo when missing (idempotent). Legacy repos are migrated automatically. */
274
276
  init(): Promise<string>;
277
+ /**
278
+ * Fully automatic legacy migration (no external CLI needed): when `.mem`
279
+ * exists in legacy format, rebuild it into the new format in place —
280
+ * dry-run + apply in one step. The caller must already hold the operation
281
+ * lock. Blocking conditions (conflicts, dirty worktree, interrupted legacy
282
+ * journal, ...) are returned, never thrown, so operations can fall back to
283
+ * legacy read-only behavior; the next operation retries automatically.
284
+ */
285
+ private autoMigrateIfNeeded;
286
+ /** Ensure the repo is initialized and writable (new format), migrating legacy repos automatically. */
287
+ private ensureWritable;
275
288
  /**
276
289
  * Search past memories (plan 6): fixed-string OR grep recall over commit
277
290
  * messages only, active-entry filtering, field-level matching and scoring,
package/lib/mem.js CHANGED
@@ -717,9 +717,13 @@ export async function recoverMigrationSwap(root, gitTimeoutMs) {
717
717
  function sleep(ms) {
718
718
  return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
719
719
  }
720
- function legacyWarning(root) {
721
- return ("gitmemo: .mem is in legacy format — search is read-only over the current branch. " +
722
- `Run \`dsh-gitmemo migrate --project-root ${root} --dry-run\` to plan migration (then --apply).`);
720
+ function legacyWarning() {
721
+ return ("gitmemo: .mem is in legacy format and automatic migration is blocked — search is read-only over the current branch");
722
+ }
723
+ /** Refusal message for write/delete/replace when automatic migration cannot run. */
724
+ function legacyWriteRefusal(outcome) {
725
+ const detail = outcome.blocked?.detail ?? "unknown blocking condition";
726
+ return ("gitmemo: .mem exists in legacy format — write operations are disabled and automatic migration is blocked: " + detail);
723
727
  }
724
728
  /** Detect the repo format from `.gitmemo-format` at the given snapshot. */
725
729
  export async function repoFormat(memDir, gitTimeoutMs, snapshot = "HEAD") {
@@ -804,9 +808,9 @@ async function resolveSnapshot(memDir, gitTimeoutMs, root, input) {
804
808
  if (!(await gitOk(memDir, ["merge-base", "--is-ancestor", full, head], gitTimeoutMs))) {
805
809
  throw new GitMemoError(`gitmemo: stale legacy snapshot ${input} is no longer part of the current HEAD history — restart pagination`);
806
810
  }
807
- return { snapshot: full, legacy: true, warning: legacyWarning(root) };
811
+ return { snapshot: full, legacy: true, warning: legacyWarning() };
808
812
  }
809
- return { snapshot: head, legacy: true, warning: legacyWarning(root) };
813
+ return { snapshot: head, legacy: true, warning: legacyWarning() };
810
814
  }
811
815
  const format = await repoFormat(memDir, gitTimeoutMs, main);
812
816
  if (format !== "new") {
@@ -815,9 +819,9 @@ async function resolveSnapshot(memDir, gitTimeoutMs, root, input) {
815
819
  if (!(await gitOk(memDir, ["merge-base", "--is-ancestor", full, main], gitTimeoutMs))) {
816
820
  throw new GitMemoError(`gitmemo: stale legacy snapshot ${input} is no longer part of the current branch history — restart pagination`);
817
821
  }
818
- return { snapshot: full, legacy: true, warning: legacyWarning(root) };
822
+ return { snapshot: full, legacy: true, warning: legacyWarning() };
819
823
  }
820
- return { snapshot: main, legacy: true, warning: legacyWarning(root) };
824
+ return { snapshot: main, legacy: true, warning: legacyWarning() };
821
825
  }
822
826
  if (input !== undefined) {
823
827
  const full = await resolveCommit(memDir, input, gitTimeoutMs);
@@ -1059,14 +1063,6 @@ async function assertOnMain(memDir, gitTimeoutMs) {
1059
1063
  throw new GitMemoError(`gitmemo: .mem HEAD is not on refs/heads/main (${sym || "unborn HEAD"}) — gitmemo requires its single main branch`);
1060
1064
  }
1061
1065
  }
1062
- /** Write/delete/replace must run on the new format; legacy repos need migration. */
1063
- async function assertNewFormat(memDir, gitTimeoutMs, root) {
1064
- const format = await repoFormat(memDir, gitTimeoutMs);
1065
- if (format !== "new") {
1066
- throw new GitMemoError("gitmemo: .mem exists in legacy format — write operations are disabled. " +
1067
- `Run \`dsh-gitmemo migrate --project-root ${root} --dry-run\` to plan migration, then --apply.`);
1068
- }
1069
- }
1070
1066
  /** Current code branch + HEAD SHA for entry metadata (plan 7.2/7.3). */
1071
1067
  async function codeContext(root, gitTimeoutMs) {
1072
1068
  try {
@@ -1190,8 +1186,10 @@ function cacheSearch(key, value) {
1190
1186
  // ---------------------------------------------------------------------------
1191
1187
  /**
1192
1188
  * Git-backed long-term memory engine operating on one project root.
1193
- * Every operation auto-initializes a new-format repo; legacy repos are
1194
- * read-only for search/read until migrated via the CLI.
1189
+ * Every operation auto-initializes a new-format repo; legacy `.mem` repos
1190
+ * are migrated automatically on first use (no external CLI needed). Only
1191
+ * when automatic migration is blocked (conflicts, dirty worktree, ...) do
1192
+ * legacy repos stay read-only for search/read with a clear warning.
1195
1193
  */
1196
1194
  export class GitMemo {
1197
1195
  root;
@@ -1214,13 +1212,45 @@ export class GitMemo {
1214
1212
  };
1215
1213
  this.memDir = join(this.root, ".mem");
1216
1214
  }
1217
- /** Initialize the memory repo when missing (idempotent). */
1215
+ /** Initialize the memory repo when missing (idempotent). Legacy repos are migrated automatically. */
1218
1216
  async init() {
1219
1217
  await withLock(this.root, this.config, "init", async () => {
1220
1218
  await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1219
+ await this.autoMigrateIfNeeded();
1221
1220
  });
1222
1221
  return this.memDir;
1223
1222
  }
1223
+ /**
1224
+ * Fully automatic legacy migration (no external CLI needed): when `.mem`
1225
+ * exists in legacy format, rebuild it into the new format in place —
1226
+ * dry-run + apply in one step. The caller must already hold the operation
1227
+ * lock. Blocking conditions (conflicts, dirty worktree, interrupted legacy
1228
+ * journal, ...) are returned, never thrown, so operations can fall back to
1229
+ * legacy read-only behavior; the next operation retries automatically.
1230
+ */
1231
+ async autoMigrateIfNeeded() {
1232
+ if (!(await pathExists(join(this.memDir, ".git")))) {
1233
+ return { migrated: false, blocked: { code: "no-repo", detail: "no .mem repository" } };
1234
+ }
1235
+ if ((await repoFormat(this.memDir, this.config.gitTimeoutMs)) === "new") {
1236
+ return { migrated: false, alreadyNewFormat: true };
1237
+ }
1238
+ const { migrateAutoUnlocked } = await import("./migrate.js");
1239
+ return migrateAutoUnlocked(this.root, {
1240
+ gitTimeoutMs: this.config.gitTimeoutMs,
1241
+ lockTimeoutMs: this.config.lockTimeoutMs
1242
+ });
1243
+ }
1244
+ /** Ensure the repo is initialized and writable (new format), migrating legacy repos automatically. */
1245
+ async ensureWritable() {
1246
+ await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1247
+ const migration = await this.autoMigrateIfNeeded();
1248
+ if (migration.alreadyNewFormat === true)
1249
+ return; // fresh/new-format repo — nothing to migrate
1250
+ if (!migration.migrated) {
1251
+ throw new GitMemoError(legacyWriteRefusal(migration));
1252
+ }
1253
+ }
1224
1254
  /**
1225
1255
  * Search past memories (plan 6): fixed-string OR grep recall over commit
1226
1256
  * messages only, active-entry filtering, field-level matching and scoring,
@@ -1262,12 +1292,18 @@ export class GitMemo {
1262
1292
  ? options.snapshot.trim()
1263
1293
  : undefined;
1264
1294
  // Read barrier: resolve the immutable snapshot under the lock (creating
1265
- // the repo when missing), then scan without holding it.
1266
- const { snapshot, legacy, warning } = await withLock(this.root, this.config, "search", async () => {
1295
+ // the repo when missing; legacy repos are migrated automatically when
1296
+ // safe), then scan without holding it.
1297
+ const { snapshot, legacy, warning, blockedNote } = await withLock(this.root, this.config, "search", async () => {
1267
1298
  if (!(await pathExists(join(this.memDir, ".git")))) {
1268
1299
  await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1269
1300
  }
1270
- return resolveSnapshot(this.memDir, this.config.gitTimeoutMs, this.root, requestedSnapshot);
1301
+ const migration = await this.autoMigrateIfNeeded();
1302
+ const resolved = await resolveSnapshot(this.memDir, this.config.gitTimeoutMs, this.root, requestedSnapshot);
1303
+ const note = !migration.migrated && migration.blocked !== undefined && resolved.legacy
1304
+ ? " Automatic migration was attempted but blocked: " + migration.blocked.detail
1305
+ : undefined;
1306
+ return { ...resolved, blockedNote: note };
1271
1307
  });
1272
1308
  const cacheKey = this.memDir + "\x00" + snapshot + "\x00" + normalized.join("\x00");
1273
1309
  let computation = searchCache.get(cacheKey);
@@ -1277,7 +1313,7 @@ export class GitMemo {
1277
1313
  }
1278
1314
  const total = computation.hits.length;
1279
1315
  const next_skip = skip + limit < total ? skip + limit : null;
1280
- const allWarnings = [adapterWarning, warning].filter((w) => w !== undefined).join(" ");
1316
+ const allWarnings = [adapterWarning, warning, blockedNote].filter((w) => w !== undefined).join(" ");
1281
1317
  return {
1282
1318
  snapshot,
1283
1319
  total,
@@ -1355,6 +1391,7 @@ export class GitMemo {
1355
1391
  if (!(await pathExists(join(this.memDir, ".git")))) {
1356
1392
  await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1357
1393
  }
1394
+ await this.autoMigrateIfNeeded();
1358
1395
  const full = await resolveCommit(this.memDir, commitHash, this.config.gitTimeoutMs);
1359
1396
  const added = await addedFilesOf(this.memDir, full, this.config.gitTimeoutMs);
1360
1397
  if (added.length !== 1) {
@@ -1378,8 +1415,7 @@ export class GitMemo {
1378
1415
  const validated = validateWriteInput(adapted, legacy);
1379
1416
  const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content);
1380
1417
  return withLock(this.root, this.config, "write", async () => {
1381
- await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1382
- await assertNewFormat(this.memDir, this.config.gitTimeoutMs, this.root);
1418
+ await this.ensureWritable();
1383
1419
  await recoverJournal(this.memDir, this.config.gitTimeoutMs);
1384
1420
  await assertClean(this.memDir, this.config.gitTimeoutMs);
1385
1421
  await assertOnMain(this.memDir, this.config.gitTimeoutMs);
@@ -1440,8 +1476,7 @@ export class GitMemo {
1440
1476
  assertNoControlChars(reason, "reason");
1441
1477
  assertNoTrailerInjection(reason, "reason");
1442
1478
  return withLock(this.root, this.config, "delete", async () => {
1443
- await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1444
- await assertNewFormat(this.memDir, this.config.gitTimeoutMs, this.root);
1479
+ await this.ensureWritable();
1445
1480
  await recoverJournal(this.memDir, this.config.gitTimeoutMs);
1446
1481
  await assertClean(this.memDir, this.config.gitTimeoutMs);
1447
1482
  await assertOnMain(this.memDir, this.config.gitTimeoutMs);
@@ -1487,8 +1522,7 @@ export class GitMemo {
1487
1522
  const validated = validateWriteInput(input);
1488
1523
  const digest = computeDigest(validated.title, validated.summary, validated.keywords, validated.content);
1489
1524
  return withLock(this.root, this.config, "replace", async () => {
1490
- await ensureInit(this.root, this.memDir, this.config.gitTimeoutMs);
1491
- await assertNewFormat(this.memDir, this.config.gitTimeoutMs, this.root);
1525
+ await this.ensureWritable();
1492
1526
  await recoverJournal(this.memDir, this.config.gitTimeoutMs);
1493
1527
  await assertClean(this.memDir, this.config.gitTimeoutMs);
1494
1528
  await assertOnMain(this.memDir, this.config.gitTimeoutMs);
package/lib/migrate.d.ts CHANGED
@@ -60,6 +60,19 @@ export interface MigrationOptions {
60
60
  gitTimeoutMs?: number;
61
61
  lockTimeoutMs?: number;
62
62
  }
63
+ /** Why automatic migration could not run (user-resolvable blocking conditions). */
64
+ export interface AutoMigrateBlock {
65
+ code: "no-repo" | "no-branches" | "conflicts" | "dirty" | "legacy-journal" | "other";
66
+ /** Human-readable detail; safe to surface to the user. */
67
+ detail: string;
68
+ }
69
+ /** Outcome of an automatic migration attempt (never throws for blocking conditions). */
70
+ export interface AutoMigrateOutcome {
71
+ migrated: boolean;
72
+ alreadyNewFormat?: boolean;
73
+ blocked?: AutoMigrateBlock;
74
+ report?: MigrationApplyReport;
75
+ }
63
76
  /**
64
77
  * Scan a legacy `.mem` repo and produce the migration plan. Read-only: no
65
78
  * ref, index or worktree state is modified (the lock file is transient).
@@ -71,3 +84,12 @@ export declare function migrateDryRun(root: string, options?: MigrationOptions):
71
84
  * new canonical main in a temp repo, then swaps directories atomically.
72
85
  */
73
86
  export declare function migrateApply(root: string, baseline: MigrationBaseline, options?: MigrationOptions): Promise<MigrationApplyReport>;
87
+ /**
88
+ * Fully automatic migration for engine use: dry-run + apply in one step,
89
+ * without taking the cross-process lock (the caller must already hold it).
90
+ *
91
+ * Never throws for user-resolvable blocking conditions — it returns
92
+ * `blocked` instead, so the caller can fall back to legacy read-only mode
93
+ * and retry automatically on a later operation.
94
+ */
95
+ export declare function migrateAutoUnlocked(root: string, options?: MigrationOptions): Promise<AutoMigrateOutcome>;
package/lib/migrate.js CHANGED
@@ -2,9 +2,14 @@
2
2
  * Legacy → new-format migration for dsh-gitmemo (plan 10).
3
3
  *
4
4
  * Legacy `.mem` repos (no `.gitmemo-format` marker, possibly multi-branch)
5
- * are migrated through an explicit CLI only: `dsh-gitmemo migrate
6
- * --project-root <path> --dry-run` plans the migration (no refs are
7
- * touched), `--apply` executes it inside the cross-process lock:
5
+ * are migrated fully automatically by the engine: the first operation on a
6
+ * legacy repo runs dry-run + apply in one step (no `dsh-gitmemo` command
7
+ * needed). The CLI (`dsh-gitmemo migrate --project-root <path>
8
+ * --dry-run|--apply`) remains for explicit control — non-default --source
9
+ * directories, baseline review, and repos where automatic migration is
10
+ * blocked (conflicts, dirty worktree, ...).
11
+ *
12
+ * Apply mechanics:
8
13
  * 1. backup refs for every old branch tip (`refs/gitmemo/backup/<ts>/<b>`);
9
14
  * 2. a brand-new canonical `main` is built in a sibling temp repo (init
10
15
  * commit + one immutable ADD commit per unique active entry);
@@ -27,6 +32,15 @@ function safeLegacyDate(epochRaw) {
27
32
  const ms = Number(epochRaw ?? 0) * 1000;
28
33
  return Number.isFinite(ms) && Math.abs(ms) <= 8.64e15 ? new Date(ms) : new Date(0);
29
34
  }
35
+ async function isGitRepo(dir, gitTimeoutMs) {
36
+ try {
37
+ await git(dir, ["rev-parse", "--git-dir"], gitTimeoutMs);
38
+ return true;
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
30
44
  async function listTips(memDir, gitTimeoutMs) {
31
45
  const out = (await git(memDir, ["for-each-ref", "--format=%(refname:short)\t%(objectname)", "refs/heads"], gitTimeoutMs)).trim();
32
46
  const branches = [];
@@ -191,136 +205,138 @@ async function buildPlanEntry(args) {
191
205
  * ref, index or worktree state is modified (the lock file is transient).
192
206
  */
193
207
  export async function migrateDryRun(root, options = {}) {
194
- const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
195
208
  const lockTimeoutMs = options.lockTimeoutMs ?? 30000;
209
+ return withLock(root, { lockTimeoutMs }, "migrate-dry-run", () => migrateDryRunUnlocked(root, options));
210
+ }
211
+ /** Unlocked core of {@link migrateDryRun}; the caller must hold the cross-process lock. */
212
+ async function migrateDryRunUnlocked(root, options = {}) {
213
+ const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
196
214
  const memDir = resolve(options.source ?? join(root, ".mem"));
197
- return withLock(root, { lockTimeoutMs }, "migrate-dry-run", async () => {
198
- try {
199
- await git(memDir, ["rev-parse", "--git-dir"], gitTimeoutMs);
200
- }
201
- catch {
202
- throw new GitMemoError("gitmemo: no .mem repository at " + memDir);
203
- }
204
- if ((await repoFormat(memDir, gitTimeoutMs)) === "new") {
205
- return {
206
- legacy: false,
207
- alreadyNewFormat: true,
208
- branches: [],
215
+ try {
216
+ await git(memDir, ["rev-parse", "--git-dir"], gitTimeoutMs);
217
+ }
218
+ catch {
219
+ throw new GitMemoError("gitmemo: no .mem repository at " + memDir);
220
+ }
221
+ if ((await repoFormat(memDir, gitTimeoutMs)) === "new") {
222
+ return {
223
+ legacy: false,
224
+ alreadyNewFormat: true,
225
+ branches: [],
226
+ tips: {},
227
+ oldEntryCount: 0,
228
+ uniqueEntryCount: 0,
229
+ conflictCount: 0,
230
+ estimatedMainCommits: 0,
231
+ baseline: {
232
+ format: "dsh-gitmemo-migration-baseline",
233
+ version: 1,
234
+ root: resolve(root),
235
+ memDir,
236
+ scannedAt: new Date().toISOString(),
209
237
  tips: {},
210
- oldEntryCount: 0,
211
- uniqueEntryCount: 0,
212
- conflictCount: 0,
213
- estimatedMainCommits: 0,
214
- baseline: {
215
- format: "dsh-gitmemo-migration-baseline",
216
- version: 1,
217
- root: resolve(root),
218
- memDir,
219
- scannedAt: new Date().toISOString(),
220
- tips: {},
221
- entries: [],
222
- conflicts: [],
223
- warnings: []
224
- },
225
- warnings: ["already in new format — nothing to migrate"]
226
- };
227
- }
228
- const { branches, tips } = await listTips(memDir, gitTimeoutMs);
229
- if (branches.length === 0) {
230
- throw new GitMemoError("gitmemo: legacy .mem has no branches — nothing to migrate");
231
- }
232
- const warnings = [];
233
- const variantsByPath = new Map();
234
- const occurrences = new Map(); // content digest -> paths
235
- const byDigest = new Map();
236
- let oldEntryCount = 0;
237
- for (const branch of branches.sort()) {
238
- const tip = tips[branch];
239
- const filesOut = (await git(memDir, ["ls-tree", "-r", "--name-only", tip, "--", ENTRY_DIR + "/"], gitTimeoutMs)).trim();
240
- const files = filesOut.split("\n").filter((line) => line.startsWith(ENTRY_DIR + "/") && line.endsWith(".md"));
241
- for (const file of files.sort()) {
242
- oldEntryCount += 1;
243
- const content = await git(memDir, ["show", tip + ":" + file], gitTimeoutMs);
244
- const createOut = (await git(memDir, ["log", "-1", "--format=%ct%x00%H%x00%s%x00%B", tip, "--", file], gitTimeoutMs)).trim();
245
- const [ctRaw, hash, subject, body] = createOut.split("\x00");
246
- const contentDigest = createHash("sha256").update(content, "utf8").digest("hex");
247
- let pathVariants = variantsByPath.get(file);
248
- if (pathVariants === undefined) {
249
- pathVariants = new Map();
250
- variantsByPath.set(file, pathVariants);
251
- }
252
- pathVariants.set(branch, { branch, hash, digest: contentDigest });
253
- let paths = occurrences.get(contentDigest);
254
- if (paths === undefined) {
255
- paths = new Set();
256
- occurrences.set(contentDigest, paths);
257
- }
258
- paths.add(file);
259
- if (!byDigest.has(contentDigest)) {
260
- const plan = await buildPlanEntry({
261
- memDir,
262
- gitTimeoutMs,
263
- branch,
264
- file,
265
- content,
266
- hash,
267
- date: safeLegacyDate(ctRaw),
268
- subject: subject ?? "",
269
- body: body ?? "",
270
- warnings
271
- });
272
- byDigest.set(contentDigest, plan);
273
- }
238
+ entries: [],
239
+ conflicts: [],
240
+ warnings: []
241
+ },
242
+ warnings: ["already in new format — nothing to migrate"]
243
+ };
244
+ }
245
+ const { branches, tips } = await listTips(memDir, gitTimeoutMs);
246
+ if (branches.length === 0) {
247
+ throw new GitMemoError("gitmemo: legacy .mem has no branches — nothing to migrate");
248
+ }
249
+ const warnings = [];
250
+ const variantsByPath = new Map();
251
+ const occurrences = new Map(); // content digest -> paths
252
+ const byDigest = new Map();
253
+ let oldEntryCount = 0;
254
+ for (const branch of branches.sort()) {
255
+ const tip = tips[branch];
256
+ const filesOut = (await git(memDir, ["ls-tree", "-r", "--name-only", tip, "--", ENTRY_DIR + "/"], gitTimeoutMs)).trim();
257
+ const files = filesOut.split("\n").filter((line) => line.startsWith(ENTRY_DIR + "/") && line.endsWith(".md"));
258
+ for (const file of files.sort()) {
259
+ oldEntryCount += 1;
260
+ const content = await git(memDir, ["show", tip + ":" + file], gitTimeoutMs);
261
+ const createOut = (await git(memDir, ["log", "-1", "--format=%ct%x00%H%x00%s%x00%B", tip, "--", file], gitTimeoutMs)).trim();
262
+ const [ctRaw, hash, subject, body] = createOut.split("\x00");
263
+ const contentDigest = createHash("sha256").update(content, "utf8").digest("hex");
264
+ let pathVariants = variantsByPath.get(file);
265
+ if (pathVariants === undefined) {
266
+ pathVariants = new Map();
267
+ variantsByPath.set(file, pathVariants);
274
268
  }
275
- }
276
- // conflicts: same path, different content
277
- const conflicts = [];
278
- for (const [path, variants] of variantsByPath) {
279
- const digests = new Set([...variants.values()].map((v) => v.digest));
280
- if (digests.size > 1) {
281
- conflicts.push({
282
- path,
283
- variants: [...variants.values()].sort((a, b) => a.branch.localeCompare(b.branch))
269
+ pathVariants.set(branch, { branch, hash, digest: contentDigest });
270
+ let paths = occurrences.get(contentDigest);
271
+ if (paths === undefined) {
272
+ paths = new Set();
273
+ occurrences.set(contentDigest, paths);
274
+ }
275
+ paths.add(file);
276
+ if (!byDigest.has(contentDigest)) {
277
+ const plan = await buildPlanEntry({
278
+ memDir,
279
+ gitTimeoutMs,
280
+ branch,
281
+ file,
282
+ content,
283
+ hash,
284
+ date: safeLegacyDate(ctRaw),
285
+ subject: subject ?? "",
286
+ body: body ?? "",
287
+ warnings
284
288
  });
289
+ byDigest.set(contentDigest, plan);
285
290
  }
286
291
  }
287
- // a content digest that occurs at a conflicted path is excluded from auto-migration
288
- const conflictedPaths = new Set(conflicts.map((c) => c.path));
289
- const conflictedDigests = new Set();
290
- for (const [digest, paths] of occurrences) {
291
- for (const path of paths) {
292
- if (conflictedPaths.has(path))
293
- conflictedDigests.add(digest);
294
- }
292
+ }
293
+ // conflicts: same path, different content
294
+ const conflicts = [];
295
+ for (const [path, variants] of variantsByPath) {
296
+ const digests = new Set([...variants.values()].map((v) => v.digest));
297
+ if (digests.size > 1) {
298
+ conflicts.push({
299
+ path,
300
+ variants: [...variants.values()].sort((a, b) => a.branch.localeCompare(b.branch))
301
+ });
295
302
  }
296
- // NOTE: byDigest is keyed by the raw content digest (sha256 of content);
297
- // the plan's `digest` field is the new canonical engine digest — filter by key.
298
- const entries = [...byDigest.entries()]
299
- .filter(([contentDigest]) => !conflictedDigests.has(contentDigest))
300
- .map(([, plan]) => plan);
301
- const baseline = {
302
- format: "dsh-gitmemo-migration-baseline",
303
- version: 1,
304
- root: resolve(root),
305
- memDir,
306
- scannedAt: new Date().toISOString(),
307
- tips,
308
- entries,
309
- conflicts,
310
- warnings
311
- };
312
- return {
313
- legacy: true,
314
- branches,
315
- tips,
316
- oldEntryCount,
317
- uniqueEntryCount: entries.length,
318
- conflictCount: conflicts.length,
319
- estimatedMainCommits: 1 + entries.length,
320
- baseline,
321
- warnings
322
- };
323
- });
303
+ }
304
+ // a content digest that occurs at a conflicted path is excluded from auto-migration
305
+ const conflictedPaths = new Set(conflicts.map((c) => c.path));
306
+ const conflictedDigests = new Set();
307
+ for (const [digest, paths] of occurrences) {
308
+ for (const path of paths) {
309
+ if (conflictedPaths.has(path))
310
+ conflictedDigests.add(digest);
311
+ }
312
+ }
313
+ // NOTE: byDigest is keyed by the raw content digest (sha256 of content);
314
+ // the plan's `digest` field is the new canonical engine digest — filter by key.
315
+ const entries = [...byDigest.entries()]
316
+ .filter(([contentDigest]) => !conflictedDigests.has(contentDigest))
317
+ .map(([, plan]) => plan);
318
+ const baseline = {
319
+ format: "dsh-gitmemo-migration-baseline",
320
+ version: 1,
321
+ root: resolve(root),
322
+ memDir,
323
+ scannedAt: new Date().toISOString(),
324
+ tips,
325
+ entries,
326
+ conflicts,
327
+ warnings
328
+ };
329
+ return {
330
+ legacy: true,
331
+ branches,
332
+ tips,
333
+ oldEntryCount,
334
+ uniqueEntryCount: entries.length,
335
+ conflictCount: conflicts.length,
336
+ estimatedMainCommits: 1 + entries.length,
337
+ baseline,
338
+ warnings
339
+ };
324
340
  }
325
341
  // ---------------------------------------------------------------------------
326
342
  // apply
@@ -370,8 +386,12 @@ async function excludeFromParent(root, gitTimeoutMs) {
370
386
  * new canonical main in a temp repo, then swaps directories atomically.
371
387
  */
372
388
  export async function migrateApply(root, baseline, options = {}) {
373
- const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
374
389
  const lockTimeoutMs = options.lockTimeoutMs ?? 30000;
390
+ return withLock(root, { lockTimeoutMs }, "migrate", () => migrateApplyUnlocked(root, baseline, options));
391
+ }
392
+ /** Unlocked core of {@link migrateApply}; the caller must hold the cross-process lock. */
393
+ async function migrateApplyUnlocked(root, baseline, options = {}) {
394
+ const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
375
395
  if (baseline === null ||
376
396
  typeof baseline !== "object" ||
377
397
  baseline.format !== "dsh-gitmemo-migration-baseline" ||
@@ -395,172 +415,236 @@ export async function migrateApply(root, baseline, options = {}) {
395
415
  if (resolve(baseline.memDir) !== memDir) {
396
416
  throw new GitMemoError("gitmemo: migration baseline source path is inconsistent — re-run --dry-run");
397
417
  }
398
- return withLock(root, { lockTimeoutMs }, "migrate", async () => {
399
- await recoverMigrationSwap(root, gitTimeoutMs);
400
- if ((await repoFormat(memDir, gitTimeoutMs)) === "new") {
401
- return { migrated: false, alreadyMigrated: true, entries: 0, newMain: "", backupRefPrefix: "", oldBranches: [] };
418
+ await recoverMigrationSwap(root, gitTimeoutMs);
419
+ if ((await repoFormat(memDir, gitTimeoutMs)) === "new") {
420
+ return { migrated: false, alreadyMigrated: true, entries: 0, newMain: "", backupRefPrefix: "", oldBranches: [] };
421
+ }
422
+ // 1. re-verify the dry-run baseline refs are unchanged
423
+ const current = await listTips(memDir, gitTimeoutMs);
424
+ const baselineBranches = Object.keys(baseline.tips).sort();
425
+ if (baselineBranches.length === 0) {
426
+ throw new GitMemoError("gitmemo: baseline contains no branches — re-run `dsh-gitmemo migrate --dry-run`");
427
+ }
428
+ for (const branch of baselineBranches) {
429
+ if (current.tips[branch] !== baseline.tips[branch]) {
430
+ throw new GitMemoError(`gitmemo: branch "${branch}" changed since the dry-run baseline (${current.tips[branch] ?? "gone"} ≠ ${baseline.tips[branch]}) — re-run --dry-run`);
402
431
  }
403
- // 1. re-verify the dry-run baseline refs are unchanged
404
- const current = await listTips(memDir, gitTimeoutMs);
405
- const baselineBranches = Object.keys(baseline.tips).sort();
406
- if (baselineBranches.length === 0) {
407
- throw new GitMemoError("gitmemo: baseline contains no branches — re-run `dsh-gitmemo migrate --dry-run`");
432
+ }
433
+ if (current.branches.length !== baselineBranches.length) {
434
+ throw new GitMemoError("gitmemo: branch set changed since the dry-run baseline — re-run --dry-run");
435
+ }
436
+ // 2. conflicts must be resolved by the user first
437
+ if (baseline.conflicts.length > 0) {
438
+ throw new GitMemoError(`gitmemo: migration blocked by ${baseline.conflicts.length} conflict(s) (same entry path, different content across branches):\n` +
439
+ conflictManifest(baseline.conflicts) +
440
+ "\nResolve the conflicts, then re-run --dry-run to produce a clean baseline.");
441
+ }
442
+ const legacyStatus = (await git(memDir, ["status", "--porcelain"], gitTimeoutMs)).trim();
443
+ if (legacyStatus.length > 0) {
444
+ throw new GitMemoError("gitmemo: legacy memory repository is not clean — migration refuses to discard uncommitted/staged content. " +
445
+ `Inspect with: git -C ${memDir} status`);
446
+ }
447
+ const legacyJournal = join(memDir, ".git", "gitmemo-transaction.json");
448
+ try {
449
+ await readFile(legacyJournal, "utf8");
450
+ throw new GitMemoError(`gitmemo: legacy repository contains an interrupted transaction journal (${legacyJournal}); repair it before migration`);
451
+ }
452
+ catch (error) {
453
+ if (error instanceof GitMemoError)
454
+ throw error;
455
+ if (error.code !== "ENOENT")
456
+ throw error;
457
+ }
458
+ // 3. backup refs in the old repo (safety net; also fetched into the new repo)
459
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
460
+ for (const [branch, hash] of Object.entries(baseline.tips)) {
461
+ await git(memDir, ["update-ref", `refs/gitmemo/backup/${ts}/${branch}`, hash], gitTimeoutMs);
462
+ }
463
+ // 4. build the new canonical main in a sibling temp repo
464
+ const tmp = join(root, `.mem.gitmemo-tmp-${process.pid}-${randomBytes(4).toString("hex")}`);
465
+ try {
466
+ await mkdir(join(tmp, ENTRY_DIR), { recursive: true });
467
+ await gitInitMain(tmp, gitTimeoutMs);
468
+ await writeFile(join(tmp, FORMAT_FILE), GITMEMO_FORMAT_VERSION + "\n", "utf8");
469
+ await writeFile(join(tmp, ENTRY_DIR, ".gitkeep"), "", "utf8");
470
+ await git(tmp, ["add", "-A", "--", FORMAT_FILE, ENTRY_DIR + "/.gitkeep"], gitTimeoutMs);
471
+ await commitWithIdentity(tmp, "init: initialize memory repo (gitmemo format 2)", gitTimeoutMs);
472
+ // fetch old objects + backup refs into the new repo so old hashes stay readable
473
+ await git(tmp, ["fetch", "-q", memDir, `+refs/heads/*:refs/gitmemo/backup/${ts}/*`], gitTimeoutMs);
474
+ // 5. one immutable ADD commit per unique entry, preserving old dates
475
+ const entries = [...baseline.entries].sort((a, b) => a.date.localeCompare(b.date) || a.sourcePath.localeCompare(b.sourcePath));
476
+ const digests = new Set(entries.map((e) => e.digest));
477
+ if (digests.size !== entries.length) {
478
+ throw new GitMemoError("gitmemo: migration plan contains duplicate digests — re-run --dry-run");
408
479
  }
409
- for (const branch of baselineBranches) {
410
- if (current.tips[branch] !== baseline.tips[branch]) {
411
- throw new GitMemoError(`gitmemo: branch "${branch}" changed since the dry-run baseline (${current.tips[branch] ?? "gone"} ≠ ${baseline.tips[branch]}) — re-run --dry-run`);
412
- }
480
+ const created = [];
481
+ for (const entry of entries) {
482
+ const parsedMs = Date.parse(entry.date);
483
+ const epoch = Number.isFinite(parsedMs) ? Math.floor(parsedMs / 1000) : 0;
484
+ const env = { GIT_AUTHOR_DATE: "@" + epoch, GIT_COMMITTER_DATE: "@" + epoch };
485
+ const entryDate = safeLegacyDate(epoch);
486
+ const baseName = utcMs(entryDate) + "-" + entry.digest.slice(0, 8) + "-" + slugifyTitle(entry.title) + ".md";
487
+ const markdown = buildEntryMarkdown({
488
+ title: entry.title,
489
+ summary: entry.summary,
490
+ keywords: entry.keywords,
491
+ content: entry.content,
492
+ related_branches: entry.relatedBranches,
493
+ related_paths: entry.relatedPaths
494
+ }, { branch: entry.codeBranch, commit: entry.codeCommit, relatedBranches: entry.relatedBranches }, entry.digest, entryDate);
495
+ const file = await createEntryFile(tmp, baseName, markdown);
496
+ await git(tmp, ["add", "--", file], gitTimeoutMs);
497
+ const hash = await commitWithIdentity(tmp, buildAddMessage(entry.title, entry.summary, entry.keywords, entry.digest, true), gitTimeoutMs, { env });
498
+ created.push({ file, hash });
413
499
  }
414
- if (current.branches.length !== baselineBranches.length) {
415
- throw new GitMemoError("gitmemo: branch set changed since the dry-run baseline — re-run --dry-run");
500
+ // 6. verify the build before touching the original
501
+ if (created.length !== entries.length) {
502
+ throw new GitMemoError("gitmemo: migration entry count mismatch — aborting");
416
503
  }
417
- // 2. conflicts must be resolved by the user first
418
- if (baseline.conflicts.length > 0) {
419
- throw new GitMemoError(`gitmemo: migration blocked by ${baseline.conflicts.length} conflict(s) (same entry path, different content across branches):\n` +
420
- conflictManifest(baseline.conflicts) +
421
- "\nResolve the conflicts, then re-run --dry-run to produce a clean baseline.");
504
+ const status = (await git(tmp, ["status", "--porcelain"], gitTimeoutMs)).trim();
505
+ if (status.length > 0) {
506
+ throw new GitMemoError("gitmemo: migration build tree is dirty: " + status);
422
507
  }
423
- const legacyStatus = (await git(memDir, ["status", "--porcelain"], gitTimeoutMs)).trim();
424
- if (legacyStatus.length > 0) {
425
- throw new GitMemoError("gitmemo: legacy memory repository is not clean — migration refuses to discard uncommitted/staged content. " +
426
- `Inspect with: git -C ${memDir} status`);
508
+ for (const createdEntry of created) {
509
+ const added = await git(tmp, ["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", createdEntry.hash, "--", ENTRY_DIR + "/"], gitTimeoutMs);
510
+ const lines = added.split("\n").filter((l) => /^A\tentries\/.+\.md$/.test(l));
511
+ if (lines.length !== 1 || !lines[0].endsWith(createdEntry.file)) {
512
+ throw new GitMemoError("gitmemo: commit/file mapping mismatch at " + createdEntry.hash + " — aborting");
513
+ }
427
514
  }
428
- const legacyJournal = join(memDir, ".git", "gitmemo-transaction.json");
429
- try {
430
- await readFile(legacyJournal, "utf8");
431
- throw new GitMemoError(`gitmemo: legacy repository contains an interrupted transaction journal (${legacyJournal}); repair it before migration`);
515
+ for (const hash of Object.values(baseline.tips)) {
516
+ await resolveCommit(tmp, hash, gitTimeoutMs); // old tip must be reachable (via backup refs)
432
517
  }
433
- catch (error) {
434
- if (error instanceof GitMemoError)
435
- throw error;
436
- if (error.code !== "ENOENT")
437
- throw error;
518
+ // 7. crash-recoverable compare-and-swap. A sibling journal is written
519
+ // before either rename so search/write never mistake the gap for a fresh
520
+ // project and initialize an empty `.mem`.
521
+ const oldDir = join(root, `.mem.gitmemo-old-${ts}`);
522
+ const targetDir = join(root, ".mem");
523
+ if (memDir !== targetDir && await access(targetDir).then(() => true).catch(() => false)) {
524
+ throw new GitMemoError(`gitmemo: migration target ${targetDir} already exists; move it away before migrating from ${memDir}`);
438
525
  }
439
- // 3. backup refs in the old repo (safety net; also fetched into the new repo)
440
- const ts = new Date().toISOString().replace(/[:.]/g, "-");
441
- for (const [branch, hash] of Object.entries(baseline.tips)) {
442
- await git(memDir, ["update-ref", `refs/gitmemo/backup/${ts}/${branch}`, hash], gitTimeoutMs);
526
+ const swapJournal = {
527
+ operationId: randomBytes(8).toString("hex"),
528
+ sourceDir: memDir,
529
+ targetDir,
530
+ oldDir,
531
+ tmpDir: tmp,
532
+ phase: "prepared",
533
+ createdAt: new Date().toISOString()
534
+ };
535
+ await writeMigrationSwapJournal(root, swapJournal);
536
+ if (memDir === targetDir) {
537
+ await rename(memDir, oldDir);
538
+ swapJournal.phase = "old-renamed";
539
+ await writeMigrationSwapJournal(root, swapJournal);
443
540
  }
444
- // 4. build the new canonical main in a sibling temp repo
445
- const tmp = join(root, `.mem.gitmemo-tmp-${process.pid}-${randomBytes(4).toString("hex")}`);
446
541
  try {
447
- await mkdir(join(tmp, ENTRY_DIR), { recursive: true });
448
- await gitInitMain(tmp, gitTimeoutMs);
449
- await writeFile(join(tmp, FORMAT_FILE), GITMEMO_FORMAT_VERSION + "\n", "utf8");
450
- await writeFile(join(tmp, ENTRY_DIR, ".gitkeep"), "", "utf8");
451
- await git(tmp, ["add", "-A", "--", FORMAT_FILE, ENTRY_DIR + "/.gitkeep"], gitTimeoutMs);
452
- await commitWithIdentity(tmp, "init: initialize memory repo (gitmemo format 2)", gitTimeoutMs);
453
- // fetch old objects + backup refs into the new repo so old hashes stay readable
454
- await git(tmp, ["fetch", "-q", memDir, `+refs/heads/*:refs/gitmemo/backup/${ts}/*`], gitTimeoutMs);
455
- // 5. one immutable ADD commit per unique entry, preserving old dates
456
- const entries = [...baseline.entries].sort((a, b) => a.date.localeCompare(b.date) || a.sourcePath.localeCompare(b.sourcePath));
457
- const digests = new Set(entries.map((e) => e.digest));
458
- if (digests.size !== entries.length) {
459
- throw new GitMemoError("gitmemo: migration plan contains duplicate digests — re-run --dry-run");
460
- }
461
- const created = [];
462
- for (const entry of entries) {
463
- const parsedMs = Date.parse(entry.date);
464
- const epoch = Number.isFinite(parsedMs) ? Math.floor(parsedMs / 1000) : 0;
465
- const env = { GIT_AUTHOR_DATE: "@" + epoch, GIT_COMMITTER_DATE: "@" + epoch };
466
- const entryDate = safeLegacyDate(epoch);
467
- const baseName = utcMs(entryDate) + "-" + entry.digest.slice(0, 8) + "-" + slugifyTitle(entry.title) + ".md";
468
- const markdown = buildEntryMarkdown({
469
- title: entry.title,
470
- summary: entry.summary,
471
- keywords: entry.keywords,
472
- content: entry.content,
473
- related_branches: entry.relatedBranches,
474
- related_paths: entry.relatedPaths
475
- }, { branch: entry.codeBranch, commit: entry.codeCommit, relatedBranches: entry.relatedBranches }, entry.digest, entryDate);
476
- const file = await createEntryFile(tmp, baseName, markdown);
477
- await git(tmp, ["add", "--", file], gitTimeoutMs);
478
- const hash = await commitWithIdentity(tmp, buildAddMessage(entry.title, entry.summary, entry.keywords, entry.digest, true), gitTimeoutMs, { env });
479
- created.push({ file, hash });
480
- }
481
- // 6. verify the build before touching the original
482
- if (created.length !== entries.length) {
483
- throw new GitMemoError("gitmemo: migration entry count mismatch — aborting");
484
- }
485
- const status = (await git(tmp, ["status", "--porcelain"], gitTimeoutMs)).trim();
486
- if (status.length > 0) {
487
- throw new GitMemoError("gitmemo: migration build tree is dirty: " + status);
488
- }
489
- for (const createdEntry of created) {
490
- const added = await git(tmp, ["diff-tree", "--root", "--no-commit-id", "--name-status", "-r", createdEntry.hash, "--", ENTRY_DIR + "/"], gitTimeoutMs);
491
- const lines = added.split("\n").filter((l) => /^A\tentries\/.+\.md$/.test(l));
492
- if (lines.length !== 1 || !lines[0].endsWith(createdEntry.file)) {
493
- throw new GitMemoError("gitmemo: commit/file mapping mismatch at " + createdEntry.hash + " — aborting");
494
- }
495
- }
496
- for (const hash of Object.values(baseline.tips)) {
497
- await resolveCommit(tmp, hash, gitTimeoutMs); // old tip must be reachable (via backup refs)
498
- }
499
- // 7. crash-recoverable compare-and-swap. A sibling journal is written
500
- // before either rename so search/write never mistake the gap for a fresh
501
- // project and initialize an empty `.mem`.
502
- const oldDir = join(root, `.mem.gitmemo-old-${ts}`);
503
- const targetDir = join(root, ".mem");
504
- if (memDir !== targetDir && await access(targetDir).then(() => true).catch(() => false)) {
505
- throw new GitMemoError(`gitmemo: migration target ${targetDir} already exists; move it away before migrating from ${memDir}`);
506
- }
507
- const swapJournal = {
508
- operationId: randomBytes(8).toString("hex"),
509
- sourceDir: memDir,
510
- targetDir,
511
- oldDir,
512
- tmpDir: tmp,
513
- phase: "prepared",
514
- createdAt: new Date().toISOString()
515
- };
542
+ await rename(tmp, targetDir);
543
+ swapJournal.phase = "new-installed";
516
544
  await writeMigrationSwapJournal(root, swapJournal);
517
- if (memDir === targetDir) {
518
- await rename(memDir, oldDir);
519
- swapJournal.phase = "old-renamed";
520
- await writeMigrationSwapJournal(root, swapJournal);
521
- }
522
- try {
523
- await rename(tmp, targetDir);
524
- swapJournal.phase = "new-installed";
525
- await writeMigrationSwapJournal(root, swapJournal);
526
- }
527
- catch (error) {
528
- try {
529
- await recoverMigrationSwap(root, gitTimeoutMs);
530
- if ((await repoFormat(targetDir, gitTimeoutMs)) !== "new")
531
- throw error;
532
- }
533
- catch (restoreError) {
534
- throw new GitMemoError(`gitmemo: migration swap failed and automatic restore also failed. Old repository remains at ${oldDir}; ` +
535
- `temporary repository is ${tmp}; recovery journal is ${join(root, MIGRATION_JOURNAL_NAME)}. ` +
536
- `Original error: ${String(error)}; restore error: ${String(restoreError)}`);
537
- }
538
- throw error;
539
- }
540
- // objects now live in the new repo via the fetch; drop the old directory
545
+ }
546
+ catch (error) {
541
547
  try {
542
- if (memDir === targetDir)
543
- await rm(oldDir, { recursive: true, force: true });
544
- await rm(join(root, MIGRATION_JOURNAL_NAME), { force: true });
545
- }
546
- catch {
547
- // The canonical new repo is already installed. Re-run the idempotent
548
- // recovery finalizer instead of reporting the migration itself failed.
549
548
  await recoverMigrationSwap(root, gitTimeoutMs);
549
+ if ((await repoFormat(targetDir, gitTimeoutMs)) !== "new")
550
+ throw error;
551
+ }
552
+ catch (restoreError) {
553
+ throw new GitMemoError(`gitmemo: migration swap failed and automatic restore also failed. Old repository remains at ${oldDir}; ` +
554
+ `temporary repository is ${tmp}; recovery journal is ${join(root, MIGRATION_JOURNAL_NAME)}. ` +
555
+ `Original error: ${String(error)}; restore error: ${String(restoreError)}`);
550
556
  }
551
- await excludeFromParent(root, gitTimeoutMs);
552
- const newMain = (await git(targetDir, ["rev-parse", "HEAD"], gitTimeoutMs)).trim();
553
- return {
554
- migrated: true,
555
- entries: created.length,
556
- newMain,
557
- backupRefPrefix: `refs/gitmemo/backup/${ts}/`,
558
- oldBranches: baselineBranches
559
- };
560
- }
561
- catch (error) {
562
- await rm(tmp, { recursive: true, force: true }).catch(() => { });
563
557
  throw error;
564
558
  }
565
- });
559
+ // objects now live in the new repo via the fetch; drop the old directory
560
+ try {
561
+ if (memDir === targetDir)
562
+ await rm(oldDir, { recursive: true, force: true });
563
+ await rm(join(root, MIGRATION_JOURNAL_NAME), { force: true });
564
+ }
565
+ catch {
566
+ // The canonical new repo is already installed. Re-run the idempotent
567
+ // recovery finalizer instead of reporting the migration itself failed.
568
+ await recoverMigrationSwap(root, gitTimeoutMs);
569
+ }
570
+ await excludeFromParent(root, gitTimeoutMs);
571
+ const newMain = (await git(targetDir, ["rev-parse", "HEAD"], gitTimeoutMs)).trim();
572
+ return {
573
+ migrated: true,
574
+ entries: created.length,
575
+ newMain,
576
+ backupRefPrefix: `refs/gitmemo/backup/${ts}/`,
577
+ oldBranches: baselineBranches
578
+ };
579
+ }
580
+ catch (error) {
581
+ await rm(tmp, { recursive: true, force: true }).catch(() => { });
582
+ throw error;
583
+ }
584
+ }
585
+ /**
586
+ * Fully automatic migration for engine use: dry-run + apply in one step,
587
+ * without taking the cross-process lock (the caller must already hold it).
588
+ *
589
+ * Never throws for user-resolvable blocking conditions — it returns
590
+ * `blocked` instead, so the caller can fall back to legacy read-only mode
591
+ * and retry automatically on a later operation.
592
+ */
593
+ export async function migrateAutoUnlocked(root, options = {}) {
594
+ const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
595
+ const memDir = resolve(options.source ?? join(root, ".mem"));
596
+ await recoverMigrationSwap(root, gitTimeoutMs);
597
+ if (!(await isGitRepo(memDir, gitTimeoutMs))) {
598
+ return { migrated: false, blocked: { code: "no-repo", detail: "no .mem repository at " + memDir } };
599
+ }
600
+ if ((await repoFormat(memDir, gitTimeoutMs)) === "new") {
601
+ return { migrated: false, alreadyNewFormat: true };
602
+ }
603
+ let report;
604
+ try {
605
+ report = await migrateDryRunUnlocked(root, options);
606
+ }
607
+ catch (error) {
608
+ if (error instanceof GitMemoError) {
609
+ const code = /no branches/.test(error.message) ? "no-branches" : "other";
610
+ return { migrated: false, blocked: { code, detail: error.message } };
611
+ }
612
+ throw error;
613
+ }
614
+ if (!report.legacy)
615
+ return { migrated: false, alreadyNewFormat: true };
616
+ if (report.conflictCount > 0) {
617
+ return {
618
+ migrated: false,
619
+ blocked: {
620
+ code: "conflicts",
621
+ detail: `${report.conflictCount} conflict(s) (same entry path, different content across branches):\n` +
622
+ conflictManifest(report.baseline.conflicts)
623
+ }
624
+ };
625
+ }
626
+ try {
627
+ const applied = await migrateApplyUnlocked(root, report.baseline, options);
628
+ return {
629
+ migrated: applied.migrated,
630
+ ...(applied.alreadyMigrated === true ? { alreadyNewFormat: true } : {}),
631
+ report: applied
632
+ };
633
+ }
634
+ catch (error) {
635
+ if (error instanceof GitMemoError) {
636
+ const message = error.message;
637
+ let code = "other";
638
+ if (/blocked by \d+ conflict/.test(message))
639
+ code = "conflicts";
640
+ else if (/not clean.*refuses to discard/.test(message))
641
+ code = "dirty";
642
+ else if (/interrupted transaction journal/.test(message))
643
+ code = "legacy-journal";
644
+ else if (/baseline contains no branches/.test(message))
645
+ code = "no-branches";
646
+ return { migrated: false, blocked: { code, detail: message } };
647
+ }
648
+ throw error;
649
+ }
566
650
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@fonlan/dsh-gitmemo",
3
- "version": "0.3.0",
4
- "description": "Git-backed long-term memory for DeepSeek Harness: a local .mem git repo (single main branch, immutable entries, structured commit messages) with mem_search / mem_read / mem_write / mem_delete / mem_replace tools and an explicit legacy-migration CLI, auto-managed by the root agent with zero manual steps.",
3
+ "version": "0.3.1",
4
+ "description": "Git-backed long-term memory for DeepSeek Harness: a local .mem git repo (single main branch, immutable entries, structured commit messages) with mem_search / mem_read / mem_write / mem_delete / mem_replace tools and fully automatic legacy migration (no CLI needed; the CLI remains for explicit control), auto-managed by the root agent with zero manual steps.",
5
5
  "author": "fonlan",
6
6
  "publishConfig": {
7
7
  "access": "public"