@fonlan/dsh-gitmemo 0.1.5 → 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/migrate.js ADDED
@@ -0,0 +1,650 @@
1
+ /**
2
+ * Legacy → new-format migration for dsh-gitmemo (plan 10).
3
+ *
4
+ * Legacy `.mem` repos (no `.gitmemo-format` marker, possibly multi-branch)
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:
13
+ * 1. backup refs for every old branch tip (`refs/gitmemo/backup/<ts>/<b>`);
14
+ * 2. a brand-new canonical `main` is built in a sibling temp repo (init
15
+ * commit + one immutable ADD commit per unique active entry);
16
+ * 3. old objects are fetched into the new repo (backup refs keep old hashes
17
+ * readable forever), then the directory is swapped atomically;
18
+ * 4. on any failure the original `.mem` is left untouched.
19
+ *
20
+ * Metadata inference is deterministic and offline (plan 10.6): keywords come
21
+ * from the old front matter `tags/keywords` (else explicit keyword lines in
22
+ * the old commit body), summary from the first `Final Outcome` paragraph
23
+ * (else the old commit body). Nothing is fabricated.
24
+ *
25
+ * @module dsh-gitmemo/migrate
26
+ */
27
+ import { createHash, randomBytes } from "node:crypto";
28
+ import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
29
+ import { basename, isAbsolute, join, resolve } from "node:path";
30
+ import { ENTRY_DIR, FORMAT_FILE, GITMEMO_FORMAT_VERSION, GitMemoError, LOCK_FILE_NAME, MIGRATION_JOURNAL_NAME, buildAddMessage, buildEntryMarkdown, computeDigest, createEntryFile, git, gitInitMain, normalizeText, repoFormat, resolveCommit, recoverMigrationSwap, slugifyTitle, commitWithIdentity, utcMs, withLock, writeMigrationSwapJournal } from "./mem.js";
31
+ function safeLegacyDate(epochRaw) {
32
+ const ms = Number(epochRaw ?? 0) * 1000;
33
+ return Number.isFinite(ms) && Math.abs(ms) <= 8.64e15 ? new Date(ms) : new Date(0);
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
+ }
44
+ async function listTips(memDir, gitTimeoutMs) {
45
+ const out = (await git(memDir, ["for-each-ref", "--format=%(refname:short)\t%(objectname)", "refs/heads"], gitTimeoutMs)).trim();
46
+ const branches = [];
47
+ const tips = {};
48
+ for (const line of out.split("\n")) {
49
+ if (line.length === 0)
50
+ continue;
51
+ const [branch, hash] = line.split("\t");
52
+ branches.push(branch);
53
+ tips[branch] = hash;
54
+ }
55
+ return { branches, tips };
56
+ }
57
+ // ---------------------------------------------------------------------------
58
+ // legacy metadata inference (deterministic, offline)
59
+ // ---------------------------------------------------------------------------
60
+ function frontMatterBlock(content) {
61
+ const lines = content.split("\n");
62
+ if ((lines[0] ?? "").trim() !== "---")
63
+ return [];
64
+ const block = [];
65
+ for (let i = 1; i < lines.length; i += 1) {
66
+ if (lines[i].trim() === "---")
67
+ break;
68
+ block.push(lines[i]);
69
+ }
70
+ return block;
71
+ }
72
+ function legacyFrontMatterKeywords(content) {
73
+ const block = frontMatterBlock(content);
74
+ const out = [];
75
+ for (let i = 0; i < block.length; i += 1) {
76
+ const match = /^(tags|keywords)\s*:\s*(.*)$/.exec(block[i]);
77
+ if (match === null)
78
+ continue;
79
+ let value = match[2].trim();
80
+ if (value.startsWith("[") && value.endsWith("]"))
81
+ value = value.slice(1, -1);
82
+ out.push(...value
83
+ .split(/[,,]/)
84
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
85
+ .filter((s) => s.length > 0));
86
+ for (let k = i + 1; k < block.length; k += 1) {
87
+ const item = /^\s*-\s*(.+)$/.exec(block[k]);
88
+ if (item === null)
89
+ break;
90
+ out.push(item[1].trim());
91
+ }
92
+ }
93
+ return out;
94
+ }
95
+ /** Explicit `keywords:`/`tags:` lines in an old commit body (list-like values only). */
96
+ function legacyBodyKeywords(body) {
97
+ const out = [];
98
+ for (const line of body.split("\n")) {
99
+ const match = /^(keywords?|tags?)\s*[::]\s*(.+)$/i.exec(line.trim());
100
+ if (match === null)
101
+ continue;
102
+ if (!/[,,]/.test(match[2]))
103
+ continue; // only accept list-like values
104
+ out.push(...match[2]
105
+ .split(/[,,]/)
106
+ .map((s) => s.trim())
107
+ .filter((s) => s.length > 0));
108
+ }
109
+ return out;
110
+ }
111
+ /** First paragraph after a `## Final Outcome`-style heading. */
112
+ function finalOutcomeParagraph(content) {
113
+ const lines = content.split("\n");
114
+ let start = -1;
115
+ for (let i = 0; i < lines.length; i += 1) {
116
+ if (/^#{1,6}\s*Final Outcome\s*$/i.test(lines[i].trim())) {
117
+ start = i + 1;
118
+ break;
119
+ }
120
+ }
121
+ if (start < 0)
122
+ return "";
123
+ const paragraph = [];
124
+ for (let i = start; i < lines.length; i += 1) {
125
+ const line = lines[i].trim();
126
+ if (line.length === 0) {
127
+ if (paragraph.length > 0)
128
+ break;
129
+ continue;
130
+ }
131
+ if (/^#{1,6}\s/.test(line))
132
+ break;
133
+ paragraph.push(line);
134
+ }
135
+ return paragraph.join(" ").trim();
136
+ }
137
+ function inferLegacyMetadata(content, subject, body) {
138
+ const warnings = [];
139
+ let title = subject.replace(/\s+/g, " ").trim().slice(0, 200);
140
+ if (title.length === 0) {
141
+ title = "legacy memory";
142
+ warnings.push("entry with empty subject — titled 'legacy memory'");
143
+ }
144
+ let keywords = legacyFrontMatterKeywords(content);
145
+ if (keywords.length === 0)
146
+ keywords = legacyBodyKeywords(body);
147
+ const safeKeywords = [];
148
+ const seenKeywords = new Set();
149
+ for (const original of keywords) {
150
+ const cleaned = original
151
+ .normalize("NFKC")
152
+ .replace(/[\u0000-\u001F\u007F]/g, " ")
153
+ .replace(/\s+/g, " ")
154
+ .trim();
155
+ if (cleaned.length === 0) {
156
+ warnings.push("discarded an empty/unsafe legacy keyword");
157
+ continue;
158
+ }
159
+ const bounded = cleaned.slice(0, 64);
160
+ if (bounded !== original.trim())
161
+ warnings.push(`sanitized legacy keyword: ${JSON.stringify(original)}`);
162
+ const normalized = normalizeText(bounded);
163
+ if (seenKeywords.has(normalized))
164
+ continue;
165
+ seenKeywords.add(normalized);
166
+ safeKeywords.push(bounded);
167
+ if (safeKeywords.length === 12)
168
+ break;
169
+ }
170
+ keywords = safeKeywords;
171
+ if (keywords.length === 0)
172
+ warnings.push("no keywords could be inferred deterministically (legacy entries allow 0-12)");
173
+ let summary = finalOutcomeParagraph(content);
174
+ if (summary.length === 0)
175
+ summary = body.replace(/\n{3,}/g, "\n\n").trim();
176
+ summary = summary.slice(0, 4000);
177
+ if (summary.length === 0)
178
+ warnings.push("no summary could be inferred");
179
+ return { title, summary, keywords, warnings };
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // dry-run
183
+ // ---------------------------------------------------------------------------
184
+ async function buildPlanEntry(args) {
185
+ const meta = inferLegacyMetadata(args.content, args.subject, args.body);
186
+ args.warnings.push(...meta.warnings.map((w) => `${args.branch}:${args.file}: ${w}`));
187
+ const date = args.date.toISOString();
188
+ return {
189
+ title: meta.title,
190
+ summary: meta.summary,
191
+ keywords: meta.keywords,
192
+ content: args.content,
193
+ date,
194
+ codeBranch: args.branch,
195
+ codeCommit: args.hash,
196
+ relatedBranches: [args.branch],
197
+ relatedPaths: ["entries/" + basename(args.file)],
198
+ digest: computeDigest(meta.title, meta.summary, meta.keywords, args.content),
199
+ sourceBranch: args.branch,
200
+ sourcePath: args.file
201
+ };
202
+ }
203
+ /**
204
+ * Scan a legacy `.mem` repo and produce the migration plan. Read-only: no
205
+ * ref, index or worktree state is modified (the lock file is transient).
206
+ */
207
+ export async function migrateDryRun(root, options = {}) {
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;
214
+ const memDir = resolve(options.source ?? join(root, ".mem"));
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(),
237
+ tips: {},
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);
268
+ }
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
288
+ });
289
+ byDigest.set(contentDigest, plan);
290
+ }
291
+ }
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
+ });
302
+ }
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
+ };
340
+ }
341
+ // ---------------------------------------------------------------------------
342
+ // apply
343
+ // ---------------------------------------------------------------------------
344
+ function conflictManifest(conflicts) {
345
+ return conflicts
346
+ .map((c) => {
347
+ const variants = c.variants.map((v) => `${v.branch}@${v.hash.slice(0, 12)}`).join(", ");
348
+ return ` ${c.path}: [${variants}]`;
349
+ })
350
+ .join("\n");
351
+ }
352
+ /** Add `.mem/` and the lock file path to the parent repo's info/exclude. */
353
+ async function excludeFromParent(root, gitTimeoutMs) {
354
+ let gitPath = "";
355
+ try {
356
+ gitPath = (await git(root, ["rev-parse", "--git-path", "info/exclude"], gitTimeoutMs)).trim();
357
+ }
358
+ catch {
359
+ return;
360
+ }
361
+ if (gitPath.length === 0)
362
+ return;
363
+ const excludePath = isAbsolute(gitPath) ? gitPath : join(root, gitPath);
364
+ let existing = "";
365
+ try {
366
+ existing = await readFile(excludePath, "utf8");
367
+ }
368
+ catch {
369
+ existing = "";
370
+ }
371
+ const lines = existing.split("\n");
372
+ let changed = false;
373
+ for (const wanted of [".mem/", LOCK_FILE_NAME, MIGRATION_JOURNAL_NAME, ".mem.gitmemo-old-*", ".mem.gitmemo-tmp-*"]) {
374
+ if (!lines.includes(wanted)) {
375
+ lines.push(wanted);
376
+ changed = true;
377
+ }
378
+ }
379
+ if (changed) {
380
+ await writeFile(excludePath, lines.filter((l, i) => !(i === 0 && l === "" && lines.length > 1)).join("\n") + "\n", "utf8");
381
+ }
382
+ }
383
+ /**
384
+ * Execute a previously dry-run migration (plan 10.4). Verifies the baseline
385
+ * refs are unchanged, blocks on conflicts, creates backup refs, builds the
386
+ * new canonical main in a temp repo, then swaps directories atomically.
387
+ */
388
+ export async function migrateApply(root, baseline, options = {}) {
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;
395
+ if (baseline === null ||
396
+ typeof baseline !== "object" ||
397
+ baseline.format !== "dsh-gitmemo-migration-baseline" ||
398
+ baseline.version !== 1 ||
399
+ typeof baseline.root !== "string" ||
400
+ baseline.root.length === 0 ||
401
+ typeof baseline.memDir !== "string" ||
402
+ baseline.memDir.length === 0 ||
403
+ baseline.tips === null ||
404
+ typeof baseline.tips !== "object" ||
405
+ Array.isArray(baseline.tips) ||
406
+ !Array.isArray(baseline.entries) ||
407
+ !Array.isArray(baseline.conflicts) ||
408
+ !Array.isArray(baseline.warnings)) {
409
+ throw new GitMemoError("gitmemo: invalid migration baseline — re-run `dsh-gitmemo migrate --dry-run`");
410
+ }
411
+ if (resolve(baseline.root) !== resolve(root)) {
412
+ throw new GitMemoError(`gitmemo: migration baseline belongs to a different project root (${baseline.root}); re-run --dry-run for ${resolve(root)}`);
413
+ }
414
+ const memDir = resolve(options.source ?? baseline.memDir ?? join(root, ".mem"));
415
+ if (resolve(baseline.memDir) !== memDir) {
416
+ throw new GitMemoError("gitmemo: migration baseline source path is inconsistent — re-run --dry-run");
417
+ }
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`);
431
+ }
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");
479
+ }
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 });
499
+ }
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");
503
+ }
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);
507
+ }
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
+ }
514
+ }
515
+ for (const hash of Object.values(baseline.tips)) {
516
+ await resolveCommit(tmp, hash, gitTimeoutMs); // old tip must be reachable (via backup refs)
517
+ }
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}`);
525
+ }
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);
540
+ }
541
+ try {
542
+ await rename(tmp, targetDir);
543
+ swapJournal.phase = "new-installed";
544
+ await writeMigrationSwapJournal(root, swapJournal);
545
+ }
546
+ catch (error) {
547
+ try {
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)}`);
556
+ }
557
+ throw error;
558
+ }
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
+ }
650
+ }