@fonlan/dsh-gitmemo 0.1.4 → 0.3.0

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.
@@ -0,0 +1,73 @@
1
+ /** One deduplicated entry planned for reimport. */
2
+ export interface MigrationPlanEntry {
3
+ title: string;
4
+ summary: string;
5
+ keywords: string[];
6
+ content: string;
7
+ date: string;
8
+ codeBranch: string;
9
+ codeCommit: string;
10
+ relatedBranches: string[];
11
+ relatedPaths: string[];
12
+ digest: string;
13
+ sourceBranch: string;
14
+ sourcePath: string;
15
+ }
16
+ /** Same entry path with different content across branch tips. */
17
+ export interface MigrationConflict {
18
+ path: string;
19
+ variants: Array<{
20
+ branch: string;
21
+ hash: string;
22
+ digest: string;
23
+ }>;
24
+ }
25
+ /** Serializable baseline produced by dry-run and consumed by apply. */
26
+ export interface MigrationBaseline {
27
+ format: "dsh-gitmemo-migration-baseline";
28
+ version: 1;
29
+ root: string;
30
+ memDir: string;
31
+ scannedAt: string;
32
+ tips: Record<string, string>;
33
+ entries: MigrationPlanEntry[];
34
+ conflicts: MigrationConflict[];
35
+ warnings: string[];
36
+ }
37
+ export interface MigrationDryRunReport {
38
+ legacy: boolean;
39
+ alreadyNewFormat?: boolean;
40
+ branches: string[];
41
+ tips: Record<string, string>;
42
+ oldEntryCount: number;
43
+ uniqueEntryCount: number;
44
+ conflictCount: number;
45
+ estimatedMainCommits: number;
46
+ baseline: MigrationBaseline;
47
+ warnings: string[];
48
+ }
49
+ export interface MigrationApplyReport {
50
+ migrated: boolean;
51
+ alreadyMigrated?: boolean;
52
+ entries: number;
53
+ newMain: string;
54
+ backupRefPrefix: string;
55
+ oldBranches: string[];
56
+ }
57
+ export interface MigrationOptions {
58
+ /** Legacy memory dir to migrate from (default `<root>/.mem`). */
59
+ source?: string;
60
+ gitTimeoutMs?: number;
61
+ lockTimeoutMs?: number;
62
+ }
63
+ /**
64
+ * Scan a legacy `.mem` repo and produce the migration plan. Read-only: no
65
+ * ref, index or worktree state is modified (the lock file is transient).
66
+ */
67
+ export declare function migrateDryRun(root: string, options?: MigrationOptions): Promise<MigrationDryRunReport>;
68
+ /**
69
+ * Execute a previously dry-run migration (plan 10.4). Verifies the baseline
70
+ * refs are unchanged, blocks on conflicts, creates backup refs, builds the
71
+ * new canonical main in a temp repo, then swaps directories atomically.
72
+ */
73
+ export declare function migrateApply(root: string, baseline: MigrationBaseline, options?: MigrationOptions): Promise<MigrationApplyReport>;
package/lib/migrate.js ADDED
@@ -0,0 +1,566 @@
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 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:
8
+ * 1. backup refs for every old branch tip (`refs/gitmemo/backup/<ts>/<b>`);
9
+ * 2. a brand-new canonical `main` is built in a sibling temp repo (init
10
+ * commit + one immutable ADD commit per unique active entry);
11
+ * 3. old objects are fetched into the new repo (backup refs keep old hashes
12
+ * readable forever), then the directory is swapped atomically;
13
+ * 4. on any failure the original `.mem` is left untouched.
14
+ *
15
+ * Metadata inference is deterministic and offline (plan 10.6): keywords come
16
+ * from the old front matter `tags/keywords` (else explicit keyword lines in
17
+ * the old commit body), summary from the first `Final Outcome` paragraph
18
+ * (else the old commit body). Nothing is fabricated.
19
+ *
20
+ * @module dsh-gitmemo/migrate
21
+ */
22
+ import { createHash, randomBytes } from "node:crypto";
23
+ import { access, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
24
+ import { basename, isAbsolute, join, resolve } from "node:path";
25
+ 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";
26
+ function safeLegacyDate(epochRaw) {
27
+ const ms = Number(epochRaw ?? 0) * 1000;
28
+ return Number.isFinite(ms) && Math.abs(ms) <= 8.64e15 ? new Date(ms) : new Date(0);
29
+ }
30
+ async function listTips(memDir, gitTimeoutMs) {
31
+ const out = (await git(memDir, ["for-each-ref", "--format=%(refname:short)\t%(objectname)", "refs/heads"], gitTimeoutMs)).trim();
32
+ const branches = [];
33
+ const tips = {};
34
+ for (const line of out.split("\n")) {
35
+ if (line.length === 0)
36
+ continue;
37
+ const [branch, hash] = line.split("\t");
38
+ branches.push(branch);
39
+ tips[branch] = hash;
40
+ }
41
+ return { branches, tips };
42
+ }
43
+ // ---------------------------------------------------------------------------
44
+ // legacy metadata inference (deterministic, offline)
45
+ // ---------------------------------------------------------------------------
46
+ function frontMatterBlock(content) {
47
+ const lines = content.split("\n");
48
+ if ((lines[0] ?? "").trim() !== "---")
49
+ return [];
50
+ const block = [];
51
+ for (let i = 1; i < lines.length; i += 1) {
52
+ if (lines[i].trim() === "---")
53
+ break;
54
+ block.push(lines[i]);
55
+ }
56
+ return block;
57
+ }
58
+ function legacyFrontMatterKeywords(content) {
59
+ const block = frontMatterBlock(content);
60
+ const out = [];
61
+ for (let i = 0; i < block.length; i += 1) {
62
+ const match = /^(tags|keywords)\s*:\s*(.*)$/.exec(block[i]);
63
+ if (match === null)
64
+ continue;
65
+ let value = match[2].trim();
66
+ if (value.startsWith("[") && value.endsWith("]"))
67
+ value = value.slice(1, -1);
68
+ out.push(...value
69
+ .split(/[,,]/)
70
+ .map((s) => s.trim().replace(/^["']|["']$/g, ""))
71
+ .filter((s) => s.length > 0));
72
+ for (let k = i + 1; k < block.length; k += 1) {
73
+ const item = /^\s*-\s*(.+)$/.exec(block[k]);
74
+ if (item === null)
75
+ break;
76
+ out.push(item[1].trim());
77
+ }
78
+ }
79
+ return out;
80
+ }
81
+ /** Explicit `keywords:`/`tags:` lines in an old commit body (list-like values only). */
82
+ function legacyBodyKeywords(body) {
83
+ const out = [];
84
+ for (const line of body.split("\n")) {
85
+ const match = /^(keywords?|tags?)\s*[::]\s*(.+)$/i.exec(line.trim());
86
+ if (match === null)
87
+ continue;
88
+ if (!/[,,]/.test(match[2]))
89
+ continue; // only accept list-like values
90
+ out.push(...match[2]
91
+ .split(/[,,]/)
92
+ .map((s) => s.trim())
93
+ .filter((s) => s.length > 0));
94
+ }
95
+ return out;
96
+ }
97
+ /** First paragraph after a `## Final Outcome`-style heading. */
98
+ function finalOutcomeParagraph(content) {
99
+ const lines = content.split("\n");
100
+ let start = -1;
101
+ for (let i = 0; i < lines.length; i += 1) {
102
+ if (/^#{1,6}\s*Final Outcome\s*$/i.test(lines[i].trim())) {
103
+ start = i + 1;
104
+ break;
105
+ }
106
+ }
107
+ if (start < 0)
108
+ return "";
109
+ const paragraph = [];
110
+ for (let i = start; i < lines.length; i += 1) {
111
+ const line = lines[i].trim();
112
+ if (line.length === 0) {
113
+ if (paragraph.length > 0)
114
+ break;
115
+ continue;
116
+ }
117
+ if (/^#{1,6}\s/.test(line))
118
+ break;
119
+ paragraph.push(line);
120
+ }
121
+ return paragraph.join(" ").trim();
122
+ }
123
+ function inferLegacyMetadata(content, subject, body) {
124
+ const warnings = [];
125
+ let title = subject.replace(/\s+/g, " ").trim().slice(0, 200);
126
+ if (title.length === 0) {
127
+ title = "legacy memory";
128
+ warnings.push("entry with empty subject — titled 'legacy memory'");
129
+ }
130
+ let keywords = legacyFrontMatterKeywords(content);
131
+ if (keywords.length === 0)
132
+ keywords = legacyBodyKeywords(body);
133
+ const safeKeywords = [];
134
+ const seenKeywords = new Set();
135
+ for (const original of keywords) {
136
+ const cleaned = original
137
+ .normalize("NFKC")
138
+ .replace(/[\u0000-\u001F\u007F]/g, " ")
139
+ .replace(/\s+/g, " ")
140
+ .trim();
141
+ if (cleaned.length === 0) {
142
+ warnings.push("discarded an empty/unsafe legacy keyword");
143
+ continue;
144
+ }
145
+ const bounded = cleaned.slice(0, 64);
146
+ if (bounded !== original.trim())
147
+ warnings.push(`sanitized legacy keyword: ${JSON.stringify(original)}`);
148
+ const normalized = normalizeText(bounded);
149
+ if (seenKeywords.has(normalized))
150
+ continue;
151
+ seenKeywords.add(normalized);
152
+ safeKeywords.push(bounded);
153
+ if (safeKeywords.length === 12)
154
+ break;
155
+ }
156
+ keywords = safeKeywords;
157
+ if (keywords.length === 0)
158
+ warnings.push("no keywords could be inferred deterministically (legacy entries allow 0-12)");
159
+ let summary = finalOutcomeParagraph(content);
160
+ if (summary.length === 0)
161
+ summary = body.replace(/\n{3,}/g, "\n\n").trim();
162
+ summary = summary.slice(0, 4000);
163
+ if (summary.length === 0)
164
+ warnings.push("no summary could be inferred");
165
+ return { title, summary, keywords, warnings };
166
+ }
167
+ // ---------------------------------------------------------------------------
168
+ // dry-run
169
+ // ---------------------------------------------------------------------------
170
+ async function buildPlanEntry(args) {
171
+ const meta = inferLegacyMetadata(args.content, args.subject, args.body);
172
+ args.warnings.push(...meta.warnings.map((w) => `${args.branch}:${args.file}: ${w}`));
173
+ const date = args.date.toISOString();
174
+ return {
175
+ title: meta.title,
176
+ summary: meta.summary,
177
+ keywords: meta.keywords,
178
+ content: args.content,
179
+ date,
180
+ codeBranch: args.branch,
181
+ codeCommit: args.hash,
182
+ relatedBranches: [args.branch],
183
+ relatedPaths: ["entries/" + basename(args.file)],
184
+ digest: computeDigest(meta.title, meta.summary, meta.keywords, args.content),
185
+ sourceBranch: args.branch,
186
+ sourcePath: args.file
187
+ };
188
+ }
189
+ /**
190
+ * Scan a legacy `.mem` repo and produce the migration plan. Read-only: no
191
+ * ref, index or worktree state is modified (the lock file is transient).
192
+ */
193
+ export async function migrateDryRun(root, options = {}) {
194
+ const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
195
+ const lockTimeoutMs = options.lockTimeoutMs ?? 30000;
196
+ 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: [],
209
+ 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
+ }
274
+ }
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))
284
+ });
285
+ }
286
+ }
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
+ }
295
+ }
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
+ });
324
+ }
325
+ // ---------------------------------------------------------------------------
326
+ // apply
327
+ // ---------------------------------------------------------------------------
328
+ function conflictManifest(conflicts) {
329
+ return conflicts
330
+ .map((c) => {
331
+ const variants = c.variants.map((v) => `${v.branch}@${v.hash.slice(0, 12)}`).join(", ");
332
+ return ` ${c.path}: [${variants}]`;
333
+ })
334
+ .join("\n");
335
+ }
336
+ /** Add `.mem/` and the lock file path to the parent repo's info/exclude. */
337
+ async function excludeFromParent(root, gitTimeoutMs) {
338
+ let gitPath = "";
339
+ try {
340
+ gitPath = (await git(root, ["rev-parse", "--git-path", "info/exclude"], gitTimeoutMs)).trim();
341
+ }
342
+ catch {
343
+ return;
344
+ }
345
+ if (gitPath.length === 0)
346
+ return;
347
+ const excludePath = isAbsolute(gitPath) ? gitPath : join(root, gitPath);
348
+ let existing = "";
349
+ try {
350
+ existing = await readFile(excludePath, "utf8");
351
+ }
352
+ catch {
353
+ existing = "";
354
+ }
355
+ const lines = existing.split("\n");
356
+ let changed = false;
357
+ for (const wanted of [".mem/", LOCK_FILE_NAME, MIGRATION_JOURNAL_NAME, ".mem.gitmemo-old-*", ".mem.gitmemo-tmp-*"]) {
358
+ if (!lines.includes(wanted)) {
359
+ lines.push(wanted);
360
+ changed = true;
361
+ }
362
+ }
363
+ if (changed) {
364
+ await writeFile(excludePath, lines.filter((l, i) => !(i === 0 && l === "" && lines.length > 1)).join("\n") + "\n", "utf8");
365
+ }
366
+ }
367
+ /**
368
+ * Execute a previously dry-run migration (plan 10.4). Verifies the baseline
369
+ * refs are unchanged, blocks on conflicts, creates backup refs, builds the
370
+ * new canonical main in a temp repo, then swaps directories atomically.
371
+ */
372
+ export async function migrateApply(root, baseline, options = {}) {
373
+ const gitTimeoutMs = options.gitTimeoutMs ?? 60000;
374
+ const lockTimeoutMs = options.lockTimeoutMs ?? 30000;
375
+ if (baseline === null ||
376
+ typeof baseline !== "object" ||
377
+ baseline.format !== "dsh-gitmemo-migration-baseline" ||
378
+ baseline.version !== 1 ||
379
+ typeof baseline.root !== "string" ||
380
+ baseline.root.length === 0 ||
381
+ typeof baseline.memDir !== "string" ||
382
+ baseline.memDir.length === 0 ||
383
+ baseline.tips === null ||
384
+ typeof baseline.tips !== "object" ||
385
+ Array.isArray(baseline.tips) ||
386
+ !Array.isArray(baseline.entries) ||
387
+ !Array.isArray(baseline.conflicts) ||
388
+ !Array.isArray(baseline.warnings)) {
389
+ throw new GitMemoError("gitmemo: invalid migration baseline — re-run `dsh-gitmemo migrate --dry-run`");
390
+ }
391
+ if (resolve(baseline.root) !== resolve(root)) {
392
+ throw new GitMemoError(`gitmemo: migration baseline belongs to a different project root (${baseline.root}); re-run --dry-run for ${resolve(root)}`);
393
+ }
394
+ const memDir = resolve(options.source ?? baseline.memDir ?? join(root, ".mem"));
395
+ if (resolve(baseline.memDir) !== memDir) {
396
+ throw new GitMemoError("gitmemo: migration baseline source path is inconsistent — re-run --dry-run");
397
+ }
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: [] };
402
+ }
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`");
408
+ }
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
+ }
413
+ }
414
+ if (current.branches.length !== baselineBranches.length) {
415
+ throw new GitMemoError("gitmemo: branch set changed since the dry-run baseline — re-run --dry-run");
416
+ }
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.");
422
+ }
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`);
427
+ }
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`);
432
+ }
433
+ catch (error) {
434
+ if (error instanceof GitMemoError)
435
+ throw error;
436
+ if (error.code !== "ENOENT")
437
+ throw error;
438
+ }
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);
443
+ }
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
+ 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
+ };
516
+ 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
541
+ 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
+ await recoverMigrationSwap(root, gitTimeoutMs);
550
+ }
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
+ throw error;
564
+ }
565
+ });
566
+ }