dotmd-cli 0.69.0 → 0.70.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.
Files changed (53) hide show
  1. package/README.md +144 -964
  2. package/bin/dotmd.mjs +238 -195
  3. package/dotmd.config.example.mjs +5 -8
  4. package/package.json +6 -10
  5. package/src/agent-context.mjs +132 -0
  6. package/src/atomic-mutation.mjs +1505 -0
  7. package/src/baton.mjs +109 -114
  8. package/src/bulk-tag.mjs +7 -7
  9. package/src/commands.mjs +326 -12
  10. package/src/completions.mjs +38 -98
  11. package/src/config.mjs +18 -3
  12. package/src/diff.mjs +7 -3
  13. package/src/doctor.mjs +12 -5
  14. package/src/export.mjs +154 -25
  15. package/src/fix-refs.mjs +2 -0
  16. package/src/frontmatter-fix.mjs +2 -0
  17. package/src/frontmatter.mjs +3 -2
  18. package/src/git.mjs +531 -14
  19. package/src/graph.mjs +53 -25
  20. package/src/guard.mjs +163 -60
  21. package/src/hud.mjs +65 -76
  22. package/src/index-file.mjs +28 -16
  23. package/src/index.mjs +17 -12
  24. package/src/init.mjs +1 -1
  25. package/src/journal.mjs +145 -12
  26. package/src/lifecycle.mjs +554 -282
  27. package/src/lint.mjs +3 -3
  28. package/src/managed-path.mjs +192 -0
  29. package/src/migrate-prompts.mjs +2 -0
  30. package/src/migrate-template.mjs +2 -0
  31. package/src/migrate.mjs +7 -1
  32. package/src/new.mjs +135 -54
  33. package/src/output-identity.mjs +106 -0
  34. package/src/pickup-card.mjs +24 -10
  35. package/src/pickup.mjs +457 -0
  36. package/src/prompts.mjs +134 -75
  37. package/src/query.mjs +22 -10
  38. package/src/reference-planner.mjs +292 -0
  39. package/src/rename.mjs +65 -73
  40. package/src/render.mjs +17 -8
  41. package/src/runlist.mjs +109 -71
  42. package/src/section.mjs +2 -1
  43. package/src/ship.mjs +39 -20
  44. package/src/stats.mjs +1 -1
  45. package/src/status-metadata.mjs +87 -0
  46. package/src/statuses.mjs +11 -26
  47. package/src/summary.mjs +14 -3
  48. package/src/update.mjs +38 -10
  49. package/src/use.mjs +4 -1
  50. package/src/util.mjs +1 -0
  51. package/src/validate.mjs +14 -6
  52. package/src/watch.mjs +6 -1
  53. package/src/notion.mjs +0 -528
package/src/git.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { renameSync } from 'node:fs';
2
+ import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
4
5
 
5
6
  // Best-effort `git check-ignore` for a path. Returns true only when git
6
7
  // definitively reports the path is ignored; any failure (not a repo, git
@@ -22,6 +23,13 @@ export function isGitIgnored(absPath, repoRoot) {
22
23
  }
23
24
 
24
25
  let gitChecked = false;
