amalgm 0.1.239 → 0.1.241

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 (87) hide show
  1. package/lib/shared-realtime-tunnel.js +18 -4
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
  4. package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
  5. package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
  6. package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
  7. package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
  8. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
  9. package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
  10. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
  11. package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
  12. package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
  13. package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
  14. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
  15. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
  16. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
  17. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
  18. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
  19. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
  20. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
  21. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
  22. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
  23. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
  24. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
  25. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
  26. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
  27. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
  28. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
  29. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
  30. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
  31. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
  33. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
  34. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
  35. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
  36. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
  37. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
  38. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
  39. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
  40. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
  41. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
  42. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
  43. package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
  44. package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
  45. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
  46. package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
  47. package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
  48. package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
  49. package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
  50. package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
  51. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
  52. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  53. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  54. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
  55. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
  56. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
  57. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
  58. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
  59. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
  60. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
  61. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
  63. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
  64. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
  65. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
  66. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
  67. package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
  68. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
  69. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
  70. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
  71. package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
  72. package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
  73. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
  74. package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
  75. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
  76. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
  77. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
  78. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
  79. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
  80. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
  81. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
  82. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
  83. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
  84. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
  85. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
  86. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
  87. package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ const CHUNK_BYTES = 512 * 1024;
6
+
7
+ function sha256(bytes) {
8
+ return crypto.createHash('sha256').update(bytes).digest('hex');
9
+ }
10
+
11
+ function describeBytes(bytesInput) {
12
+ const bytes = Buffer.isBuffer(bytesInput) ? bytesInput : Buffer.from(bytesInput);
13
+ const chunks = [];
14
+ for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
15
+ const part = bytes.subarray(offset, Math.min(offset + CHUNK_BYTES, bytes.length));
16
+ chunks.push({ sha256: sha256(part), bytes: part.length });
17
+ }
18
+ return chunks;
19
+ }
20
+
21
+ function manifestChecksum(chunks) {
22
+ return sha256(Buffer.from(JSON.stringify(
23
+ chunks.map((chunk) => ({ sha256: chunk.sha256, bytes: chunk.bytes })),
24
+ )));
25
+ }
26
+
27
+ module.exports = {
28
+ CHUNK_BYTES,
29
+ describeBytes,
30
+ manifestChecksum,
31
+ sha256,
32
+ };
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+
7
+ function syncDirectory(directory) {
8
+ const descriptor = fs.openSync(directory, 'r');
9
+ try {
10
+ fs.fsyncSync(descriptor);
11
+ } finally {
12
+ fs.closeSync(descriptor);
13
+ }
14
+ }
15
+
16
+ /**
17
+ * Durable sync state is replaced, never edited in place. After a process
18
+ * death, readers therefore see either the complete old value or the complete
19
+ * new value.
20
+ */
21
+ function writeFile(file, content, mode = 0o600) {
22
+ fs.mkdirSync(path.dirname(file), { recursive: true });
23
+ const temporary = path.join(
24
+ path.dirname(file),
25
+ `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`,
26
+ );
27
+ let descriptor = fs.openSync(temporary, 'wx', mode);
28
+ try {
29
+ fs.writeFileSync(descriptor, content);
30
+ fs.fsyncSync(descriptor);
31
+ fs.closeSync(descriptor);
32
+ descriptor = null;
33
+ fs.renameSync(temporary, file);
34
+ syncDirectory(path.dirname(file));
35
+ } finally {
36
+ if (descriptor !== null) fs.closeSync(descriptor);
37
+ if (fs.existsSync(temporary)) fs.rmSync(temporary, { force: true });
38
+ }
39
+ }
40
+
41
+ function writeJson(file, value, { pretty = false } = {}) {
42
+ writeFile(file, `${JSON.stringify(value, null, pretty ? 2 : 0)}\n`);
43
+ }
44
+
45
+ module.exports = { writeFile, writeJson };
@@ -18,6 +18,7 @@
18
18
  */
19
19
 
20
20
  const fs = require('fs');