26
+ function assertSafeGitPaths(paths) {
27
+ for (const filePath of paths) {
28
+ if (!filePath || path.isAbsolute(filePath) || filePath.split(/[\\/]/).includes('..')) {
29
+ throw new Error(`Unsafe repository-relative Git path: ${filePath}`);
30
+ }
31
+ }
32
+ }
25
33
  function ensureGit() {
26
34
  if (gitChecked) return;
27
35
  const result = spawnSync('git', ['--version'], { encoding: 'utf8' });
@@ -50,22 +58,225 @@ export function getGitFirstAdded(relPath, repoRoot) {
50
58
  return lines[lines.length - 1] ?? null;
51
59
  }
52
60
 
53
- export function getGitLastModifiedBatch(repoRoot) {
54
- const result = spawnSync('git', [
55
- 'log', '--format=commit %aI', '--name-only', '--diff-filter=ACDMR', 'HEAD',
56
- ], { cwd: repoRoot, encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });
57
- if (result.error || result.status !== 0) return new Map();
61
+ const DEFAULT_GIT_METADATA_MAX_COMMITS = 10_000;
62
+ const DEFAULT_GIT_METADATA_MAX_BUFFER = 10 * 1024 * 1024;
63
+ const DEFAULT_GIT_METADATA_PATH_BATCH = 256;
64
+
65
+ function boundedPositiveInteger(value, fallback, name) {
66
+ if (value == null) return fallback;
67
+ if (!Number.isSafeInteger(value) || value < 1) {
68
+ throw new TypeError(`${name} must be a positive integer`);
69
+ }
70
+ return value;
71
+ }
72
+
73
+ function unavailableGitHistory(result) {
74
+ if (result.error?.code === 'ENOENT') return true;
75
+ const detail = `${result.stderr ?? ''}\n${result.error?.message ?? ''}`;
76
+ return /not a git repository|does not have any commits yet|bad revision ['"]?HEAD|ambiguous argument ['"]?HEAD/i.test(detail);
77
+ }
58
78
 
59
- const map = new Map();
79
+ function parseGitMetadataOutput(stdout, dates, expectedPaths) {
80
+ const fields = String(stdout ?? '').split('\0');
60
81
  let currentDate = null;
61
- for (const line of result.stdout.split('\n')) {
62
- if (line.startsWith('commit ')) {
63
- currentDate = line.slice(7).trim();
64
- } else if (line && currentDate && !map.has(line)) {
65
- map.set(line, currentDate);
82
+ for (let i = 0; i < fields.length; i++) {
83
+ const field = fields[i];
84
+ if (field === 'dotmd:git-metadata:commit' && /^\d{4}-\d{2}-\d{2}T/.test(fields[i + 1] ?? '')) {
85
+ currentDate = fields[++i];
86
+ continue;
87
+ }
88
+ const filePath = field.startsWith('\n') ? field.slice(1) : field;
89
+ if (filePath && currentDate && expectedPaths.has(filePath) && !dates.has(filePath)) {
90
+ dates.set(filePath, currentDate);
91
+ }
92
+ }
93
+ }
94
+
95
+ // Read latest Git dates only for the current managed paths. Every subprocess
96
+ // has explicit history, output, and path-count bounds. Callers must inspect
97
+ // `complete` before using missing dates as evidence that no history exists.
98
+ export function getGitLastModifiedBatch(repoRoot, relPaths, options = {}) {
99
+ const maxCommits = boundedPositiveInteger(options.maxCommits, DEFAULT_GIT_METADATA_MAX_COMMITS, 'maxCommits');
100
+ const maxBuffer = boundedPositiveInteger(options.maxBuffer, DEFAULT_GIT_METADATA_MAX_BUFFER, 'maxBuffer');
101
+ const maxPathsPerBatch = boundedPositiveInteger(options.maxPathsPerBatch, DEFAULT_GIT_METADATA_PATH_BATCH, 'maxPathsPerBatch');
102
+ const paths = [...new Set(relPaths ?? [])];
103
+ assertSafeGitPaths(paths);
104
+ if (paths.length === 0) return { dates: new Map(), complete: true, reason: null };
105
+
106
+ const dates = new Map();
107
+ let reason = null;
108
+ const revisions = spawnSync('git', ['rev-list', `--max-count=${maxCommits + 1}`, 'HEAD'], {
109
+ cwd: repoRoot,
110
+ encoding: 'utf8',
111
+ maxBuffer,
112
+ });
113
+ if (revisions.error?.code === 'ENOBUFS') {
114
+ reason = 'output-limit';
115
+ } else if (revisions.error || revisions.status !== 0) {
116
+ if (unavailableGitHistory(revisions)) return { dates, complete: true, reason: null };
117
+ return { dates, complete: false, reason: 'git-error' };
118
+ }
119
+ const commits = String(revisions.stdout ?? '')
120
+ .split('\n')
121
+ .filter(line => /^[0-9a-f]{40,64}$/i.test(line))
122
+ .slice(0, maxCommits);
123
+ if (commits.length === 0) return { dates, complete: reason === null, reason };
124
+ if (!reason && String(revisions.stdout ?? '').trim().split('\n').length > maxCommits) {
125
+ reason = 'commit-limit';
126
+ }
127
+
128
+ for (let offset = 0; offset < paths.length; offset += maxPathsPerBatch) {
129
+ const batch = paths.slice(offset, offset + maxPathsPerBatch);
130
+ const result = spawnSync('git', [
131
+ 'diff-tree', '--stdin', '--root', '-r', '-z', '--format=%x00dotmd:git-metadata:commit%x00%aI%x00', '--name-only', '--diff-filter=ACDMR', '--', ...batch,
132
+ ], { cwd: repoRoot, encoding: 'utf8', input: commits.join('\n') + '\n', maxBuffer });
133
+ parseGitMetadataOutput(result.stdout, dates, new Set(batch));
134
+
135
+ if (result.error?.code === 'ENOBUFS') {
136
+ reason = 'output-limit';
137
+ } else if (result.error || result.status !== 0) {
138
+ reason = reason ?? 'git-error';
139
+ }
140
+ }
141
+
142
+ return { dates, complete: reason === null, reason };
143
+ }
144
+
145
+ function parseNullPaths(result) {
146
+ if (result.error || result.status !== 0) return [];
147
+ return result.stdout.split('\0').filter(Boolean);
148
+ }
149
+
150
+ function parsePorcelainRecords(result) {
151
+ if (result.error || result.status !== 0) return [];
152
+ const fields = result.stdout.split('\0').filter(Boolean);
153
+ const records = [];
154
+ for (let i = 0; i < fields.length; i++) {
155
+ const field = fields[i];
156
+ if (field.length < 4 || field[2] !== ' ') continue;
157
+ const status = field.slice(0, 2);
158
+ records.push({ status, path: field.slice(3) });
159
+ // In porcelain v1 -z, rename/copy records put the destination first and
160
+ // the old source path in the following NUL field. The command includes the
161
+ // destination state, not the now-missing live source.
162
+ if (/[RC]/.test(status)) i += 1;
163
+ }
164
+ return records;
165
+ }
166
+
167
+ function parseShortOptions(arg, valueOptions = new Set()) {
168
+ const parsed = { options: new Set(), consumesNext: false };
169
+ if (!/^-[^-]/.test(arg)) return parsed;
170
+ const chars = arg.slice(1);
171
+ for (let i = 0; i < chars.length; i++) {
172
+ const option = chars[i];
173
+ parsed.options.add(option);
174
+ if (valueOptions.has(option)) {
175
+ parsed.consumesNext = i === chars.length - 1;
176
+ break; // remaining characters are this option's attached value
66
177
  }
67
178
  }
68
- return map;
179
+ return parsed;
180
+ }
181
+
182
+ function hasShortOption(args, option, valueOptions = new Set()) {
183
+ return args.some(arg => parseShortOptions(arg, valueOptions).options.has(option));
184
+ }
185
+
186
+ function gitPathspecs(args, subcommand) {
187
+ const separator = args.indexOf('--');
188
+ if (separator >= 0) return args.slice(separator + 1);
189
+ const valueFlags = subcommand === 'commit'
190
+ ? new Set(['-m', '--message', '-F', '--file', '-C', '-c', '--reuse-message', '--reedit-message', '-t', '--template', '--author', '--date', '--cleanup', '--fixup', '--squash', '--trailer'])
191
+ : new Set(['--chmod', '--pathspec-from-file']);
192
+ const paths = [];
193
+ for (let i = 0; i < args.length; i++) {
194
+ const arg = args[i];
195
+ if (valueFlags.has(arg)) { i += 1; continue; }
196
+ if (subcommand === 'commit' && /^-[^-]/.test(arg)) {
197
+ const short = parseShortOptions(arg, new Set(['m', 'F', 'C', 'c', 't', 'S']));
198
+ if (short.consumesNext) i += 1;
199
+ continue;
200
+ }
201
+ if (arg.startsWith('-')) continue;
202
+ paths.push(arg);
203
+ }
204
+ return paths;
205
+ }
206
+
207
+ // Paths a broad `git add`/`git commit` would actually include. Read-only and
208
+ // fail-open: any Git error returns an empty list so the PreToolUse guard never
209
+ // blocks on an uncertain repository state.
210
+ export function inspectGitCommandPaths(subcommand, args, repoRoot) {
211
+ try {
212
+ if ((subcommand === 'add' || subcommand === 'stage')
213
+ && (hasShortOption(args, 'n') || args.includes('--dry-run'))) return [];
214
+ if (subcommand === 'commit' && args.includes('--dry-run')) return [];
215
+ const pathspecs = gitPathspecs(args, subcommand);
216
+ if (subcommand === 'add' || subcommand === 'stage') {
217
+ const cmd = ['status', '--porcelain=v1', '-z', '--untracked-files=all'];
218
+ if (pathspecs.length > 0) cmd.push('--', ...pathspecs);
219
+ const updateOnly = hasShortOption(args, 'u') || args.includes('--update');
220
+ const records = parsePorcelainRecords(spawnSync('git', cmd, { cwd: repoRoot, encoding: 'utf8' }));
221
+ const paths = records
222
+ .filter(record => !updateOnly || record.status !== '??')
223
+ .map(record => record.path)
224
+ .filter(candidate => existsSync(path.resolve(repoRoot, candidate)));
225
+ const forceAll = hasShortOption(args, 'A') || args.includes('--all');
226
+ if ((hasShortOption(args, 'f') || args.includes('--force')) && (pathspecs.length > 0 || forceAll)) {
227
+ const ignoredCmd = ['ls-files', '--others', '--ignored', '--exclude-standard', '-z'];
228
+ if (pathspecs.length > 0) ignoredCmd.push('--', ...pathspecs);
229
+ paths.push(...parseNullPaths(spawnSync('git', ignoredCmd, { cwd: repoRoot, encoding: 'utf8' })));
230
+ }
231
+ return [...new Set(paths)];
232
+ }
233
+ if (subcommand !== 'commit') return [];
234
+
235
+ const commands = [];
236
+ const commitValueOptions = new Set(['m', 'F', 'C', 'c', 't', 'S']);
237
+ const commitAll = args.includes('--all') || hasShortOption(args, 'a', commitValueOptions);
238
+ if (pathspecs.length > 0) {
239
+ const hasHead = spawnSync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).status === 0;
240
+ if (hasHead) commands.push(['diff', 'HEAD', '--name-only', '--diff-filter=ACMR', '-z', '--', ...pathspecs]);
241
+ else commands.push(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z', '--', ...pathspecs]);
242
+ if (args.includes('-i') || args.includes('--include')) {
243
+ commands.push(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z']);
244
+ }
245
+ } else if (commitAll) {
246
+ // commit -a replaces tracked index entries with worktree state. Comparing
247
+ // HEAD directly to the worktree models that final commit and avoids a
248
+ // false deny when a staged prompt change was subsequently reverted.
249
+ commands.push(['diff', 'HEAD', '--name-only', '--diff-filter=ACMR', '-z']);
250
+ } else {
251
+ commands.push(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z']);
252
+ }
253
+ return [...new Set(commands.flatMap(cmd => parseNullPaths(spawnSync('git', cmd, { cwd: repoRoot, encoding: 'utf8' }))))];
254
+ } catch {
255
+ return [];
256
+ }
257
+ }
258
+
259
+ export function listStagedPaths(repoRoot) {
260
+ const result = spawnSync('git', ['diff', '--cached', '--name-only', '--no-renames', '-z'], {
261
+ cwd: repoRoot,
262
+ encoding: 'utf8',
263
+ });
264
+ if (result.error || result.status !== 0) {
265
+ throw new Error(`Cannot inspect staged files: ${result.error?.message || result.stderr.trim() || 'git diff failed'}`);
266
+ }
267
+ return result.stdout.split('\0').filter(Boolean);
268
+ }
269
+
270
+ export function assertGitIndex(repoRoot, expectedPaths = []) {
271
+ const actual = [...new Set(listStagedPaths(repoRoot))].sort();
272
+ const expected = [...new Set(expectedPaths)].sort();
273
+ if (actual.length === expected.length && actual.every((item, index) => item === expected[index])) {
274
+ return actual;
275
+ }
276
+
277
+ const expectedLabel = expected.length > 0 ? expected.join(', ') : 'empty';
278
+ const actualLabel = actual.length > 0 ? actual.join(', ') : 'empty';
279
+ throw new Error(`Git index mismatch (expected: ${expectedLabel}; staged: ${actualLabel}). Commit or unstage existing work before releasing.`);
69
280
  }
70
281
 
71
282
  export function gitMv(source, target, repoRoot) {
@@ -91,7 +302,7 @@ export function gitMv(source, target, repoRoot) {
91
302
  return { status: result.status, stderr: result.stderr };
92
303
  }
93
304
 
94
- function isTracked(source, repoRoot) {
305
+ export function isTracked(source, repoRoot) {
95
306
  const relSource = path.isAbsolute(source) ? path.relative(repoRoot, source) : source;
96
307
  const result = spawnSync('git', ['ls-files', '--error-unmatch', '--', relSource], {
97
308
  cwd: repoRoot,
@@ -100,6 +311,312 @@ function isTracked(source, repoRoot) {
100
311
  return result.status === 0;
101
312
  }
102
313
 
314
+ export function gitIndexLocations(repoRoot) {
315
+ const result = spawnSync('git', ['rev-parse', '--git-dir'], { cwd: repoRoot, encoding: 'utf8' });
316
+ if (result.error || result.status !== 0) throw new Error(`Could not locate Git directory: ${result.error?.message || result.stderr.trim()}`);
317
+ const gitDir = path.resolve(repoRoot, result.stdout.trim());
318
+ const indexPath = process.env.GIT_INDEX_FILE ? path.resolve(repoRoot, process.env.GIT_INDEX_FILE) : path.join(gitDir, 'index');
319
+ return { gitDir, indexPath, indexDir: path.dirname(indexPath), lockPath: `${indexPath}.lock` };
320
+ }
321
+
322
+ function generationFromBytes(bytes, indexPath, mode) {
323
+ return { exists: true, hash: createHash('sha256').update(bytes).digest('hex'), size: bytes.length, content: bytes.toString('base64'), mode, indexPath };
324
+ }
325
+
326
+ function captureIndexPath(indexPath) {
327
+ if (!existsSync(indexPath)) return { exists: false, hash: null, size: 0, content: null, mode: null, indexPath };
328
+ const stat = lstatSync(indexPath);
329
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Selected Git index is not a regular file: ${indexPath}`);
330
+ return generationFromBytes(readFileSync(indexPath), indexPath, stat.mode & 0o7777);
331
+ }
332
+
333
+ export function captureGitIndexGeneration(repoRoot) {
334
+ return captureIndexPath(gitIndexLocations(repoRoot).indexPath);
335
+ }
336
+
337
+ export function sameGitIndexGeneration(left, right) {
338
+ return Boolean(left && right && left.indexPath === right.indexPath && left.exists === right.exists
339
+ && left.hash === right.hash && left.size === right.size && left.mode === right.mode);
340
+ }
341
+
342
+ function fsyncIndexDirectory(directory, testHooks, reason) {
343
+ testHooks?.beforeGitIndexDirectoryFsync?.({ directory, reason });
344
+ const dirFd = openSync(directory, 'r');
345
+ try { fsyncSync(dirFd); } catch (err) { if (!['EINVAL', 'ENOTSUP', 'EBADF', 'EISDIR', 'EPERM'].includes(err?.code)) throw err; } finally { closeSync(dirFd); }
346
+ }
347
+
348
+ function writeGeneration(filePath, generation, mode = generation.mode ?? (0o666 & ~process.umask())) {
349
+ const bytes = generation.exists ? Buffer.from(generation.content, 'base64') : Buffer.alloc(0);
350
+ if (generation.exists && (bytes.length !== generation.size || createHash('sha256').update(bytes).digest('hex') !== generation.hash)) throw new Error('Invalid captured Git index generation.');
351
+ const fd = openSync(filePath, 'wx', mode);
352
+ try { writeFileSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); }
353
+ }
354
+
355
+ function describePrepared(preparedPath, generation) {
356
+ const stat = lstatSync(preparedPath);
357
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Prepared Git index is unsafe: ${preparedPath}`);
358
+ const bytes = readFileSync(preparedPath);
359
+ return {
360
+ path: preparedPath,
361
+ dev: stat.dev,
362
+ ino: stat.ino,
363
+ mode: stat.mode & 0o7777,
364
+ size: bytes.length,
365
+ hash: createHash('sha256').update(bytes).digest('hex'),
366
+ generation,
367
+ };
368
+ }
369
+
370
+ function preparedMatches(prepared) {
371
+ try {
372
+ const stat = lstatSync(prepared.path);
373
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.dev !== prepared.dev || stat.ino !== prepared.ino || (stat.mode & 0o7777) !== prepared.mode || stat.size !== prepared.size) return false;
374
+ return createHash('sha256').update(readFileSync(prepared.path)).digest('hex') === prepared.hash;
375
+ } catch { return false; }
376
+ }
377
+
378
+ function unlinkPrepared(prepared, testHooks = null, reason = 'prepared-delete') {
379
+ if (prepared && preparedMatches(prepared)) {
380
+ unlinkSync(prepared.path);
381
+ fsyncIndexDirectory(path.dirname(prepared.path), testHooks, reason);
382
+ }
383
+ }
384
+
385
+ function prepareMoveIndex(source, target, repoRoot, before, options = {}) {
386
+ const testHooks = options.testHooks ?? {};
387
+ const paths = [source, target].map(candidate => path.isAbsolute(candidate) ? path.relative(repoRoot, candidate) : candidate);
388
+ assertSafeGitPaths(paths);
389
+ const { indexDir } = gitIndexLocations(repoRoot);
390
+ const preparedPath = path.join(indexDir, `.dotmd-index-${process.pid}-${randomUUID()}`);
391
+ const artifactPath = options.artifactPath ?? preparedPath;
392
+ writeGeneration(artifactPath, before);
393
+ let seed = { ...describePrepared(artifactPath, before), state: 'preparing', tempPath: preparedPath, work: null };
394
+ testHooks.afterGitIndexArtifact?.({ before, prepared: seed });
395
+ if (artifactPath !== preparedPath && before.exists) {
396
+ try { linkSync(artifactPath, preparedPath); }
397
+ catch (err) {
398
+ if (err?.code !== 'EXDEV') throw err;
399
+ writeGeneration(preparedPath, before);
400
+ seed = { ...seed, work: describePrepared(preparedPath, before) };
401
+ testHooks.afterGitIndexWorkSeed?.({ before, prepared: seed });
402
+ }
403
+ fsyncIndexDirectory(indexDir, testHooks, 'working-index-link');
404
+ }
405
+ testHooks.beforeGitIndexPrepare?.({ preparedPath, before, prepared: seed });
406
+ const env = { ...process.env, GIT_INDEX_FILE: preparedPath };
407
+ const result = spawnSync('git', ['add', '-A', '--', paths[1]], {
408
+ cwd: repoRoot,
409
+ encoding: 'utf8',
410
+ env,
411
+ });
412
+ let preCheckpointError = null;
413
+ try { testHooks.afterGitIndexSubprocessBeforeCheckpoint?.({ preparedPath, before, prepared: seed, step: 'target-added', result }); }
414
+ catch (err) { preCheckpointError = err; }
415
+ fsyncIndexDirectory(indexDir, testHooks, 'working-index-step');
416
+ const prepareStep = existsSync(preparedPath) ? { ...seed, work: describePrepared(preparedPath, captureIndexPath(preparedPath)) } : seed;
417
+ if (prepareStep.work) prepareStep.work.generation.indexPath = before.indexPath;
418
+ testHooks.afterGitIndexPrepareStep?.({ preparedPath, before, prepared: prepareStep, step: 'target-added' });
419
+ if (preCheckpointError) throw preCheckpointError;
420
+ if (result.error || result.status !== 0) {
421
+ throw new Error(`Could not stage moved document: ${result.error?.message || result.stderr.trim() || 'git add failed'}`);
422
+ }
423
+ testHooks.duringGitIndexPrepare?.({ preparedPath, before, prepared: prepareStep, step: 'target-added' });
424
+ const removed = spawnSync('git', ['update-index', '--remove', '--', paths[0]], { cwd: repoRoot, encoding: 'utf8', env });
425
+ preCheckpointError = null;
426
+ try { testHooks.afterGitIndexSubprocessBeforeCheckpoint?.({ preparedPath, before, prepared: prepareStep, step: 'source-removed', result: removed }); }
427
+ catch (err) { preCheckpointError = err; }
428
+ fsyncIndexDirectory(indexDir, testHooks, 'working-index-step');
429
+ const removedStep = existsSync(preparedPath) ? { ...seed, work: describePrepared(preparedPath, captureIndexPath(preparedPath)) } : prepareStep;
430
+ if (removedStep.work) removedStep.work.generation.indexPath = before.indexPath;
431
+ testHooks.afterGitIndexPrepareStep?.({ preparedPath, before, prepared: removedStep, step: 'source-removed' });
432
+ if (preCheckpointError) throw preCheckpointError;
433
+ if (removed.error || removed.status !== 0) {
434
+ throw new Error(`Could not stage moved document source removal: ${removed.error?.message || removed.stderr.trim() || 'git update-index failed'}`);
435
+ }
436
+ if (before.exists && (lstatSync(preparedPath).mode & 0o7777) !== before.mode) {
437
+ chmodSync(preparedPath, before.mode);
438
+ const fd = openSync(preparedPath, 'r+');
439
+ try { fsyncSync(fd); } finally { closeSync(fd); }
440
+ }
441
+ const generation = captureIndexPath(preparedPath);
442
+ generation.indexPath = before.indexPath;
443
+ const work = describePrepared(preparedPath, generation);
444
+ if (artifactPath !== preparedPath) {
445
+ writeFileSync(artifactPath, readFileSync(preparedPath));
446
+ chmodSync(artifactPath, generation.mode);
447
+ const artifactFd = openSync(artifactPath, 'r+');
448
+ try { fsyncSync(artifactFd); } finally { closeSync(artifactFd); }
449
+ }
450
+ const prepared = { ...describePrepared(artifactPath, generation), state: 'prepared', tempPath: preparedPath, work };
451
+ testHooks.afterGitIndexPrepared?.({ preparedPath, before, generation, prepared });
452
+ return prepared;
453
+ }
454
+
455
+ function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks = {}) {
456
+ const { indexPath, indexDir, lockPath } = gitIndexLocations(repoRoot);
457
+ if (expected.indexPath !== indexPath || desired.indexPath !== indexPath) throw new Error('Selected Git index changed since the transaction snapshot; recovery refused to target a different index.');
458
+ if (!prepared || prepared.state !== 'prepared' || path.dirname(prepared.tempPath) !== indexDir || !path.basename(prepared.tempPath).startsWith('.dotmd-index-') || !preparedMatches(prepared)) throw new Error('Prepared Git index ownership could not be verified.');
459
+ if ((desired.exists && (prepared.hash !== desired.hash || prepared.size !== desired.size || prepared.mode !== desired.mode)) || (!desired.exists && prepared.size !== 0)) throw new Error('Prepared Git index artifact does not match the desired generation.');
460
+ const publication = prepared.work ?? prepared;
461
+ if (!preparedMatches(publication) || (desired.exists && (publication.hash !== desired.hash || publication.size !== desired.size || publication.mode !== desired.mode)) || (!desired.exists && publication.size !== 0)) throw new Error('Selected-directory Git working index does not match the desired generation.');
462
+ let lockOwned = false;
463
+ try {
464
+ linkSync(publication.path, lockPath);
465
+ lockOwned = true;
466
+ fsyncIndexDirectory(indexDir, testHooks, 'lock-acquisition');
467
+ } catch (err) {
468
+ if (lockOwned) {
469
+ try {
470
+ const lock = lstatSync(lockPath);
471
+ if (lock.dev === publication.dev && lock.ino === publication.ino) {
472
+ unlinkSync(lockPath);
473
+ fsyncIndexDirectory(indexDir, null, 'lock-acquisition-rollback');
474
+ }
475
+ } catch { /* retain original durability error */ }
476
+ }
477
+ if (err?.code === 'EEXIST') throw new Error('Git index is locked by another process; transaction index publication was not attempted.');
478
+ throw err;
479
+ }
480
+ let published = false;
481
+ try {
482
+ testHooks.afterGitIndexLock?.({ lockPath, expected, desired });
483
+ const current = captureIndexPath(indexPath);
484
+ if (!sameGitIndexGeneration(current, expected)) throw new Error('Git index changed before transaction publication; current staging was preserved.');
485
+ testHooks.afterGitIndexCompare?.({ lockPath, current, desired });
486
+ if (desired.exists) {
487
+ renameSync(lockPath, indexPath);
488
+ lockOwned = false;
489
+ } else {
490
+ if (existsSync(indexPath)) unlinkSync(indexPath);
491
+ const lock = lstatSync(lockPath);
492
+ if (!lock.isFile() || lock.isSymbolicLink() || lock.dev !== publication.dev || lock.ino !== publication.ino) throw new Error('Transaction-owned Git index lock was replaced before deletion.');
493
+ unlinkSync(lockPath);
494
+ lockOwned = false;
495
+ }
496
+ published = true;
497
+ fsyncIndexDirectory(indexDir, testHooks, 'publication');
498
+ testHooks.afterGitIndexPublication?.({ indexPath, desired });
499
+ return desired;
500
+ } finally {
501
+ if (!published && lockOwned) {
502
+ try {
503
+ const lock = lstatSync(lockPath);
504
+ if (lock.dev === publication.dev && lock.ino === publication.ino) {
505
+ unlinkSync(lockPath);
506
+ fsyncIndexDirectory(indexDir, testHooks, 'failed-lock-delete');
507
+ }
508
+ } catch (err) {
509
+ if (err?.code !== 'ENOENT') throw err;
510
+ }
511
+ }
512
+ }
513
+ }
514
+
515
+ export function stageMovePathsCas(source, target, repoRoot, before, options = {}) {
516
+ const prepared = prepareMoveIndex(source, target, repoRoot, before, options);
517
+ try { return publishIndexGeneration(repoRoot, before, prepared.generation, prepared, options.testHooks); }
518
+ finally {
519
+ if (prepared.work && prepared.work.path !== prepared.path) unlinkPrepared(prepared.work, options.testHooks, 'working-index-delete');
520
+ unlinkPrepared(prepared, options.testHooks);
521
+ }
522
+ }
523
+
524
+ export function restoreGitIndexCas(before, ownedAfter, repoRoot, options = {}) {
525
+ const { indexDir } = gitIndexLocations(repoRoot);
526
+ const preparedPath = options.artifactPath ?? path.join(indexDir, `.dotmd-index-restore-${process.pid}-${randomUUID()}`);
527
+ let artifact = null;
528
+ try {
529
+ writeGeneration(preparedPath, before);
530
+ const prepared = before;
531
+ const tempPath = path.join(indexDir, `.dotmd-index-restore-${process.pid}-${randomUUID()}`);
532
+ artifact = { ...describePrepared(preparedPath, prepared), state: 'preparing', tempPath, work: null };
533
+ options.testHooks?.afterGitRestoreArtifact?.({ before, ownedAfter, prepared: artifact });
534
+ writeGeneration(tempPath, before);
535
+ const work = describePrepared(tempPath, prepared);
536
+ artifact = { ...artifact, state: 'prepared', work };
537
+ options.testHooks?.afterGitRestorePrepared?.({ before, ownedAfter, preparedPath, prepared: artifact });
538
+ return publishIndexGeneration(repoRoot, ownedAfter, prepared, artifact, options.testHooks);
539
+ } finally {
540
+ if (artifact?.work) unlinkPrepared(artifact.work, options.testHooks, 'restore-working-index-delete');
541
+ unlinkPrepared(artifact, options.testHooks, 'restore-prepared-delete');
542
+ }
543
+ }
544
+
545
+ export function reclaimPreparedGitIndex(manifestGitIndex, repoRoot, options = {}) {
546
+ const prepared = manifestGitIndex?.prepared;
547
+ if (!prepared) return { cleaned: false, retainedPaths: [] };
548
+ const retainedPaths = [];
549
+ const { indexPath, indexDir, lockPath } = gitIndexLocations(repoRoot);
550
+ if (manifestGitIndex.before?.indexPath !== indexPath || prepared.generation?.indexPath !== indexPath) throw new Error('Recovery environment selects a different Git index than the abandoned transaction.');
551
+ if (path.dirname(prepared.tempPath) !== indexDir || !path.basename(prepared.tempPath).startsWith('.dotmd-index-')) throw new Error('Abandoned prepared Git index path is unsafe.');
552
+ if (!existsSync(prepared.path)) {
553
+ if (existsSync(lockPath)) throw new Error('Git index lock exists without its recorded prepared inode; it was preserved.');
554
+ if (existsSync(prepared.tempPath)) retainedPaths.push(prepared.tempPath);
555
+ return { cleaned: false, retainedPaths };
556
+ }
557
+ if (!preparedMatches(prepared)) throw new Error('Abandoned prepared Git index ownership could not be verified.');
558
+ if (existsSync(prepared.tempPath)) {
559
+ const expectedWork = prepared.work ?? { ...prepared, path: prepared.tempPath };
560
+ if (!preparedMatches(expectedWork)) {
561
+ if (prepared.state !== 'preparing') throw new Error('Abandoned Git working index is foreign or unverified; it was preserved.');
562
+ retainedPaths.push(prepared.tempPath);
563
+ } else {
564
+ unlinkPrepared(expectedWork, options.testHooks, 'recovery-working-index-delete');
565
+ }
566
+ }
567
+ if (existsSync(lockPath)) {
568
+ const lock = lstatSync(lockPath);
569
+ const publication = prepared.work ?? prepared;
570
+ if (!lock.isFile() || lock.isSymbolicLink() || lock.dev !== publication.dev || lock.ino !== publication.ino) throw new Error('Git index lock is foreign; it was preserved.');
571
+ unlinkSync(lockPath);
572
+ fsyncIndexDirectory(indexDir, options.testHooks, 'recovery-lock-delete');
573
+ }
574
+ unlinkPrepared(prepared, options.testHooks, 'recovery-prepared-delete');
575
+ return { cleaned: true, retainedPaths };
576
+ }
577
+
578
+ export function stageMovePaths(source, target, repoRoot) {
579
+ const before = captureGitIndexGeneration(repoRoot);
580
+ return stageMovePathsCas(source, target, repoRoot, before);
581
+ }
582
+
583
+ // Legacy selected-path helpers remain read-only compatibility surfaces.
584
+ export function captureGitIndexPaths(paths, repoRoot) {
585
+ const relative = paths.map(candidate => path.isAbsolute(candidate) ? path.relative(repoRoot, candidate) : candidate);
586
+ assertSafeGitPaths(relative);
587
+ const result = spawnSync('git', ['ls-files', '--stage', '-z', '--', ...relative], { cwd: repoRoot, encoding: 'utf8' });
588
+ if (result.error || result.status !== 0) throw new Error(`Could not snapshot Git index: ${result.error?.message || result.stderr.trim()}`);
589
+ const records = result.stdout.split('\0').filter(Boolean);
590
+ return { paths: relative, records, identity: createHash('sha256').update(result.stdout).digest('hex') };
591
+ }
592
+
593
+ export function restoreGitIndexPathsCas(before, ownedAfter, repoRoot) {
594
+ const current = captureGitIndexPaths(before.paths, repoRoot);
595
+ if (!before || !ownedAfter || current.identity !== ownedAfter.identity) {
596
+ throw new Error(`Git index changed after transaction staging for: ${before.paths.join(', ')}; current staging was preserved.`);
597
+ }
598
+ restoreGitIndexPaths(before, repoRoot);
599
+ }
600
+
601
+ export function restoreGitIndexPaths(snapshot, repoRoot) {
602
+ assertSafeGitPaths(snapshot.paths);
603
+ const firstHash = snapshot.records[0]?.match(/^\d+ ([0-9a-f]+) /)?.[1];
604
+ const removals = snapshot.paths.map(filePath => `0 ${'0'.repeat(firstHash?.length ?? 40)}\t${filePath}\n`);
605
+ const records = snapshot.records.map(record => {
606
+ const match = /^(\d+) ([0-9a-f]+) (\d+)\t([\s\S]+)$/.exec(record);
607
+ if (!match) throw new Error(`Unexpected Git index record: ${record}`);
608
+ return `${match[1]} ${match[2]} ${match[3]}\t${match[4]}\n`;
609
+ });
610
+ const restored = spawnSync('git', ['update-index', '--index-info'], {
611
+ cwd: repoRoot,
612
+ encoding: 'utf8',
613
+ input: [...removals, ...records].join(''),
614
+ });
615
+ if (restored.error || restored.status !== 0) {
616
+ throw new Error(`Could not restore Git index: ${restored.error?.message || restored.stderr.trim() || 'git update-index failed'}`);
617
+ }
618
+ }
619
+
103
620
  export function gitDiffSince(relPath, sinceDate, repoRoot, opts = {}) {
104
621
  ensureGit();
105
622
  // Find the last commit at or before sinceDate