21
+ const os = require('os');
21
22
  const path = require('path');
22
23
  const { execFile } = require('child_process');
23
24
  const { promisify } = require('util');
@@ -46,14 +47,15 @@ class GitError extends Error {
46
47
  }
47
48
  }
48
49
 
49
- async function git(args, cwd, { okCodes = [0] } = {}) {
50
+ async function git(args, cwd, { okCodes = [0], env = {} } = {}) {
50
51
  try {
51
- const { stdout } = await execFileAsync('git', args, {
52
+ const options = {
52
53
  cwd,
53
- env: { ...process.env, ...GIT_ENV },
54
+ env: { ...process.env, ...GIT_ENV, ...env },
54
55
  timeout: 15_000,
55
56
  maxBuffer: 128 * 1024 * 1024,
56
- });
57
+ };
58
+ const { stdout } = await execFileAsync('git', args, options);
57
59
  return { stdout, exitCode: 0 };
58
60
  } catch (error) {
59
61
  const exitCode = typeof error.code === 'number' ? error.code : 1;
@@ -64,6 +66,189 @@ async function git(args, cwd, { okCodes = [0] } = {}) {
64
66
  }
65
67
  }
66
68
 
69
+ function changeIdentity(file) {
70
+ try {
71
+ const stat = fs.lstatSync(file, { bigint: true });
72
+ return {
73
+ dev: stat.dev.toString(),
74
+ ino: stat.ino.toString(),
75
+ mode: stat.mode.toString(),
76
+ size: stat.size.toString(),
77
+ mtimeNs: stat.mtimeNs.toString(),
78
+ ctimeNs: stat.ctimeNs.toString(),
79
+ };
80
+ } catch (error) {
81
+ if (error?.code === 'ENOENT') return null;
82
+ throw error;
83
+ }
84
+ }
85
+
86
+ function sameIdentity(left, right) {
87
+ return JSON.stringify(left) === JSON.stringify(right);
88
+ }
89
+
90
+ async function indexPath(worktreeRoot) {
91
+ const { stdout } = await git(
92
+ ['-C', worktreeRoot, 'rev-parse', '--git-path', 'index'],
93
+ worktreeRoot,
94
+ );
95
+ const value = stdout.trim();
96
+ return path.isAbsolute(value) ? value : path.resolve(worktreeRoot, value);
97
+ }
98
+
99
+ function worktreePathIdentity(worktreeRoot, relativePath) {
100
+ const parts = relativePath.split('/');
101
+ let current = worktreeRoot;
102
+ for (let index = 0; index < parts.length; index += 1) {
103
+ current = path.join(current, parts[index]);
104
+ let stat;
105
+ try {
106
+ stat = fs.lstatSync(current, { bigint: true });
107
+ } catch (error) {
108
+ if (error?.code === 'ENOENT') return { kind: 'absent' };
109
+ throw error;
110
+ }
111
+ if (index < parts.length - 1 && !stat.isDirectory()) {
112
+ return { kind: 'unsafe-parent' };
113
+ }
114
+ if (index === parts.length - 1) {
115
+ return {
116
+ kind: stat.isSymbolicLink()
117
+ ? 'symlink'
118
+ : stat.isFile()
119
+ ? 'file'
120
+ : 'other',
121
+ dev: stat.dev.toString(),
122
+ ino: stat.ino.toString(),
123
+ mode: stat.mode.toString(),
124
+ size: stat.size.toString(),
125
+ mtimeNs: stat.mtimeNs.toString(),
126
+ ctimeNs: stat.ctimeNs.toString(),
127
+ ...(stat.isSymbolicLink() ? { target: fs.readlinkSync(current) } : {}),
128
+ };
129
+ }
130
+ }
131
+ return { kind: 'absent' };
132
+ }
133
+
134
+ function worktreeIdentity(worktreeRoot, paths) {
135
+ return Object.fromEntries(
136
+ paths.map((relativePath) => [
137
+ relativePath,
138
+ worktreePathIdentity(worktreeRoot, relativePath),
139
+ ]),
140
+ );
141
+ }
142
+
143
+ async function ignoredNamespacePaths(worktreeRoot) {
144
+ const { stdout } = await git(
145
+ [
146
+ '-C', worktreeRoot,
147
+ 'ls-files',
148
+ '--others',
149
+ '--ignored',
150
+ '--exclude-standard',
151
+ '--directory',
152
+ '-z',
153
+ ],
154
+ worktreeRoot,
155
+ );
156
+ return stdout.split('\0').filter(Boolean).map((entry) => (
157
+ entry.endsWith('/') ? entry.slice(0, -1) : entry
158
+ ));
159
+ }
160
+
161
+ function namespacePathIgnored(relativePath, ignoredPaths) {
162
+ return ignoredPaths.some((ignored) => (
163
+ relativePath === ignored || relativePath.startsWith(`${ignored}/`)
164
+ ));
165
+ }
166
+
167
+ function directoryIdentity(absolutePath) {
168
+ const stat = fs.lstatSync(absolutePath, { bigint: true });
169
+ if (!stat.isDirectory()) return null;
170
+ return {
171
+ dev: stat.dev.toString(),
172
+ ino: stat.ino.toString(),
173
+ mode: stat.mode.toString(),
174
+ mtimeNs: stat.mtimeNs.toString(),
175
+ ctimeNs: stat.ctimeNs.toString(),
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Git's untracked listing names files, not the namespace in which new files
181
+ * can appear. Capture the complete non-ignored directory membership before
182
+ * and after the byte reads so delete/list/recreate ABA cannot hide a file.
183
+ * Ignored subtrees are pruned at their boundary, so dependency churn never
184
+ * blocks publication.
185
+ */
186
+ async function visibleNamespaceIdentity(worktreeRoot) {
187
+ const ignored = await ignoredNamespacePaths(worktreeRoot);
188
+ const identity = {};
189
+ let directories = [{ absolute: worktreeRoot, relative: '' }];
190
+ while (directories.length > 0) {
191
+ const next = [];
192
+ for (const directory of directories) {
193
+ const entries = [];
194
+ try {
195
+ const before = directoryIdentity(directory.absolute);
196
+ if (!before) return { state: 'moving' };
197
+ for (const dirent of fs.readdirSync(directory.absolute, { withFileTypes: true })) {
198
+ if (directory.relative === '' && dirent.name === '.git') continue;
199
+ const relative = directory.relative
200
+ ? `${directory.relative}/${dirent.name}`
201
+ : dirent.name;
202
+ if (namespacePathIgnored(relative, ignored)) continue;
203
+ const absolute = path.join(directory.absolute, dirent.name);
204
+ const stat = fs.lstatSync(absolute, { bigint: true });
205
+ const isDirectory = stat.isDirectory();
206
+ const kind = isDirectory
207
+ ? 'directory'
208
+ : stat.isSymbolicLink()
209
+ ? 'symlink'
210
+ : stat.isFile()
211
+ ? 'file'
212
+ : 'other';
213
+ entries.push({
214
+ name: dirent.name,
215
+ kind,
216
+ dev: stat.dev.toString(),
217
+ ino: stat.ino.toString(),
218
+ ...(isDirectory ? {
219
+ mode: stat.mode.toString(),
220
+ mtimeNs: stat.mtimeNs.toString(),
221
+ ctimeNs: stat.ctimeNs.toString(),
222
+ } : {
223
+ mode: stat.mode.toString(),
224
+ size: stat.size.toString(),
225
+ mtimeNs: stat.mtimeNs.toString(),
226
+ ctimeNs: stat.ctimeNs.toString(),
227
+ ...(stat.isSymbolicLink() ? { target: fs.readlinkSync(absolute) } : {}),
228
+ }),
229
+ });
230
+ if (isDirectory && !fs.existsSync(path.join(absolute, '.git'))) {
231
+ next.push({ absolute, relative });
232
+ }
233
+ }
234
+ const after = directoryIdentity(directory.absolute);
235
+ if (!after || !sameIdentity(before, after)) return { state: 'moving' };
236
+ identity[directory.relative] = {
237
+ directory: after,
238
+ entries: entries.sort((left, right) => left.name.localeCompare(right.name)),
239
+ };
240
+ } catch (error) {
241
+ if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
242
+ return { state: 'moving' };
243
+ }
244
+ throw error;
245
+ }
246
+ }
247
+ directories = next.sort((left, right) => left.relative.localeCompare(right.relative));
248
+ }
249
+ return { state: 'ready', value: identity };
250
+ }
251
+
67
252
  const NOT_A_REPO_RE = /not a git repository/i;
68
253
 
69
254
  /**
@@ -339,7 +524,16 @@ async function captureWorktreeDiff(worktreeRoot, { maxBytes = Infinity } = {}) {
339
524
  ['-C', worktreeRoot, 'ls-files', '--others', '--exclude-standard', '-z'],
340
525
  worktreeRoot,
341
526
  );
342
- for (const file of untracked.stdout.split('\0').filter(Boolean).sort()) {
527
+ const untrackedPaths = untracked.stdout.split('\0').filter(Boolean).sort();
528
+ if (untrackedPaths.some((file) => file.endsWith('/'))) {
529
+ return {
530
+ baseOid,
531
+ diff: null,
532
+ reason: 'unsupported-nested-repository',
533
+ bytes,
534
+ };
535
+ }
536
+ for (const file of untrackedPaths) {
343
537
  if (bytes > maxBytes) break;
344
538
  const creation = await git(
345
539
  ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--no-index', '--binary', '--full-index', '--', '/dev/null', file],
@@ -377,47 +571,207 @@ function checkpointCaptureResult({
377
571
  return { baseOid, indexDiff, worktreeDiff, bytes };
378
572
  }
379
573
 
380
- async function captureCheckpoint(worktreeRoot, { maxBytes = Infinity } = {}) {
574
+ async function captureCheckpointOnce(worktreeRoot, { maxBytes = Infinity } = {}) {
381
575
  const baseOid = await headOid(worktreeRoot);
382
576
  if (!baseOid) return { baseOid: null, indexDiff: null, worktreeDiff: null, reason: 'unborn' };
577
+ const sourceIndex = await indexPath(worktreeRoot);
578
+ const snapshotRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-index-snapshot-'));
579
+ const snapshotIndex = path.join(snapshotRoot, 'index');
580
+ try {
581
+ const initialIndexIdentity = changeIdentity(sourceIndex);
582
+ if (!initialIndexIdentity) {
583
+ return {
584
+ baseOid,
585
+ indexDiff: null,
586
+ worktreeDiff: null,
587
+ reason: 'index-missing',
588
+ bytes: 0,
589
+ };
590
+ }
591
+ fs.copyFileSync(sourceIndex, snapshotIndex);
592
+ if (!sameIdentity(initialIndexIdentity, changeIdentity(sourceIndex))) {
593
+ return {
594
+ baseOid,
595
+ indexDiff: null,
596
+ worktreeDiff: null,
597
+ reason: 'observation-moved',
598
+ bytes: 0,
599
+ };
600
+ }
601
+ const snapshotEnv = { GIT_INDEX_FILE: snapshotIndex };
602
+ let stagedTree;
603
+ try {
604
+ stagedTree = (await git(
605
+ ['-C', worktreeRoot, 'write-tree'],
606
+ worktreeRoot,
607
+ { env: snapshotEnv },
608
+ )).stdout.trim();
609
+ } catch (error) {
610
+ if (
611
+ error instanceof GitError
612
+ && /unmerged|not uptodate|cannot write.*tree/i.test(error.stderr)
613
+ ) {
614
+ return {
615
+ baseOid,
616
+ indexDiff: null,
617
+ worktreeDiff: null,
618
+ reason: 'index-unmerged',
619
+ bytes: 0,
620
+ };
621
+ }
622
+ throw error;
623
+ }
624
+ const normalizedIndex = path.join(snapshotRoot, 'normalized-index');
625
+ const normalizedEnv = { GIT_INDEX_FILE: normalizedIndex };
626
+ await git(
627
+ ['-C', worktreeRoot, 'read-tree', stagedTree],
628
+ worktreeRoot,
629
+ { env: normalizedEnv },
630
+ );
631
+ const tracked = await git(
632
+ ['-C', worktreeRoot, 'ls-files', '-z'],
633
+ worktreeRoot,
634
+ { env: normalizedEnv },
635
+ );
636
+ const trackedPaths = tracked.stdout.split('\0').filter(Boolean);
637
+ const initialNamespace = await visibleNamespaceIdentity(worktreeRoot);
638
+ if (initialNamespace.state !== 'ready') {
639
+ return {
640
+ baseOid,
641
+ indexDiff: null,
642
+ worktreeDiff: null,
643
+ reason: 'observation-moved',
644
+ bytes: 0,
645
+ };
646
+ }
647
+ const untracked = await git(
648
+ ['-C', worktreeRoot, 'ls-files', '--others', '--exclude-standard', '-z'],
649
+ worktreeRoot,
650
+ { env: normalizedEnv },
651
+ );
652
+ const untrackedPaths = untracked.stdout.split('\0').filter(Boolean).sort();
653
+ if (untrackedPaths.some((file) => file.endsWith('/'))) {
654
+ return {
655
+ baseOid,
656
+ indexDiff: null,
657
+ worktreeDiff: null,
658
+ reason: 'unsupported-nested-repository',
659
+ bytes: 0,
660
+ };
661
+ }
662
+ const observedPaths = [...new Set([...trackedPaths, ...untrackedPaths])].sort();
663
+ const initialWorktreeIdentity = worktreeIdentity(worktreeRoot, observedPaths);
383
664
 
384
- const staged = await git(
385
- ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--cached', 'HEAD', '--binary', '--full-index'],
386
- worktreeRoot,
387
- );
388
- const unstaged = await git(
389
- ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--binary', '--full-index'],
390
- worktreeRoot,
391
- );
392
- const worktreeParts = [unstaged.stdout];
393
- let bytes = Buffer.byteLength(staged.stdout) + Buffer.byteLength(unstaged.stdout);
394
- const untracked = await git(
395
- ['-C', worktreeRoot, 'ls-files', '--others', '--exclude-standard', '-z'],
396
- worktreeRoot,
397
- );
398
- for (const file of untracked.stdout.split('\0').filter(Boolean).sort()) {
399
- if (bytes > maxBytes) break;
400
- const creation = await git(
401
- ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--no-index', '--binary', '--full-index', '--', '/dev/null', file],
665
+ const staged = await git(
666
+ ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', 'HEAD', stagedTree, '--binary', '--full-index'],
402
667
  worktreeRoot,
403
- { okCodes: [0, 1] },
668
+ { env: normalizedEnv },
404
669
  );
405
- worktreeParts.push(creation.stdout);
406
- bytes += Buffer.byteLength(creation.stdout);
670
+ const unstaged = await git(
671
+ ['-C', worktreeRoot, ...PATCH_ARGS, 'diff', '--binary', '--full-index'],
672
+ worktreeRoot,
673
+ { env: normalizedEnv },
674
+ );
675
+ const worktreeParts = [unstaged.stdout];
676
+ let bytes = Buffer.byteLength(staged.stdout) + Buffer.byteLength(unstaged.stdout);
677
+ for (const file of untrackedPaths) {
678
+ if (bytes > maxBytes) break;
679
+ const creation = await git(
680
+ [
681
+ '-C', worktreeRoot, ...PATCH_ARGS,
682
+ 'diff', '--no-index', '--binary', '--full-index', '--', '/dev/null', file,
683
+ ],
684
+ worktreeRoot,
685
+ { okCodes: [0, 1], env: normalizedEnv },
686
+ );
687
+ worktreeParts.push(creation.stdout);
688
+ bytes += Buffer.byteLength(creation.stdout);
689
+ }
690
+ const observedOid = await headOid(worktreeRoot);
691
+ const finalWorktreeIdentity = worktreeIdentity(worktreeRoot, observedPaths);
692
+ const finalUntracked = await git(
693
+ ['-C', worktreeRoot, 'ls-files', '--others', '--exclude-standard', '-z'],
694
+ worktreeRoot,
695
+ { env: normalizedEnv },
696
+ );
697
+ const finalNamespace = await visibleNamespaceIdentity(worktreeRoot);
698
+ if (
699
+ !sameIdentity(initialIndexIdentity, changeIdentity(sourceIndex))
700
+ || !sameIdentity(initialWorktreeIdentity, finalWorktreeIdentity)
701
+ || untracked.stdout !== finalUntracked.stdout
702
+ || finalNamespace.state !== 'ready'
703
+ || !sameIdentity(initialNamespace.value, finalNamespace.value)
704
+ ) {
705
+ return {
706
+ baseOid,
707
+ indexDiff: null,
708
+ worktreeDiff: null,
709
+ reason: 'observation-moved',
710
+ bytes,
711
+ };
712
+ }
713
+ return checkpointCaptureResult({
714
+ baseOid,
715
+ observedOid,
716
+ indexDiff: staged.stdout,
717
+ worktreeDiff: worktreeParts.join(''),
718
+ bytes,
719
+ maxBytes,
720
+ });
721
+ } finally {
722
+ fs.rmSync(snapshotRoot, { recursive: true, force: true });
407
723
  }
408
- const observedOid = await headOid(worktreeRoot);
409
- return checkpointCaptureResult({
410
- baseOid,
411
- observedOid,
412
- indexDiff: staged.stdout,
413
- worktreeDiff: worktreeParts.join(''),
414
- bytes,
415
- maxBytes,
416
- });
724
+ }
725
+
726
+ function stableCheckpointCapture(first, second) {
727
+ if (JSON.stringify(first) === JSON.stringify(second)) return second;
728
+ return {
729
+ baseOid: second?.baseOid ?? first?.baseOid ?? null,
730
+ indexDiff: null,
731
+ worktreeDiff: null,
732
+ reason: 'observation-moved',
733
+ bytes: Math.max(
734
+ Number.isFinite(first?.bytes) ? first.bytes : 0,
735
+ Number.isFinite(second?.bytes) ? second.bytes : 0,
736
+ ),
737
+ };
738
+ }
739
+
740
+ async function captureStableCheckpoint(read, attempts = 4) {
741
+ if (!Number.isInteger(attempts) || attempts < 2) {
742
+ throw new Error('checkpoint capture requires at least two observations');
743
+ }
744
+ let previous = await read();
745
+ let compared = previous;
746
+ for (let attempt = 1; attempt < attempts; attempt += 1) {
747
+ const current = await read();
748
+ compared = stableCheckpointCapture(previous, current);
749
+ if (compared.reason !== 'observation-moved') return compared;
750
+ previous = current;
751
+ }
752
+ return compared;
753
+ }
754
+
755
+ /**
756
+ * A checkpoint is a fact about HEAD, index, and worktree at one stable
757
+ * instant. Individual Git reads cannot lock all three layers together, so a
758
+ * capture is usable only after two consecutive complete captures are
759
+ * byte-identical. A bounded retry absorbs filesystem metadata settling after
760
+ * a save; alternating or continuously moving state still returns "moving."
761
+ * Mixed layers are never promoted to provenance.
762
+ */
763
+ async function captureCheckpoint(worktreeRoot, options = {}) {
764
+ return captureStableCheckpoint(
765
+ () => captureCheckpointOnce(worktreeRoot, options),
766
+ );
417
767
  }
418
768
 
419
769
  module.exports = {
420
- _test: { checkpointCaptureResult },
770
+ _test: {
771
+ captureStableCheckpoint,
772
+ checkpointCaptureResult,
773
+ stableCheckpointCapture,
774
+ },
421
775
  GitError,
422
776
  captureCheckpoint,
423
777
  captureWorktreeDiff,