amalgm 0.1.239 → 0.1.240

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 (86) hide show
  1. package/lib/shared-realtime-tunnel.js +6 -1
  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/state/attachments.js +40 -12
  6. package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
  7. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
  8. package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
  9. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
  10. package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
  11. package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
  12. package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
  13. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
  14. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
  15. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
  16. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
  17. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
  18. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
  19. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
  20. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
  21. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
  22. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
  23. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
  24. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
  25. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
  26. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
  27. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
  28. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
  29. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +8 -4
  30. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +114 -8
  31. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
  33. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
  34. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
  35. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
  36. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
  37. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
  38. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
  39. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
  40. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
  41. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
  42. package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
  43. package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
  44. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
  45. package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
  46. package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
  47. package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
  48. package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
  49. package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
  50. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
  51. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  52. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  53. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
  54. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
  55. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
  56. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
  57. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
  58. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
  59. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
  60. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
  61. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
  63. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
  64. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
  65. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
  66. package/runtime/scripts/amalgm-mcp/workspace/objects.js +119 -54
  67. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
  68. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
  69. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
  70. package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
  71. package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
  72. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
  73. package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
  74. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
  75. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
  76. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
  77. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
  78. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
  79. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
  80. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
  81. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
  82. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
  83. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
  84. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
  85. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
  86. package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
@@ -7,23 +7,47 @@
7
7
  * no shell, pinned cwd, no prompts, and config armor so repo-configured
8
8
  * programs (hooks, fsmonitor) can never run under an executed transition.
9
9
  *
10
- * The verbs are deliberately few and exact — fetch one branch from a
11
- * named remote, fast-forward to an exact commit, apply or reverse a
12
- * captured patch. Anything messier is a human act on one machine; the
13
- * rail carries its settled outcome. Every execution, success or failure,
14
- * lands as one line in a visible journal before control returns.
10
+ * The verbs are deliberately few and exact — import verified objects,
11
+ * move Git metadata with expected-old guards, and replace file/index
12
+ * state only when the observed local state is unchanged. Anything messier
13
+ * is a human act on one machine; the rail carries its settled outcome.
14
+ * Every execution, success or failure, lands as one line in a visible
15
+ * journal before control returns.
15
16
  */
16
17
 
17
18
  const fs = require('fs');
18
19
  const path = require('path');
19
- const { execFile } = require('child_process');
20
+ const crypto = require('crypto');
21
+ const { execFile, execFileSync } = require('child_process');
20
22
  const { promisify } = require('util');
21
23
  const { AMALGM_DIR } = require('../config');
24
+ const durable = require('./durable');
25
+ const nativeFileTransaction = require('./native-file-transaction/client');
22
26
 
23
27
  const execFileAsync = promisify(execFile);
24
28
 
25
29
  const JOURNAL_FILE = path.join(AMALGM_DIR, 'state', 'workspace-transitions.jsonl');
26
- const HYDRATION_DIR = path.join(AMALGM_DIR, 'state', 'repo-hydration');
30
+ const FILE_TRANSACTION_CONTRACT = 'workspace-file-transaction@4';
31
+ const GIT_METADATA_TRANSACTION_CONTRACT = 'workspace-git-metadata-transaction@1';
32
+ const GIT_WORKTREE_NAMESPACE_GUARD_CONTRACT = 'workspace-git-worktree-namespace-guard@2';
33
+ const APPLY_RECEIPT_DIRECTORY = /^\.amalgm-apply-[0-9a-f]{64}$/;
34
+ const APPLY_RECEIPT_EXCLUDE = `.amalgm-apply-${'[0-9a-f]'.repeat(64)}/`;
35
+
36
+ function processLifetime(pid) {
37
+ return nativeFileTransaction.processLifetime(pid);
38
+ }
39
+
40
+ const PROCESS_LIFETIME = processLifetime(process.pid);
41
+ if (!PROCESS_LIFETIME?.live) {
42
+ throw new Error('workspace transitions cannot identify their process lifetime');
43
+ }
44
+ const PROCESS_START_IDENTITY = PROCESS_LIFETIME.identity;
45
+
46
+ function guardOwnerIsActive(transaction) {
47
+ const owner = processLifetime(transaction.ownerPid);
48
+ if (owner === null) return true;
49
+ return owner.live && owner.identity === transaction.ownerStarted;
50
+ }
27
51
 
28
52
  const TRANSITION_ENV = {
29
53
  GIT_TERMINAL_PROMPT: '0',
@@ -54,14 +78,43 @@ function journal(entry) {
54
78
  }
55
79
  }
56
80
 
57
- async function run(action, worktreeRoot, args, { timeoutMs = 120_000, journalResult = true } = {}) {
81
+ function execFileWithInput(file, args, options, input) {
82
+ return new Promise((resolve, reject) => {
83
+ const child = execFile(file, args, options, (error, stdout, stderr) => {
84
+ if (error) {
85
+ error.stdout = stdout;
86
+ error.stderr = stderr;
87
+ reject(error);
88
+ return;
89
+ }
90
+ resolve({ stdout, stderr });
91
+ });
92
+ child.stdin.on('error', () => {});
93
+ child.stdin.end(input);
94
+ });
95
+ }
96
+
97
+ async function run(
98
+ action,
99
+ worktreeRoot,
100
+ args,
101
+ {
102
+ timeoutMs = 120_000,
103
+ journalResult = true,
104
+ input = null,
105
+ env = {},
106
+ } = {},
107
+ ) {
58
108
  try {
59
- const { stdout } = await execFileAsync('git', args, {
109
+ const options = {
60
110
  cwd: worktreeRoot,
61
- env: { ...process.env, ...TRANSITION_ENV },
111
+ env: { ...process.env, ...TRANSITION_ENV, ...env },
62
112
  timeout: timeoutMs,
63
113
  maxBuffer: 32 * 1024 * 1024,
64
- });
114
+ };
115
+ const { stdout } = input === null
116
+ ? await execFileAsync('git', args, options)
117
+ : await execFileWithInput('git', args, options, input);
65
118
  if (journalResult) journal({ action, worktree: worktreeRoot, args, ok: true });
66
119
  return stdout;
67
120
  } catch (error) {
@@ -73,14 +126,6 @@ async function run(action, worktreeRoot, args, { timeoutMs = 120_000, journalRes
73
126
  }
74
127
  }
75
128
 
76
- /** Bring one branch's objects and remote-tracking ref in from a named remote. */
77
- function fetchBranch(worktreeRoot, remote, branch) {
78
- return run('fetch-branch', worktreeRoot, [
79
- '-C', worktreeRoot, 'fetch', '--no-tags', remote,
80
- `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
81
- ]);
82
- }
83
-
84
129
  async function createBundle(worktreeRoot, oid, bundleFile, exportRef) {
85
130
  await run('prepare-bundle-ref', worktreeRoot, [
86
131
  '-C', worktreeRoot, 'update-ref', exportRef, oid,
@@ -128,60 +173,18 @@ async function initializeRepo(worktreeRoot, remote) {
128
173
  }
129
174
  }
130
175
 
131
- async function checkoutInitialCard(worktreeRoot, { branch, oid }) {
132
- await run('checkout-initial-card', worktreeRoot, branch
133
- ? ['-C', worktreeRoot, 'checkout', '-B', branch, oid]
134
- : ['-C', worktreeRoot, 'checkout', '--detach', oid], {
135
- timeoutMs: 60_000,
136
- });
137
- const after = await currentHead(worktreeRoot);
138
- if (after.branch !== (branch || null) || after.oid !== oid) {
139
- throw new TransitionError(
140
- 'checkout-initial-card',
176
+ async function currentHead(worktreeRoot) {
177
+ let oid = null;
178
+ try {
179
+ oid = (await run(
180
+ 'observe-head',
141
181
  worktreeRoot,
142
- `expected ${branch || '(detached)'}@${oid}, observed ${after.branch || '(detached)'}@${after.oid}`,
143
- );
144
- }
145
- }
146
-
147
- function holdInitialFiles(worktreeRoot) {
148
- const entries = fs.readdirSync(worktreeRoot).filter((entry) => entry !== '.git');
149
- if (!entries.length) return null;
150
- fs.mkdirSync(HYDRATION_DIR, { recursive: true });
151
- const holding = fs.mkdtempSync(path.join(HYDRATION_DIR, 'hold-'));
152
- for (const entry of entries) {
153
- fs.renameSync(path.join(worktreeRoot, entry), path.join(holding, entry));
182
+ ['-C', worktreeRoot, 'rev-parse', '--verify', 'HEAD'],
183
+ { journalResult: false },
184
+ )).trim();
185
+ } catch (error) {
186
+ if (!(error instanceof TransitionError)) throw error;
154
187
  }
155
- journal({ action: 'hold-initial-files', worktree: worktreeRoot, holding, entries, ok: true });
156
- return holding;
157
- }
158
-
159
- function restoreInitialFiles(worktreeRoot, holding) {
160
- if (!holding) return;
161
- fs.cpSync(holding, worktreeRoot, {
162
- recursive: true,
163
- force: true,
164
- dereference: false,
165
- verbatimSymlinks: true,
166
- });
167
- fs.rmSync(holding, { recursive: true, force: true });
168
- journal({ action: 'restore-initial-files', worktree: worktreeRoot, holding, ok: true });
169
- }
170
-
171
- /** Fast-forward the current branch to an exact commit. Refuses anything but a ff. */
172
- function fastForwardTo(worktreeRoot, oid) {
173
- return run('fast-forward', worktreeRoot, ['-C', worktreeRoot, 'merge', '--ff-only', oid], {
174
- timeoutMs: 60_000,
175
- });
176
- }
177
-
178
- async function currentHead(worktreeRoot) {
179
- const oid = (await run(
180
- 'observe-head',
181
- worktreeRoot,
182
- ['-C', worktreeRoot, 'rev-parse', '--verify', 'HEAD'],
183
- { journalResult: false },
184
- )).trim();
185
188
  let branch = null;
186
189
  try {
187
190
  branch = (await run(
@@ -196,151 +199,476 @@ async function currentHead(worktreeRoot) {
196
199
  return { branch, oid };
197
200
  }
198
201
 
202
+ async function branchOid(worktreeRoot, branch) {
203
+ try {
204
+ return (await run(
205
+ 'observe-branch-oid',
206
+ worktreeRoot,
207
+ ['-C', worktreeRoot, 'rev-parse', '--verify', `refs/heads/${branch}`],
208
+ { journalResult: false },
209
+ )).trim();
210
+ } catch (error) {
211
+ if (error instanceof TransitionError) return null;
212
+ throw error;
213
+ }
214
+ }
215
+
216
+ function syncDirectory(directory) {
217
+ const descriptor = fs.openSync(directory, 'r');
218
+ try {
219
+ fs.fsyncSync(descriptor);
220
+ } finally {
221
+ fs.closeSync(descriptor);
222
+ }
223
+ }
224
+
199
225
  /**
200
- * Materialize one exact card. Branch and OID are one instruction:
201
- * either the expected old pair still exists and the target pair lands,
202
- * or the command refuses without moving another branch.
226
+ * Change only HEAD's attachment, under Git's own HEAD lock. The index and
227
+ * working files are deliberately untouched. A process death therefore
228
+ * cannot discard local bytes; the reducer's durable apply record can replay
229
+ * the remaining content-addressed steps.
203
230
  */
204
- async function moveToCard(worktreeRoot, {
231
+ async function compareAndSwapHead(worktreeRoot, {
205
232
  expectedBranch,
206
233
  expectedOid,
207
234
  targetBranch,
208
235
  targetOid,
236
+ operation,
209
237
  }) {
210
- const before = await currentHead(worktreeRoot);
211
- if (before.branch !== expectedBranch || before.oid !== expectedOid) {
212
- throw new TransitionError('move-to-card', worktreeRoot, 'local card changed before movement');
238
+ if (!/^[0-9a-f]{64}$/.test(operation || '')) {
239
+ throw new Error('HEAD movement has no durable operation identity');
213
240
  }
241
+ const expected = Buffer.from(expectedBranch
242
+ ? `ref: refs/heads/${expectedBranch}\n`
243
+ : `${expectedOid}\n`);
244
+ const target = Buffer.from(targetBranch
245
+ ? `ref: refs/heads/${targetBranch}\n`
246
+ : `${targetOid}\n`);
247
+ if (expected.equals(target)) return;
248
+ applyGitMetadataTransaction(
249
+ worktreeRoot,
250
+ 'HEAD',
251
+ expected,
252
+ target,
253
+ operation,
254
+ );
255
+ }
214
256
 
215
- if (before.branch === targetBranch) {
216
- await fastForwardTo(worktreeRoot, targetOid);
217
- } else {
218
- await run(
219
- 'switch-card',
257
+ /**
258
+ * Move only repository metadata to an exact card.
259
+ *
260
+ * Branch refs use compare-and-swap. Switching attachment uses Git's HEAD
261
+ * lock. Neither operation touches the index or working tree, so replay after
262
+ * a crash is idempotent and cannot erase pending local work.
263
+ */
264
+ async function moveHeadToCard(worktreeRoot, {
265
+ expectedBranch,
266
+ expectedOid,
267
+ expectedTargetOid,
268
+ targetBranch,
269
+ targetOid,
270
+ operation,
271
+ }) {
272
+ const before = await currentHead(worktreeRoot);
273
+ if (before.branch === (targetBranch || null) && before.oid === targetOid) {
274
+ if (
275
+ expectedBranch === (targetBranch || null)
276
+ && expectedOid === targetOid
277
+ ) return;
278
+ if (
279
+ /^[0-9a-f]{64}$/.test(operation || '')
280
+ && (
281
+ (
282
+ expectedBranch === (targetBranch || null)
283
+ && await branchReceiptCrossed(worktreeRoot, operation, targetBranch)
284
+ )
285
+ || (
286
+ expectedBranch !== (targetBranch || null)
287
+ && gitMetadataReceiptCrossed(worktreeRoot, operation, 'HEAD')
288
+ )
289
+ )
290
+ ) return;
291
+ throw new TransitionError(
292
+ 'move-head-to-card',
220
293
  worktreeRoot,
221
- ['-C', worktreeRoot, 'checkout', '-B', targetBranch, targetOid],
222
- { timeoutMs: 60_000 },
294
+ 'local HEAD reached the target without this ordered apply',
223
295
  );
224
296
  }
297
+ if (before.branch !== expectedBranch || before.oid !== expectedOid) {
298
+ throw new TransitionError('move-head-to-card', worktreeRoot, 'local card changed before movement');
299
+ }
300
+
301
+ if (targetBranch) {
302
+ const observedTarget = await branchOid(worktreeRoot, targetBranch);
303
+ if (observedTarget !== targetOid) {
304
+ if (observedTarget !== (expectedTargetOid || null)) {
305
+ throw new TransitionError(
306
+ 'move-head-to-card',
307
+ worktreeRoot,
308
+ `target branch changed from ${expectedTargetOid || '(absent)'} to ${observedTarget || '(absent)'}`,
309
+ );
310
+ }
311
+ await updateBranchRefs(worktreeRoot, [{
312
+ branch: targetBranch,
313
+ expectedOid: observedTarget,
314
+ oid: targetOid,
315
+ }], operation);
316
+ }
317
+ }
318
+
319
+ if (!targetBranch || expectedBranch !== targetBranch) {
320
+ await compareAndSwapHead(worktreeRoot, {
321
+ expectedBranch,
322
+ expectedOid,
323
+ targetBranch,
324
+ targetOid,
325
+ operation,
326
+ });
327
+ }
225
328
 
226
329
  const after = await currentHead(worktreeRoot);
227
- if (after.branch !== targetBranch || after.oid !== targetOid) {
330
+ if (after.branch !== (targetBranch || null) || after.oid !== targetOid) {
228
331
  throw new TransitionError(
229
- 'move-to-card',
332
+ 'move-head-to-card',
230
333
  worktreeRoot,
231
- `expected ${targetBranch}@${targetOid}, observed ${after.branch}@${after.oid}`,
334
+ `expected ${targetBranch || '(detached)'}@${targetOid}, observed ${after.branch || '(detached)'}@${after.oid}`,
232
335
  );
233
336
  }
234
337
  }
235
338
 
236
- /** Keep a commit reachable under one content-derived internal ref. */
237
- function parkCommit(worktreeRoot, oid) {
238
- const ref = `refs/amalgm/parked/${oid}`;
239
- return run('park-commit', worktreeRoot, [
240
- '-C', worktreeRoot, 'update-ref', ref, oid,
241
- ]).then(() => ref);
339
+ async function emptyTree(worktreeRoot) {
340
+ return (await run(
341
+ 'empty-tree',
342
+ worktreeRoot,
343
+ ['-C', worktreeRoot, 'mktree'],
344
+ { input: '', journalResult: false },
345
+ )).trim();
242
346
  }
243
347
 
244
- /** Delete internal parked refs that are no longer part of shared checkpoint state. */
245
- async function pruneParkedCommits(worktreeRoot, keepOids) {
246
- const output = await run('list-parked-commits', worktreeRoot, [
247
- '-C', worktreeRoot, 'for-each-ref', '--format=%(refname)', 'refs/amalgm/parked',
248
- ], { journalResult: false });
249
- const prefix = 'refs/amalgm/parked/';
250
- for (const ref of output.trim().split('\n').filter(Boolean)) {
251
- if (!ref.startsWith(prefix) || keepOids.has(ref.slice(prefix.length))) continue;
252
- await run('remove-parked-commit', worktreeRoot, [
253
- '-C', worktreeRoot, 'update-ref', '-d', ref,
254
- ]);
348
+ async function ensureEmptyIndex(worktreeRoot, operation, testHooks = {}) {
349
+ if (!/^[0-9a-f]{64}$/.test(operation || '')) {
350
+ throw new Error('index initialization has no durable operation identity');
351
+ }
352
+ const value = (await run(
353
+ 'observe-index-file',
354
+ worktreeRoot,
355
+ ['-C', worktreeRoot, 'rev-parse', '--git-path', 'index'],
356
+ { journalResult: false },
357
+ )).trim();
358
+ const file = path.isAbsolute(value) ? value : path.resolve(worktreeRoot, value);
359
+ if (gitMetadataReceiptCrossed(worktreeRoot, operation, 'index')) {
360
+ if (testHooks.retainReceipt !== true) {
361
+ releaseGitMetadataReceipts(worktreeRoot, operation, 'index');
362
+ }
363
+ return;
364
+ }
365
+ if (fs.existsSync(file)) return;
366
+ if (typeof testHooks.afterAbsent === 'function') {
367
+ await testHooks.afterAbsent();
368
+ }
369
+ const stateDir = path.join(AMALGM_DIR, 'state');
370
+ fs.mkdirSync(stateDir, { recursive: true });
371
+ const scratch = path.join(
372
+ stateDir,
373
+ `empty-index-${process.pid}-${crypto.randomBytes(8).toString('hex')}`,
374
+ );
375
+ try {
376
+ await run(
377
+ 'prepare-empty-index',
378
+ worktreeRoot,
379
+ ['-C', worktreeRoot, 'read-tree', '--empty'],
380
+ { env: { GIT_INDEX_FILE: scratch } },
381
+ );
382
+ applyGitMetadataTransaction(
383
+ worktreeRoot,
384
+ 'index',
385
+ null,
386
+ fs.readFileSync(scratch),
387
+ operation,
388
+ );
389
+ if (testHooks.retainReceipt !== true) {
390
+ releaseGitMetadataReceipts(worktreeRoot, operation, 'index');
391
+ }
392
+ } finally {
393
+ fs.rmSync(scratch, { force: true });
255
394
  }
256
- }
257
-
258
- function setBranchRef(worktreeRoot, branch, oid) {
259
- return run('set-branch-ref', worktreeRoot, [
260
- '-C', worktreeRoot, 'update-ref', `refs/heads/${branch}`, oid,
261
- ]);
262
- }
263
-
264
- function deleteBranchRef(worktreeRoot, branch) {
265
- return run('delete-branch-ref', worktreeRoot, [
266
- '-C', worktreeRoot, 'update-ref', '-d', `refs/heads/${branch}`,
267
- ]);
268
395
  }
269
396
 
270
397
  /**
271
- * Exact card movement after the caller has durably parked the old commit
272
- * and captured the working tree. This deliberately discards the local
273
- * checkout copy; the caller immediately pours the preserved work back as
274
- * the checkpoint layer.
398
+ * Give an unborn repository its first exact card without checking out files.
399
+ * Existing files remain local pending work and are materialized separately.
275
400
  */
276
- async function forceMoveToCard(worktreeRoot, {
277
- expectedBranch,
278
- expectedOid,
401
+ async function initializeHeadToCard(worktreeRoot, {
279
402
  targetBranch,
280
403
  targetOid,
404
+ operation,
281
405
  }) {
282
- const before = await currentHead(worktreeRoot);
283
- if (before.branch !== expectedBranch || before.oid !== expectedOid) {
284
- throw new TransitionError('force-move-to-card', worktreeRoot, 'local card changed before movement');
406
+ try {
407
+ const current = await currentHead(worktreeRoot);
408
+ if (current.branch === (targetBranch || null) && current.oid === targetOid) return;
409
+ throw new TransitionError('initialize-head-to-card', worktreeRoot, 'repository already has another card');
410
+ } catch (error) {
411
+ if (!(error instanceof TransitionError) || error.action !== 'observe-head') throw error;
412
+ }
413
+
414
+ let expectedBranch = null;
415
+ try {
416
+ expectedBranch = (await run(
417
+ 'observe-unborn-branch',
418
+ worktreeRoot,
419
+ ['-C', worktreeRoot, 'symbolic-ref', '--quiet', '--short', 'HEAD'],
420
+ { journalResult: false },
421
+ )).trim() || null;
422
+ } catch (error) {
423
+ if (!(error instanceof TransitionError)) throw error;
285
424
  }
286
425
  if (targetBranch) {
287
- await run('force-move-to-card', worktreeRoot, [
288
- '-C', worktreeRoot, 'checkout', '--force', '-B', targetBranch, targetOid,
289
- ], { timeoutMs: 60_000 });
290
- } else {
291
- await run('force-move-to-card', worktreeRoot, [
292
- '-C', worktreeRoot, 'checkout', '--force', '--detach', targetOid,
293
- ], { timeoutMs: 60_000 });
426
+ const observedTarget = await branchOid(worktreeRoot, targetBranch);
427
+ if (observedTarget !== null && observedTarget !== targetOid) {
428
+ throw new TransitionError(
429
+ 'initialize-head-to-card',
430
+ worktreeRoot,
431
+ 'target branch already has another commit',
432
+ );
433
+ }
434
+ if (observedTarget === null) {
435
+ await updateBranchRefs(worktreeRoot, [{
436
+ branch: targetBranch,
437
+ expectedOid: null,
438
+ oid: targetOid,
439
+ }], operation);
440
+ }
294
441
  }
442
+ await compareAndSwapHead(worktreeRoot, {
443
+ expectedBranch,
444
+ expectedOid: null,
445
+ targetBranch,
446
+ targetOid,
447
+ operation,
448
+ });
295
449
  const after = await currentHead(worktreeRoot);
296
450
  if (after.branch !== (targetBranch || null) || after.oid !== targetOid) {
297
- throw new TransitionError(
298
- 'force-move-to-card',
299
- worktreeRoot,
300
- `expected ${targetBranch || '(detached)'}@${targetOid}, observed ${after.branch || '(detached)'}@${after.oid}`,
301
- );
451
+ throw new TransitionError('initialize-head-to-card', worktreeRoot, 'initial card did not land');
302
452
  }
303
453
  }
304
454
 
305
455
  /**
306
- * Apply a captured worktree patch — or reverse one — from a file on disk.
307
- * The patch file lives outside the repo (in the state dir), so what was
308
- * applied stays inspectable after the fact.
456
+ * Move a set of branch refs as one compare-and-swap transaction.
457
+ * Each change is { branch, expectedOid, oid }; null expectedOid means create
458
+ * only if absent, and null oid means delete only from the expected commit.
309
459
  */
310
- function applyPatch(worktreeRoot, patchFile, { reverse = false } = {}) {
311
- return run(reverse ? 'reverse-patch' : 'apply-patch', worktreeRoot, [
312
- '-C', worktreeRoot, 'apply', ...(reverse ? ['-R'] : []), '--whitespace=nowarn', patchFile,
313
- ], { timeoutMs: 60_000 });
460
+ function branchReceiptPrefix(operation) {
461
+ if (!/^[0-9a-f]{64}$/.test(operation || '')) {
462
+ throw new Error('branch receipt has no operation identity');
463
+ }
464
+ return `refs/amalgm/apply/${operation}/branches`;
465
+ }
466
+
467
+ function branchReceiptRef(operation, branch) {
468
+ if (typeof branch !== 'string' || branch.length === 0) {
469
+ throw new Error('branch receipt has no branch identity');
470
+ }
471
+ const digest = crypto.createHash('sha256').update(branch).digest('hex');
472
+ return `${branchReceiptPrefix(operation)}/${digest}`;
473
+ }
474
+
475
+ async function updateBranchRefs(
476
+ worktreeRoot,
477
+ changes,
478
+ operation = null,
479
+ testHooks = {},
480
+ ) {
481
+ if (changes.length === 0) return Promise.resolve('');
482
+ const deletions = changes.filter((change) => change.oid === null);
483
+ const nonDeletions = changes.filter((change) => change.oid !== null);
484
+ if (deletions.length > 0 && nonDeletions.length > 0) {
485
+ const checked = new Set(
486
+ (await listedWorktreeHeads(worktreeRoot))
487
+ .map((entry) => entry.branch)
488
+ .filter(Boolean),
489
+ );
490
+ if (nonDeletions.some((change) => checked.has(change.branch))) {
491
+ await updateBranchRefs(worktreeRoot, nonDeletions, operation);
492
+ if (typeof testHooks.afterNonDeletionBatch === 'function') {
493
+ await testHooks.afterNonDeletionBatch();
494
+ }
495
+ return updateBranchRefs(worktreeRoot, deletions, operation, testHooks);
496
+ }
497
+ }
498
+ let input = 'start\0';
499
+ for (const { branch, expectedOid, oid } of changes) {
500
+ const ref = `refs/heads/${branch}`;
501
+ if (oid === null) {
502
+ input += `delete ${ref}\0${expectedOid}\0`;
503
+ } else if (expectedOid === null) {
504
+ input += `create ${ref}\0${oid}\0`;
505
+ } else {
506
+ input += `update ${ref}\0${oid}\0${expectedOid}\0`;
507
+ }
508
+ }
509
+ if (operation !== null) {
510
+ for (const change of changes) {
511
+ const receiptOid = change.oid || change.expectedOid;
512
+ if (!/^[0-9a-f]{40,64}$/.test(receiptOid || '')) {
513
+ throw new Error('branch receipt has no object');
514
+ }
515
+ input += `create ${branchReceiptRef(operation, change.branch)}\0${receiptOid}\0`;
516
+ }
517
+ }
518
+ input += 'prepare\0commit\0';
519
+ const apply = (commonRoot = null) => run(
520
+ 'update-branch-refs',
521
+ worktreeRoot,
522
+ commonRoot === null
523
+ ? ['-C', worktreeRoot, 'update-ref', '--stdin', '-z']
524
+ : [`--git-dir=${commonRoot}`, 'update-ref', '--stdin', '-z'],
525
+ { input },
526
+ );
527
+ if (!changes.some((change) => change.oid === null)) return apply();
528
+ return withGitHeadGuard(
529
+ worktreeRoot,
530
+ changes,
531
+ testHooks,
532
+ apply,
533
+ );
534
+ }
535
+
536
+ async function branchReceiptCrossed(worktreeRoot, operation, branch = null) {
537
+ try {
538
+ if (branch === null) {
539
+ const observed = await run(
540
+ 'observe-branch-receipts',
541
+ worktreeRoot,
542
+ [
543
+ '-C', worktreeRoot,
544
+ 'for-each-ref',
545
+ '--count=1',
546
+ '--format=%(refname)',
547
+ `${branchReceiptPrefix(operation)}/`,
548
+ ],
549
+ { journalResult: false },
550
+ );
551
+ return observed.trim() !== '';
552
+ }
553
+ await run(
554
+ 'observe-branch-receipt',
555
+ worktreeRoot,
556
+ ['-C', worktreeRoot, 'rev-parse', '--verify', branchReceiptRef(operation, branch)],
557
+ { journalResult: false },
558
+ );
559
+ return true;
560
+ } catch (error) {
561
+ if (error instanceof TransitionError) return false;
562
+ throw error;
563
+ }
564
+ }
565
+
566
+ async function releaseBranchReceipt(worktreeRoot, operation) {
567
+ const observed = await run(
568
+ 'observe-branch-receipts',
569
+ worktreeRoot,
570
+ [
571
+ '-C', worktreeRoot,
572
+ 'for-each-ref',
573
+ '--format=%(refname)',
574
+ `${branchReceiptPrefix(operation)}/`,
575
+ ],
576
+ { journalResult: false },
577
+ );
578
+ for (const ref of observed.trim().split('\n').filter(Boolean)) {
579
+ await run('release-branch-receipt', worktreeRoot, [
580
+ '-C', worktreeRoot, 'update-ref', '-d', ref,
581
+ ]);
582
+ }
314
583
  }
315
584
 
316
585
  /**
317
- * Apply only the index change the reducer computed. Git locks the index and
318
- * verifies the patch's preimage, so a concurrent commit is serialized instead
319
- * of being replaced by an old whole-index snapshot.
586
+ * Apply only the index change the reducer computed. The current exact index
587
+ * bytes must first derive the reducer's sealed source tree. The patch must
588
+ * then derive the named target tree before Git's index lock may replace those
589
+ * exact bytes. A concurrent staged change therefore stops the movement
590
+ * instead of becoming a new expected-old value.
320
591
  */
321
- async function applyIndexPatch(worktreeRoot, patch) {
592
+ async function applyIndexPatch(
593
+ worktreeRoot,
594
+ patch,
595
+ operation,
596
+ { expectedTree, targetTree } = {},
597
+ ) {
322
598
  if (!patch) return;
599
+ if (!/^[0-9a-f]{64}$/.test(operation || '')) {
600
+ throw new Error('index movement has no durable operation identity');
601
+ }
602
+ if (
603
+ !/^[0-9a-f]{40,64}$/.test(expectedTree || '')
604
+ || !/^[0-9a-f]{40,64}$/.test(targetTree || '')
605
+ ) {
606
+ throw new Error('index movement has no sealed tree endpoints');
607
+ }
323
608
  const stateDir = path.join(AMALGM_DIR, 'state');
324
609
  fs.mkdirSync(stateDir, { recursive: true });
325
610
  const dir = fs.mkdtempSync(path.join(stateDir, 'index-patch-'));
326
611
  const patchFile = path.join(dir, 'patch');
612
+ const scratchIndex = path.join(dir, 'index');
613
+ const index = gitPath(worktreeRoot, 'index');
327
614
  try {
328
615
  fs.writeFileSync(patchFile, patch);
616
+ const expected = fs.existsSync(index) ? fs.readFileSync(index) : null;
617
+ if (expected === null) {
618
+ await run(
619
+ 'prepare-empty-index',
620
+ worktreeRoot,
621
+ ['-C', worktreeRoot, 'read-tree', '--empty'],
622
+ { env: { GIT_INDEX_FILE: scratchIndex } },
623
+ );
624
+ } else {
625
+ fs.writeFileSync(scratchIndex, expected);
626
+ }
627
+ const observedTree = (await run(
628
+ 'verify-index-patch-source',
629
+ worktreeRoot,
630
+ ['-C', worktreeRoot, 'write-tree'],
631
+ {
632
+ env: { GIT_INDEX_FILE: scratchIndex },
633
+ journalResult: false,
634
+ },
635
+ )).trim();
636
+ if (observedTree !== expectedTree) {
637
+ throw new TransitionError(
638
+ 'apply-index-patch',
639
+ worktreeRoot,
640
+ `local index changed from sealed tree ${expectedTree} to ${observedTree}`,
641
+ );
642
+ }
329
643
  await run('apply-index-patch', worktreeRoot, [
330
644
  '-C', worktreeRoot, 'apply', '--cached', '--whitespace=nowarn', patchFile,
331
- ]);
645
+ ], { env: { GIT_INDEX_FILE: scratchIndex } });
646
+ const appliedTree = (await run(
647
+ 'verify-index-patch-target',
648
+ worktreeRoot,
649
+ ['-C', worktreeRoot, 'write-tree'],
650
+ {
651
+ env: { GIT_INDEX_FILE: scratchIndex },
652
+ journalResult: false,
653
+ },
654
+ )).trim();
655
+ if (appliedTree !== targetTree) {
656
+ throw new Error(
657
+ `index patch target mismatch: expected ${targetTree}, derived ${appliedTree}`,
658
+ );
659
+ }
660
+ applyGitMetadataTransaction(
661
+ worktreeRoot,
662
+ 'index',
663
+ expected,
664
+ fs.readFileSync(scratchIndex),
665
+ operation,
666
+ );
332
667
  } finally {
333
668
  fs.rmSync(dir, { recursive: true, force: true });
334
669
  }
335
670
  }
336
671
 
337
- /** Set only Git's index; working files are left exactly as they are. */
338
- function setIndexTree(worktreeRoot, treeOid) {
339
- return run('set-index-tree', worktreeRoot, [
340
- '-C', worktreeRoot, 'read-tree', treeOid,
341
- ]);
342
- }
343
-
344
672
  /**
345
673
  * Apply one ordered file write set to a working tree. Each entry is
346
674
  * { path, content, mode } (content a
@@ -349,58 +677,1267 @@ function setIndexTree(worktreeRoot, treeOid) {
349
677
  * as one journal line.
350
678
  */
351
679
  function applyFiles(worktreeRoot, writes, label) {
352
- const root = path.resolve(worktreeRoot);
680
+ const root = fs.realpathSync(worktreeRoot);
681
+ const operation = typeof label === 'string' && label
682
+ ? label
683
+ : `anonymous-${crypto.randomBytes(32).toString('hex')}`;
353
684
  const written = [];
354
685
  const removed = [];
355
686
  try {
687
+ ensureFileTransactionExcluded(root);
688
+ recoverFileTransactions(root);
356
689
  for (const write of writes) {
357
- const abs = path.resolve(root, write.path);
358
- if (abs !== root && !abs.startsWith(root + path.sep)) {
359
- throw new Error(`unsafe path: ${write.path}`);
360
- }
361
- if (write.content === null) {
362
- fs.rmSync(abs, { force: true });
363
- removed.push(write.path);
364
- continue;
365
- }
366
- fs.mkdirSync(path.dirname(abs), { recursive: true });
367
- if (write.mode === '120000') {
368
- fs.rmSync(abs, { force: true });
369
- fs.symlinkSync(write.content.toString('utf8'), abs);
370
- } else {
371
- fs.writeFileSync(abs, write.content);
372
- fs.chmodSync(abs, write.mode === '100755' ? 0o755 : 0o644);
373
- }
374
- written.push(write.path);
690
+ applyFileTransaction(root, write, operation);
691
+ if (write.content === null) removed.push(write.path);
692
+ else written.push(write.path);
375
693
  }
376
- journal({ action: 'apply-files', worktree: root, label, written, removed, ok: true });
694
+ journal({ action: 'apply-files', worktree: root, operation, written, removed, ok: true });
695
+ return { operation };
377
696
  } catch (error) {
378
697
  journal({
379
- action: 'apply-files', worktree: root, label, written, removed, ok: false,
698
+ action: 'apply-files', worktree: root, operation, written, removed, ok: false,
380
699
  error: String(error.message).slice(0, 500),
381
700
  });
382
701
  throw new TransitionError('apply-files', root, String(error.message));
383
702
  }
384
703
  }
385
704
 
705
+ function gitDirectory(worktreeRoot) {
706
+ const marker = path.join(worktreeRoot, '.git');
707
+ if (fs.statSync(marker).isDirectory()) return marker;
708
+ const match = fs.readFileSync(marker, 'utf8').trim().match(/^gitdir:\s*(.+)$/);
709
+ if (!match) throw new Error('worktree has no Git directory');
710
+ return path.resolve(worktreeRoot, match[1]);
711
+ }
712
+
713
+ function gitPath(worktreeRoot, relativePath) {
714
+ const value = execFileSync(
715
+ 'git',
716
+ ['-C', worktreeRoot, 'rev-parse', '--git-path', relativePath],
717
+ {
718
+ cwd: worktreeRoot,
719
+ env: { ...process.env, ...TRANSITION_ENV },
720
+ encoding: 'utf8',
721
+ timeout: 120_000,
722
+ maxBuffer: 32 * 1024 * 1024,
723
+ },
724
+ ).trim();
725
+ return path.isAbsolute(value) ? value : path.resolve(worktreeRoot, value);
726
+ }
727
+
728
+ function fileTransactionDirectory(worktreeRoot) {
729
+ return path.join(gitDirectory(worktreeRoot), 'amalgm-apply');
730
+ }
731
+
732
+ /**
733
+ * Receipts live beside their destination so the kernel can consume prepared
734
+ * targets atomically even when a linked worktree's Git directory is on
735
+ * another device. They are implementation state, never workspace content.
736
+ */
737
+ function isApplyReceiptPath(relativePath) {
738
+ return String(relativePath)
739
+ .split(/[\\/]/)
740
+ .some((component) => APPLY_RECEIPT_DIRECTORY.test(component));
741
+ }
742
+
743
+ function ensureFileTransactionExcluded(worktreeRoot) {
744
+ // Linked worktrees have a private Git directory, but Git reads exclusions
745
+ // from the common path named by `--git-path`. The command is the authority;
746
+ // reconstructing this path from `.git` makes receipt bytes look user-owned.
747
+ const exclude = gitPath(worktreeRoot, 'info/exclude');
748
+ let content = '';
749
+ let mode = 0o644;
750
+ try {
751
+ content = fs.readFileSync(exclude, 'utf8');
752
+ mode = fs.statSync(exclude).mode & 0o777;
753
+ } catch (error) {
754
+ if (error?.code !== 'ENOENT') throw error;
755
+ }
756
+ if (content.split(/\r?\n/).includes(APPLY_RECEIPT_EXCLUDE)) return;
757
+ fs.mkdirSync(path.dirname(exclude), { recursive: true });
758
+ // Append instead of replacing a read snapshot: a concurrent user edit to
759
+ // info/exclude must survive. A racing apply may append the same harmless
760
+ // rule twice, while every later call observes it and stops.
761
+ const descriptor = fs.openSync(exclude, 'a', mode);
762
+ try {
763
+ fs.writeFileSync(descriptor, `\n${APPLY_RECEIPT_EXCLUDE}\n`);
764
+ fs.fsyncSync(descriptor);
765
+ } finally {
766
+ fs.closeSync(descriptor);
767
+ }
768
+ syncDirectory(path.dirname(exclude));
769
+ }
770
+
771
+ function ensureDurableDirectory(directory) {
772
+ try {
773
+ fs.mkdirSync(directory);
774
+ syncDirectory(path.dirname(directory));
775
+ } catch (error) {
776
+ if (error?.code !== 'EEXIST') throw error;
777
+ if (!fs.statSync(directory).isDirectory()) {
778
+ throw new Error(`workspace transaction path is not a directory: ${directory}`);
779
+ }
780
+ }
781
+ }
782
+
783
+ function confinedPath(worktreeRoot, relativePath) {
784
+ if (
785
+ typeof relativePath !== 'string'
786
+ || !relativePath
787
+ || path.isAbsolute(relativePath)
788
+ || relativePath.split(/[\\/]/).includes('..')
789
+ ) throw new Error(`unsafe path: ${relativePath}`);
790
+ const root = fs.realpathSync(worktreeRoot);
791
+ const absolute = path.resolve(root, relativePath);
792
+ if (!absolute.startsWith(root + path.sep)) throw new Error(`unsafe path: ${relativePath}`);
793
+ let current = root;
794
+ for (const component of relativePath.split(/[\\/]/).slice(0, -1)) {
795
+ if (!component || component === '.') continue;
796
+ current = path.join(current, component);
797
+ let stat;
798
+ try {
799
+ stat = fs.lstatSync(current);
800
+ } catch (error) {
801
+ if (error?.code === 'ENOENT') break;
802
+ throw error;
803
+ }
804
+ if (stat.isSymbolicLink()) {
805
+ throw new Error(`unsafe symlink parent: ${relativePath}`);
806
+ }
807
+ if (!stat.isDirectory()) throw new Error(`unsafe non-directory parent: ${relativePath}`);
808
+ }
809
+ return absolute;
810
+ }
811
+
812
+ function fileStateIdentity(state) {
813
+ if (state === null) return null;
814
+ if (state?.kind === 'other') return { kind: 'other' };
815
+ if (!Buffer.isBuffer(state?.content)) throw new Error('file state has no exact bytes');
816
+ return {
817
+ kind: state.kind,
818
+ exec: state.exec === true,
819
+ bytes: state.content.length,
820
+ sha256: crypto.createHash('sha256').update(state.content).digest('hex'),
821
+ };
822
+ }
823
+
824
+ function transactionPaths(worktreeRoot, operation, relativePath, rootStat) {
825
+ const recordDirectory = fileTransactionDirectory(worktreeRoot);
826
+ ensureDurableDirectory(recordDirectory);
827
+ const id = crypto.createHash('sha256')
828
+ .update(
829
+ `${FILE_TRANSACTION_CONTRACT}\0${rootStat.dev}\0${rootStat.ino}`
830
+ + `\0${operation}\0${relativePath}`,
831
+ )
832
+ .digest('hex');
833
+ return {
834
+ id,
835
+ recordDirectory,
836
+ record: path.join(recordDirectory, `${id}.json`),
837
+ };
838
+ }
839
+
840
+ function removeTransactionRecord(record) {
841
+ try {
842
+ fs.unlinkSync(record);
843
+ } catch (error) {
844
+ if (error?.code !== 'ENOENT') throw error;
845
+ }
846
+ syncDirectory(path.dirname(record));
847
+ }
848
+
849
+ function readFileTransaction(record) {
850
+ try {
851
+ const value = JSON.parse(fs.readFileSync(record, 'utf8'));
852
+ if (
853
+ value?.contract !== FILE_TRANSACTION_CONTRACT
854
+ || typeof value.root !== 'string'
855
+ || !/^\d+$/.test(value.rootDev)
856
+ || !/^\d+$/.test(value.rootIno)
857
+ || typeof value.path !== 'string'
858
+ || typeof value.id !== 'string'
859
+ || typeof value.operation !== 'string'
860
+ || !value.operation
861
+ || value.id !== path.basename(record, '.json')
862
+ || !/^[a-f0-9]{64}$/.test(value.id)
863
+ || (value.expected !== null && typeof value.expected !== 'object')
864
+ || (value.target !== null && typeof value.target !== 'object')
865
+ ) throw new Error('invalid file transaction');
866
+ return { ...value, record };
867
+ } catch (error) {
868
+ throw new Error(`invalid workspace file transaction ${record}: ${error.message}`);
869
+ }
870
+ }
871
+
872
+ function runNativeFileTransaction(transaction, action, content = null, testHooks = {}) {
873
+ try {
874
+ return nativeFileTransaction.run(transaction, action, content, testHooks);
875
+ } catch (error) {
876
+ throw new Error(
877
+ String(error?.message || error).replace(
878
+ /^native file transaction/,
879
+ 'native workspace file transaction',
880
+ ),
881
+ );
882
+ }
883
+ }
884
+
885
+ function fileTransactionRecords(worktreeRoot, operation = null) {
886
+ const directory = fileTransactionDirectory(worktreeRoot);
887
+ if (!fs.existsSync(directory)) return [];
888
+ return fs.readdirSync(directory)
889
+ .filter((name) => name.endsWith('.json'))
890
+ .sort()
891
+ .map((name) => readFileTransaction(path.join(directory, name)))
892
+ .filter((transaction) => operation === null || transaction.operation === operation);
893
+ }
894
+
895
+ /**
896
+ * Resolve an interrupted expected-source → official-target file movement
897
+ * before any new repo observation. The retained source is either the exact
898
+ * expected floor (safe to finish/rollback) or a third value (a local save
899
+ * that is restored without overwriting anything newer).
900
+ */
901
+ function recoverFileTransactions(worktreeRoot) {
902
+ const root = fs.realpathSync(worktreeRoot);
903
+ ensureFileTransactionExcluded(root);
904
+ for (const transaction of fileTransactionRecords(root)) {
905
+ if (transaction.root !== root) {
906
+ throw new Error(`workspace file transaction escaped its repository: ${transaction.record}`);
907
+ }
908
+ const result = runNativeFileTransaction(transaction, 'recover');
909
+ if (result.status === 'pending') {
910
+ throw new Error(
911
+ `workspace file transaction is waiting for local writer ownership: `
912
+ + `${transaction.path}: ${result.reason || 'pending'}`,
913
+ );
914
+ }
915
+ // A crossed operation is evidence until the verified cursor passes it.
916
+ // Only a transaction proven not to have crossed can retire here.
917
+ if (result.status === 'changed') removeTransactionRecord(transaction.record);
918
+ }
919
+ }
920
+
921
+ function sameTransaction(left, right) {
922
+ return (
923
+ left.contract === right.contract
924
+ && left.root === right.root
925
+ && left.rootDev === right.rootDev
926
+ && left.rootIno === right.rootIno
927
+ && left.path === right.path
928
+ && left.id === right.id
929
+ && left.operation === right.operation
930
+ && JSON.stringify(left.expected) === JSON.stringify(right.expected)
931
+ && JSON.stringify(left.target) === JSON.stringify(right.target)
932
+ );
933
+ }
934
+
935
+ function applyFileTransaction(worktreeRoot, write, operation) {
936
+ if (typeof operation !== 'string' || !operation || operation.length > 1024) {
937
+ throw new Error('workspace file transaction has no operation identity');
938
+ }
939
+ const rootStat = fs.statSync(worktreeRoot, { bigint: true });
940
+ const paths = transactionPaths(worktreeRoot, operation, write.path, rootStat);
941
+ const target = write.content === null
942
+ ? null
943
+ : {
944
+ ...fileStateIdentity({
945
+ kind: write.mode === '120000' ? 'symlink' : 'file',
946
+ content: write.content,
947
+ exec: write.mode === '100755',
948
+ }),
949
+ };
950
+ const transaction = {
951
+ contract: FILE_TRANSACTION_CONTRACT,
952
+ root: worktreeRoot,
953
+ rootDev: rootStat.dev.toString(),
954
+ rootIno: rootStat.ino.toString(),
955
+ path: write.path,
956
+ id: paths.id,
957
+ operation,
958
+ expected: fileStateIdentity(write.expected),
959
+ target,
960
+ };
961
+ if (fs.existsSync(paths.record)) {
962
+ const retained = readFileTransaction(paths.record);
963
+ if (!sameTransaction(retained, transaction)) {
964
+ throw new Error(`workspace file transaction identity changed: ${write.path}`);
965
+ }
966
+ const recovered = runNativeFileTransaction(retained, 'recover');
967
+ if (recovered.status === 'applied') return;
968
+ if (recovered.status === 'pending') {
969
+ throw new Error(
970
+ `local writer ownership is unresolved: ${write.path}: `
971
+ + `${recovered.reason || 'pending'}`,
972
+ );
973
+ }
974
+ removeTransactionRecord(paths.record);
975
+ }
976
+ durable.writeJson(paths.record, transaction);
977
+ const result = runNativeFileTransaction(
978
+ transaction,
979
+ 'apply',
980
+ write.content,
981
+ {
982
+ pauseAfterParentOpen: Number(write.testPauseAfterParentOpenMillis || 0),
983
+ pauseAfterExtraction: Number(write.testPauseAfterExtractionMillis || 0),
984
+ pauseBeforeInstall: Number(write.testPauseBeforeInstallMillis || 0),
985
+ crashAfterExtraction: write.testCrashAfterExtraction === true,
986
+ crashBeforeInstall: write.testCrashBeforeInstall === true,
987
+ },
988
+ );
989
+ if (result.status === 'pending') {
990
+ throw new Error(
991
+ `local writer ownership is unresolved: ${write.path}: ${result.reason || 'pending'}`,
992
+ );
993
+ }
994
+ if (result.status === 'changed') {
995
+ removeTransactionRecord(paths.record);
996
+ throw new Error(`local file changed before ordered apply: ${write.path}`);
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * The transaction artifact, not ambient file metadata, says whether an
1002
+ * operation crossed. A later local write may return the pathname to either
1003
+ * endpoint without changing this answer.
1004
+ */
1005
+ function crossedFileReceiptPaths(worktreeRoot, operation, relativePaths) {
1006
+ const root = fs.realpathSync(worktreeRoot);
1007
+ const requested = new Set(relativePaths);
1008
+ const crossed = new Set();
1009
+ recoverFileTransactions(root);
1010
+ for (const transaction of fileTransactionRecords(root, operation)) {
1011
+ if (!requested.has(transaction.path)) continue;
1012
+ const result = runNativeFileTransaction(transaction, 'receipt');
1013
+ if (result.status === 'crossed') crossed.add(transaction.path);
1014
+ else if (result.status !== 'not-crossed') {
1015
+ throw new Error(
1016
+ `workspace file receipt is unresolved: ${transaction.path}: `
1017
+ + `${result.reason || result.status}`,
1018
+ );
1019
+ }
1020
+ }
1021
+ return crossed;
1022
+ }
1023
+
1024
+ /**
1025
+ * Receipt retirement is safe only after the caller's verified cursor is
1026
+ * durable. Repeating release after any crash is harmless.
1027
+ */
1028
+ function releaseFileReceipts(worktreeRoot, operation) {
1029
+ const root = fs.realpathSync(worktreeRoot);
1030
+ for (const transaction of fileTransactionRecords(root, operation)) {
1031
+ const result = runNativeFileTransaction(transaction, 'release');
1032
+ if (result.status === 'pending') {
1033
+ throw new Error(
1034
+ `workspace file receipt cannot be released: ${transaction.path}: `
1035
+ + `${result.reason || 'pending'}`,
1036
+ );
1037
+ }
1038
+ if (result.status !== 'released') {
1039
+ throw new Error(
1040
+ `workspace file receipt release failed: ${transaction.path}: ${result.status}`,
1041
+ );
1042
+ }
1043
+ removeTransactionRecord(transaction.record);
1044
+ }
1045
+ }
1046
+
1047
+ function gitMetadataTransactionDirectoryForRoot(root) {
1048
+ return path.join(fs.realpathSync(root), 'amalgm-metadata-apply');
1049
+ }
1050
+
1051
+ function gitMetadataTransactionDirectory(worktreeRoot) {
1052
+ return gitMetadataTransactionDirectoryForRoot(gitDirectory(worktreeRoot));
1053
+ }
1054
+
1055
+ async function listedWorktreeHeads(worktreeRoot) {
1056
+ const output = await run(
1057
+ 'observe-worktree-heads',
1058
+ worktreeRoot,
1059
+ ['-C', worktreeRoot, 'worktree', 'list', '--porcelain', '-z'],
1060
+ { journalResult: false },
1061
+ );
1062
+ const entries = [];
1063
+ let current = null;
1064
+ for (const token of output.split('\0')) {
1065
+ if (!token) {
1066
+ if (current?.path) entries.push(current);
1067
+ current = null;
1068
+ } else if (token.startsWith('worktree ')) {
1069
+ current = { path: token.slice('worktree '.length), branch: null };
1070
+ } else if (current && token.startsWith('branch refs/heads/')) {
1071
+ current.branch = token.slice('branch refs/heads/'.length);
1072
+ }
1073
+ }
1074
+ if (current?.path) entries.push(current);
1075
+ return entries;
1076
+ }
1077
+
1078
+ async function withOneGitHeadGuard(
1079
+ gitRoot,
1080
+ label,
1081
+ changes,
1082
+ guarded,
1083
+ ) {
1084
+ const root = fs.realpathSync(gitRoot);
1085
+ recoverGitMetadataTransactionsAtRoot(root, label);
1086
+ const head = path.join(root, 'HEAD');
1087
+ const expected = fs.readFileSync(head);
1088
+ const attached = expected.toString('utf8').match(/^ref: refs\/heads\/(.+)\n$/)?.[1] || null;
1089
+ const deleted = new Set(
1090
+ changes.filter((change) => change.oid === null).map((change) => change.branch),
1091
+ );
1092
+ if (attached && deleted.has(attached)) {
1093
+ throw new TransitionError(
1094
+ 'guard-branch-delete',
1095
+ label,
1096
+ `HEAD is attached to branch ${attached}`,
1097
+ );
1098
+ }
1099
+ // A logical branch operation may be attempted concurrently. Its transient
1100
+ // guard artifacts must still have exactly one process owner.
1101
+ const guardOperation = crypto.randomBytes(32).toString('hex');
1102
+ const rootStat = fs.statSync(root, { bigint: true });
1103
+ const paths = gitMetadataTransactionPathsForRoot(
1104
+ root,
1105
+ guardOperation,
1106
+ 'HEAD',
1107
+ rootStat,
1108
+ );
1109
+ const identity = gitMetadataIdentity(expected);
1110
+ const transaction = {
1111
+ contract: GIT_METADATA_TRANSACTION_CONTRACT,
1112
+ backend: 'git-guard',
1113
+ root,
1114
+ rootDev: rootStat.dev.toString(),
1115
+ rootIno: rootStat.ino.toString(),
1116
+ path: 'HEAD',
1117
+ id: paths.id,
1118
+ operation: guardOperation,
1119
+ ownerPid: process.pid,
1120
+ ownerStarted: PROCESS_START_IDENTITY,
1121
+ expected: identity,
1122
+ target: identity,
1123
+ record: paths.record,
1124
+ prepared: paths.target,
1125
+ claimed: paths.claimed,
1126
+ };
1127
+
1128
+ if (fs.existsSync(paths.record)) {
1129
+ const retained = readGitMetadataTransaction(paths.record);
1130
+ if (!sameGitMetadataTransaction(retained, transaction)) {
1131
+ throw new Error('Git HEAD guard identity changed');
1132
+ }
1133
+ if (guardOwnerIsActive(retained)) {
1134
+ throw new TransitionError(
1135
+ 'recover-head-guard',
1136
+ label,
1137
+ `guard owner ${retained.ownerPid} is still active`,
1138
+ );
1139
+ }
1140
+ const recovered = runNativeFileTransaction(retained, 'recover');
1141
+ if (recovered.status !== 'released') {
1142
+ throw new TransitionError(
1143
+ 'recover-head-guard',
1144
+ label,
1145
+ recovered.reason || recovered.status,
1146
+ );
1147
+ }
1148
+ removeGitMetadataTransaction(retained);
1149
+ }
1150
+
1151
+ durable.writeJson(paths.record, {
1152
+ contract: transaction.contract,
1153
+ backend: transaction.backend,
1154
+ root: transaction.root,
1155
+ rootDev: transaction.rootDev,
1156
+ rootIno: transaction.rootIno,
1157
+ path: transaction.path,
1158
+ id: transaction.id,
1159
+ operation: transaction.operation,
1160
+ ownerPid: transaction.ownerPid,
1161
+ ownerStarted: transaction.ownerStarted,
1162
+ expected: transaction.expected,
1163
+ target: transaction.target,
1164
+ });
1165
+ durable.writeFile(paths.target, expected, 0o644);
1166
+ const claimed = runNativeFileTransaction(transaction, 'guard');
1167
+ if (claimed.status !== 'guarded') {
1168
+ removeGitMetadataTransaction(transaction);
1169
+ throw new TransitionError(
1170
+ 'guard-branch-delete',
1171
+ label,
1172
+ claimed.reason || claimed.status,
1173
+ );
1174
+ }
1175
+ try {
1176
+ return await guarded();
1177
+ } finally {
1178
+ const released = runNativeFileTransaction(transaction, 'unguard');
1179
+ if (released.status !== 'released') {
1180
+ throw new Error(`Git HEAD guard release failed: ${released.reason || released.status}`);
1181
+ }
1182
+ removeGitMetadataTransaction(transaction);
1183
+ }
1184
+ }
1185
+
1186
+ function gitWorktreeNamespaceTransactionDirectory() {
1187
+ return path.join(AMALGM_DIR, 'state', 'workspace-git-worktree-guards');
1188
+ }
1189
+
1190
+ function physicalDirectoryIdentity(directory) {
1191
+ const realpath = fs.realpathSync(directory);
1192
+ const stat = fs.statSync(realpath, { bigint: true });
1193
+ if (!stat.isDirectory()) {
1194
+ throw new Error(`physical directory identity is not a directory: ${realpath}`);
1195
+ }
1196
+ return {
1197
+ realpath,
1198
+ dev: stat.dev.toString(),
1199
+ ino: stat.ino.toString(),
1200
+ };
1201
+ }
1202
+
1203
+ function pinPhysicalDirectory(directory) {
1204
+ const realpath = fs.realpathSync(directory);
1205
+ const fd = fs.openSync(
1206
+ realpath,
1207
+ fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_DIRECTORY,
1208
+ );
1209
+ try {
1210
+ const stat = fs.fstatSync(fd, { bigint: true });
1211
+ if (!stat.isDirectory()) {
1212
+ throw new Error(`physical directory pin is not a directory: ${realpath}`);
1213
+ }
1214
+ return {
1215
+ fd,
1216
+ realpath,
1217
+ dev: stat.dev.toString(),
1218
+ ino: stat.ino.toString(),
1219
+ };
1220
+ } catch (error) {
1221
+ fs.closeSync(fd);
1222
+ throw error;
1223
+ }
1224
+ }
1225
+
1226
+ function closePhysicalDirectoryPins(pins) {
1227
+ for (const pin of [...pins].reverse()) fs.closeSync(pin.fd);
1228
+ }
1229
+
1230
+ function samePhysicalDirectory(left, right) {
1231
+ return (
1232
+ left !== null
1233
+ && right !== null
1234
+ && left.dev === right.dev
1235
+ && left.ino === right.ino
1236
+ );
1237
+ }
1238
+
1239
+ function gitCommonDirectory(worktreeRoot) {
1240
+ const value = execFileSync(
1241
+ 'git',
1242
+ ['-C', worktreeRoot, 'rev-parse', '--git-common-dir'],
1243
+ {
1244
+ cwd: worktreeRoot,
1245
+ env: { ...process.env, ...TRANSITION_ENV },
1246
+ encoding: 'utf8',
1247
+ timeout: 120_000,
1248
+ maxBuffer: 32 * 1024 * 1024,
1249
+ },
1250
+ ).trim();
1251
+ return fs.realpathSync(path.isAbsolute(value) ? value : path.resolve(worktreeRoot, value));
1252
+ }
1253
+
1254
+ function gitCommonDirectoryFromMarker(worktreeRoot) {
1255
+ const root = fs.realpathSync(worktreeRoot);
1256
+ const marker = path.join(root, '.git');
1257
+ if (fs.statSync(marker).isDirectory()) return fs.realpathSync(marker);
1258
+ const match = fs.readFileSync(marker, 'utf8').trim().match(/^gitdir:\s*(.+)$/);
1259
+ if (!match) throw new Error('worktree has no Git directory');
1260
+ const privateDirectory = path.resolve(root, match[1]);
1261
+ const registrations = path.dirname(privateDirectory);
1262
+ return fs.realpathSync(
1263
+ path.basename(registrations) === 'worktrees'
1264
+ ? path.dirname(registrations)
1265
+ : privateDirectory,
1266
+ );
1267
+ }
1268
+
1269
+ function physicalGitWorktreeRegistrations(directory) {
1270
+ return fs.readdirSync(directory)
1271
+ .sort()
1272
+ .map((name) => {
1273
+ const stat = fs.lstatSync(path.join(directory, name), { bigint: true });
1274
+ if (!stat.isDirectory()) {
1275
+ throw new Error(`Git worktree registration is not a directory: ${name}`);
1276
+ }
1277
+ return {
1278
+ name,
1279
+ dev: stat.dev.toString(),
1280
+ ino: stat.ino.toString(),
1281
+ };
1282
+ });
1283
+ }
1284
+
1285
+ function ensureGitWorktreeNamespace(commonRoot) {
1286
+ const directory = path.join(commonRoot, 'worktrees');
1287
+ try {
1288
+ fs.statSync(directory);
1289
+ } catch (error) {
1290
+ if (error?.code !== 'ENOENT') throw error;
1291
+ fs.mkdirSync(directory, { mode: 0o755 });
1292
+ syncDirectory(commonRoot);
1293
+ }
1294
+ return directory;
1295
+ }
1296
+
1297
+ function pinGitWorktreeNamespace(commonRoot) {
1298
+ const directory = ensureGitWorktreeNamespace(commonRoot);
1299
+ const pins = [];
1300
+ try {
1301
+ const namespace = pinPhysicalDirectory(directory);
1302
+ pins.push(namespace);
1303
+ const entries = fs.readdirSync(directory)
1304
+ .sort()
1305
+ .map((name) => {
1306
+ const pin = pinPhysicalDirectory(path.join(directory, name));
1307
+ pins.push(pin);
1308
+ return { name, dev: pin.dev, ino: pin.ino };
1309
+ });
1310
+ return { namespace, entries, pins };
1311
+ } catch (error) {
1312
+ closePhysicalDirectoryPins(pins);
1313
+ throw error;
1314
+ }
1315
+ }
1316
+
1317
+ function gitWorktreeNamespacePaths(commonRoot, operation, rootStat) {
1318
+ const receiptDirectory = path.join(commonRoot, 'amalgm-metadata-apply');
1319
+ const recordDirectory = gitWorktreeNamespaceTransactionDirectory();
1320
+ ensureDurableDirectory(receiptDirectory);
1321
+ ensureDurableDirectory(AMALGM_DIR);
1322
+ ensureDurableDirectory(path.join(AMALGM_DIR, 'state'));
1323
+ ensureDurableDirectory(recordDirectory);
1324
+ const id = crypto.createHash('sha256')
1325
+ .update(
1326
+ `${GIT_WORKTREE_NAMESPACE_GUARD_CONTRACT}\0${rootStat.dev}\0${rootStat.ino}`
1327
+ + `\0${operation}\0worktrees`,
1328
+ )
1329
+ .digest('hex');
1330
+ return {
1331
+ id,
1332
+ record: path.join(recordDirectory, `${id}.json`),
1333
+ target: path.join(receiptDirectory, `${id}.target`),
1334
+ claimed: path.join(receiptDirectory, `${id}.claimed`),
1335
+ };
1336
+ }
1337
+
1338
+ function readGitWorktreeNamespaceTransaction(record) {
1339
+ try {
1340
+ const value = JSON.parse(fs.readFileSync(record, 'utf8'));
1341
+ if (
1342
+ value?.contract !== GIT_WORKTREE_NAMESPACE_GUARD_CONTRACT
1343
+ || value.backend !== 'git-worktree-guard'
1344
+ || typeof value.worktree !== 'string'
1345
+ || !path.isAbsolute(value.worktree)
1346
+ || !/^\d+$/.test(value.worktreeDev)
1347
+ || !/^\d+$/.test(value.worktreeIno)
1348
+ || typeof value.root !== 'string'
1349
+ || !path.isAbsolute(value.root)
1350
+ || !/^\d+$/.test(value.rootDev)
1351
+ || !/^\d+$/.test(value.rootIno)
1352
+ || value.path !== 'worktrees'
1353
+ || value.id !== path.basename(record, '.json')
1354
+ || !/^[a-f0-9]{64}$/.test(value.id || '')
1355
+ || !/^[a-f0-9]{64}$/.test(value.operation || '')
1356
+ || !Number.isSafeInteger(value.ownerPid)
1357
+ || value.ownerPid < 1
1358
+ || typeof value.ownerStarted !== 'string'
1359
+ || !value.ownerStarted
1360
+ || value.expected?.kind !== 'directory'
1361
+ || !/^\d+$/.test(value.expected?.dev || '')
1362
+ || !/^\d+$/.test(value.expected?.ino || '')
1363
+ || value.target?.kind !== 'file'
1364
+ || !Array.isArray(value.entries)
1365
+ || value.entries.some((entry) => (
1366
+ entry === null
1367
+ || typeof entry !== 'object'
1368
+ || typeof entry.name !== 'string'
1369
+ || !entry.name
1370
+ || entry.name === '.'
1371
+ || entry.name === '..'
1372
+ || entry.name.includes('/')
1373
+ || entry.name.includes('\\')
1374
+ || !/^\d+$/.test(entry.dev || '')
1375
+ || !/^\d+$/.test(entry.ino || '')
1376
+ ))
1377
+ ) throw new Error('invalid Git worktree namespace transaction');
1378
+ return {
1379
+ ...value,
1380
+ record,
1381
+ prepared: path.join(value.root, 'amalgm-metadata-apply', `${value.id}.target`),
1382
+ claimed: path.join(value.root, 'amalgm-metadata-apply', `${value.id}.claimed`),
1383
+ };
1384
+ } catch (error) {
1385
+ throw new Error(
1386
+ `invalid workspace Git worktree namespace transaction ${record}: ${error.message}`,
1387
+ );
1388
+ }
1389
+ }
1390
+
1391
+ function gitWorktreeNamespaceTransactionRecords(worktreeRoot) {
1392
+ const directory = gitWorktreeNamespaceTransactionDirectory();
1393
+ if (!fs.existsSync(directory)) return [];
1394
+ const requested = physicalDirectoryIdentity(worktreeRoot);
1395
+ let common = null;
1396
+ try {
1397
+ common = physicalDirectoryIdentity(gitCommonDirectoryFromMarker(worktreeRoot));
1398
+ } catch {
1399
+ // A nonstandard Git layout may require Git's own common-dir resolution.
1400
+ // That is available whenever the shared registration namespace is open.
1401
+ try {
1402
+ common = physicalDirectoryIdentity(gitCommonDirectory(worktreeRoot));
1403
+ } catch {
1404
+ // The checkout identity may still surface a record for diagnosis, but
1405
+ // only a resolved matching common repository can authorize recovery.
1406
+ }
1407
+ }
1408
+ return fs.readdirSync(directory)
1409
+ .filter((name) => name.endsWith('.json'))
1410
+ .sort()
1411
+ .map((name) => readGitWorktreeNamespaceTransaction(path.join(directory, name)))
1412
+ .map((transaction) => {
1413
+ const worktreeMatches = samePhysicalDirectory(
1414
+ { dev: transaction.worktreeDev, ino: transaction.worktreeIno },
1415
+ requested,
1416
+ );
1417
+ const commonMatches = samePhysicalDirectory(
1418
+ { dev: transaction.rootDev, ino: transaction.rootIno },
1419
+ common,
1420
+ );
1421
+ if (!worktreeMatches && !commonMatches) return null;
1422
+ if (!commonMatches) {
1423
+ throw new Error(
1424
+ `workspace Git worktree guard belongs to another common repository: `
1425
+ + `${transaction.record}`,
1426
+ );
1427
+ }
1428
+ return {
1429
+ ...transaction,
1430
+ root: common.realpath,
1431
+ prepared: path.join(
1432
+ common.realpath,
1433
+ 'amalgm-metadata-apply',
1434
+ `${transaction.id}.target`,
1435
+ ),
1436
+ claimed: path.join(
1437
+ common.realpath,
1438
+ 'amalgm-metadata-apply',
1439
+ `${transaction.id}.claimed`,
1440
+ ),
1441
+ };
1442
+ })
1443
+ .filter(Boolean);
1444
+ }
1445
+
1446
+ function removeGitWorktreeNamespaceTransaction(transaction) {
1447
+ try {
1448
+ fs.unlinkSync(transaction.record);
1449
+ } catch (error) {
1450
+ if (error?.code !== 'ENOENT') throw error;
1451
+ }
1452
+ if (fs.existsSync(path.dirname(transaction.record))) {
1453
+ syncDirectory(path.dirname(transaction.record));
1454
+ }
1455
+ }
1456
+
1457
+ function recoverGitWorktreeNamespaceTransactions(worktreeRoot) {
1458
+ for (const transaction of gitWorktreeNamespaceTransactionRecords(worktreeRoot)) {
1459
+ if (guardOwnerIsActive(transaction)) {
1460
+ throw new TransitionError(
1461
+ 'recover-git-worktree-guard',
1462
+ transaction.worktree,
1463
+ `guard owner ${transaction.ownerPid} is still active`,
1464
+ );
1465
+ }
1466
+ const result = runNativeFileTransaction(transaction, 'recover');
1467
+ if (result.status !== 'released') {
1468
+ throw new TransitionError(
1469
+ 'recover-git-worktree-guard',
1470
+ transaction.root,
1471
+ result.reason || result.status,
1472
+ );
1473
+ }
1474
+ removeGitWorktreeNamespaceTransaction(transaction);
1475
+ }
1476
+ }
1477
+
1478
+ function prepareGitWorktreeNamespaceGuard(worktreeRoot) {
1479
+ recoverGitWorktreeNamespaceTransactions(worktreeRoot);
1480
+ const worktree = physicalDirectoryIdentity(worktreeRoot);
1481
+ const common = physicalDirectoryIdentity(gitCommonDirectory(worktree.realpath));
1482
+ const root = common.realpath;
1483
+ const pinned = pinGitWorktreeNamespace(root);
1484
+ const guardOperation = crypto.randomBytes(32).toString('hex');
1485
+ const paths = gitWorktreeNamespacePaths(root, guardOperation, {
1486
+ dev: common.dev,
1487
+ ino: common.ino,
1488
+ });
1489
+ const target = gitMetadataIdentity(Buffer.alloc(0));
1490
+ return {
1491
+ contract: GIT_WORKTREE_NAMESPACE_GUARD_CONTRACT,
1492
+ backend: 'git-worktree-guard',
1493
+ worktree: worktree.realpath,
1494
+ worktreeDev: worktree.dev,
1495
+ worktreeIno: worktree.ino,
1496
+ root,
1497
+ rootDev: common.dev,
1498
+ rootIno: common.ino,
1499
+ path: 'worktrees',
1500
+ id: paths.id,
1501
+ operation: guardOperation,
1502
+ ownerPid: process.pid,
1503
+ ownerStarted: PROCESS_START_IDENTITY,
1504
+ expected: {
1505
+ kind: 'directory',
1506
+ dev: pinned.namespace.dev,
1507
+ ino: pinned.namespace.ino,
1508
+ },
1509
+ target,
1510
+ entries: pinned.entries,
1511
+ pins: pinned.pins,
1512
+ gitRoots: [
1513
+ root,
1514
+ ...pinned.pins.slice(1).map((pin) => pin.realpath),
1515
+ ].sort(),
1516
+ record: paths.record,
1517
+ prepared: paths.target,
1518
+ claimed: paths.claimed,
1519
+ };
1520
+ }
1521
+
1522
+ async function withGitWorktreeNamespaceGuard(transaction, testHooks, guarded) {
1523
+ try {
1524
+ durable.writeJson(transaction.record, {
1525
+ contract: transaction.contract,
1526
+ backend: transaction.backend,
1527
+ worktree: transaction.worktree,
1528
+ worktreeDev: transaction.worktreeDev,
1529
+ worktreeIno: transaction.worktreeIno,
1530
+ root: transaction.root,
1531
+ rootDev: transaction.rootDev,
1532
+ rootIno: transaction.rootIno,
1533
+ path: transaction.path,
1534
+ id: transaction.id,
1535
+ operation: transaction.operation,
1536
+ ownerPid: transaction.ownerPid,
1537
+ ownerStarted: transaction.ownerStarted,
1538
+ expected: transaction.expected,
1539
+ target: transaction.target,
1540
+ entries: transaction.entries,
1541
+ });
1542
+ durable.writeFile(transaction.prepared, Buffer.alloc(0), 0o644);
1543
+ const result = runNativeFileTransaction(transaction, 'guard');
1544
+ if (result.status !== 'guarded') {
1545
+ throw new TransitionError(
1546
+ 'guard-git-worktree-namespace',
1547
+ transaction.root,
1548
+ result.reason || result.status,
1549
+ );
1550
+ }
1551
+ const guardedEntries = physicalGitWorktreeRegistrations(transaction.prepared);
1552
+ if (JSON.stringify(guardedEntries) !== JSON.stringify(transaction.entries)) {
1553
+ throw new TransitionError(
1554
+ 'guard-git-worktree-namespace',
1555
+ transaction.root,
1556
+ 'Git worktree registrations changed before namespace exclusion',
1557
+ );
1558
+ }
1559
+ if (typeof testHooks.afterWorktreeNamespaceGuard === 'function') {
1560
+ await testHooks.afterWorktreeNamespaceGuard();
1561
+ }
1562
+ return await guarded(transaction.root);
1563
+ } finally {
1564
+ const released = runNativeFileTransaction(transaction, 'unguard');
1565
+ if (released.status !== 'released') {
1566
+ throw new Error(
1567
+ `Git worktree namespace guard release failed: ${released.reason || released.status}`,
1568
+ );
1569
+ }
1570
+ removeGitWorktreeNamespaceTransaction(transaction);
1571
+ }
1572
+ }
1573
+
1574
+ async function withGitHeadGuard(
1575
+ worktreeRoot,
1576
+ changes,
1577
+ testHooks,
1578
+ guarded,
1579
+ ) {
1580
+ const namespace = prepareGitWorktreeNamespaceGuard(worktreeRoot);
1581
+ try {
1582
+ const acquire = (index) => (
1583
+ index === namespace.gitRoots.length
1584
+ ? (async () => {
1585
+ if (typeof testHooks.afterHeadGuard === 'function') {
1586
+ await testHooks.afterHeadGuard();
1587
+ }
1588
+ return withGitWorktreeNamespaceGuard(namespace, testHooks, guarded);
1589
+ })()
1590
+ : withOneGitHeadGuard(
1591
+ namespace.gitRoots[index],
1592
+ worktreeRoot,
1593
+ changes,
1594
+ () => acquire(index + 1),
1595
+ )
1596
+ );
1597
+ return await acquire(0);
1598
+ } finally {
1599
+ closePhysicalDirectoryPins(namespace.pins);
1600
+ }
1601
+ }
1602
+
1603
+ function gitMetadataIdentity(content) {
1604
+ if (content === null) return null;
1605
+ if (!Buffer.isBuffer(content)) throw new Error('Git metadata has no exact bytes');
1606
+ return {
1607
+ kind: 'file',
1608
+ exec: false,
1609
+ bytes: content.length,
1610
+ sha256: crypto.createHash('sha256').update(content).digest('hex'),
1611
+ };
1612
+ }
1613
+
1614
+ function gitMetadataTransactionPathsForRoot(root, operation, component, rootStat) {
1615
+ if (!/^[0-9a-f]{64}$/.test(operation || '')) {
1616
+ throw new Error('Git metadata transaction has no operation identity');
1617
+ }
1618
+ if (component !== 'index' && component !== 'HEAD') {
1619
+ throw new Error(`unsupported Git metadata component: ${component}`);
1620
+ }
1621
+ const directory = gitMetadataTransactionDirectoryForRoot(root);
1622
+ ensureDurableDirectory(directory);
1623
+ const id = crypto.createHash('sha256')
1624
+ .update(
1625
+ `${GIT_METADATA_TRANSACTION_CONTRACT}\0${rootStat.dev}\0${rootStat.ino}`
1626
+ + `\0${operation}\0${component}`,
1627
+ )
1628
+ .digest('hex');
1629
+ return {
1630
+ id,
1631
+ directory,
1632
+ record: path.join(directory, `${id}.json`),
1633
+ target: path.join(directory, `${id}.target`),
1634
+ claimed: path.join(directory, `${id}.claimed`),
1635
+ };
1636
+ }
1637
+
1638
+ function gitMetadataTransactionPaths(worktreeRoot, operation, component, rootStat) {
1639
+ return gitMetadataTransactionPathsForRoot(
1640
+ gitDirectory(worktreeRoot),
1641
+ operation,
1642
+ component,
1643
+ rootStat,
1644
+ );
1645
+ }
1646
+
1647
+ function readGitMetadataTransaction(record) {
1648
+ try {
1649
+ const value = JSON.parse(fs.readFileSync(record, 'utf8'));
1650
+ if (
1651
+ value?.contract !== GIT_METADATA_TRANSACTION_CONTRACT
1652
+ || (value.backend !== 'git-lock' && value.backend !== 'git-guard')
1653
+ || typeof value.root !== 'string'
1654
+ || !/^\d+$/.test(value.rootDev)
1655
+ || !/^\d+$/.test(value.rootIno)
1656
+ || (value.path !== 'index' && value.path !== 'HEAD')
1657
+ || typeof value.id !== 'string'
1658
+ || value.id !== path.basename(record, '.json')
1659
+ || !/^[a-f0-9]{64}$/.test(value.id)
1660
+ || !/^[a-f0-9]{64}$/.test(value.operation || '')
1661
+ || (
1662
+ value.backend === 'git-guard'
1663
+ && (
1664
+ !Number.isSafeInteger(value.ownerPid)
1665
+ || value.ownerPid < 1
1666
+ || typeof value.ownerStarted !== 'string'
1667
+ || !value.ownerStarted
1668
+ )
1669
+ )
1670
+ || (value.expected !== null && typeof value.expected !== 'object')
1671
+ || typeof value.target !== 'object'
1672
+ ) throw new Error('invalid Git metadata transaction');
1673
+ return {
1674
+ ...value,
1675
+ record,
1676
+ prepared: path.join(path.dirname(record), `${value.id}.target`),
1677
+ claimed: path.join(path.dirname(record), `${value.id}.claimed`),
1678
+ };
1679
+ } catch (error) {
1680
+ throw new Error(`invalid workspace Git metadata transaction ${record}: ${error.message}`);
1681
+ }
1682
+ }
1683
+
1684
+ function gitMetadataTransactionRecordsAtRoot(rootInput, operation = null, component = null) {
1685
+ const root = physicalDirectoryIdentity(rootInput);
1686
+ const directory = gitMetadataTransactionDirectoryForRoot(root.realpath);
1687
+ if (!fs.existsSync(directory)) return [];
1688
+ return fs.readdirSync(directory)
1689
+ .filter((name) => name.endsWith('.json'))
1690
+ .sort()
1691
+ .map((name) => readGitMetadataTransaction(path.join(directory, name)))
1692
+ .map((transaction) => {
1693
+ if (!samePhysicalDirectory(
1694
+ { dev: transaction.rootDev, ino: transaction.rootIno },
1695
+ root,
1696
+ )) {
1697
+ throw new Error(`Git metadata transaction escaped its worktree: ${transaction.record}`);
1698
+ }
1699
+ return { ...transaction, root: root.realpath };
1700
+ })
1701
+ .filter((transaction) => operation === null || transaction.operation === operation)
1702
+ .filter((transaction) => component === null || transaction.path === component);
1703
+ }
1704
+
1705
+ function gitMetadataTransactionRecords(worktreeRoot, operation = null, component = null) {
1706
+ return gitMetadataTransactionRecordsAtRoot(
1707
+ gitDirectory(worktreeRoot),
1708
+ operation,
1709
+ component,
1710
+ );
1711
+ }
1712
+
1713
+ function sameGitMetadataTransaction(left, right) {
1714
+ return (
1715
+ left.contract === right.contract
1716
+ && left.backend === right.backend
1717
+ && left.rootDev === right.rootDev
1718
+ && left.rootIno === right.rootIno
1719
+ && left.path === right.path
1720
+ && left.id === right.id
1721
+ && left.operation === right.operation
1722
+ && JSON.stringify(left.expected) === JSON.stringify(right.expected)
1723
+ && JSON.stringify(left.target) === JSON.stringify(right.target)
1724
+ );
1725
+ }
1726
+
1727
+ function removeGitMetadataTransaction(transaction) {
1728
+ for (const file of [transaction.record, transaction.prepared, transaction.claimed]) {
1729
+ try {
1730
+ fs.unlinkSync(file);
1731
+ } catch (error) {
1732
+ if (error?.code !== 'ENOENT') throw error;
1733
+ }
1734
+ }
1735
+ if (fs.existsSync(path.dirname(transaction.record))) {
1736
+ syncDirectory(path.dirname(transaction.record));
1737
+ }
1738
+ }
1739
+
1740
+ /**
1741
+ * Apply one exact index or HEAD byte transition through Git's canonical lock.
1742
+ * The prepared bytes stay in the per-worktree Git directory and are hard
1743
+ * linked to index.lock/HEAD.lock by the native helper, so even very large
1744
+ * indexes never travel through JSON.
1745
+ */
1746
+ function applyGitMetadataTransaction(
1747
+ worktreeRoot,
1748
+ component,
1749
+ expected,
1750
+ target,
1751
+ operation,
1752
+ testHooks = {},
1753
+ ) {
1754
+ if (expected !== null && !Buffer.isBuffer(expected)) {
1755
+ throw new Error('Git metadata expected value has no exact bytes');
1756
+ }
1757
+ if (!Buffer.isBuffer(target)) throw new Error('Git metadata target has no exact bytes');
1758
+ const root = fs.realpathSync(gitDirectory(worktreeRoot));
1759
+ const rootStat = fs.statSync(root, { bigint: true });
1760
+ const paths = gitMetadataTransactionPaths(worktreeRoot, operation, component, rootStat);
1761
+ const transaction = {
1762
+ contract: GIT_METADATA_TRANSACTION_CONTRACT,
1763
+ backend: 'git-lock',
1764
+ root,
1765
+ rootDev: rootStat.dev.toString(),
1766
+ rootIno: rootStat.ino.toString(),
1767
+ path: component,
1768
+ id: paths.id,
1769
+ operation,
1770
+ expected: gitMetadataIdentity(expected),
1771
+ target: gitMetadataIdentity(target),
1772
+ record: paths.record,
1773
+ prepared: paths.target,
1774
+ claimed: paths.claimed,
1775
+ };
1776
+
1777
+ if (fs.existsSync(paths.record)) {
1778
+ const retained = readGitMetadataTransaction(paths.record);
1779
+ if (!sameGitMetadataTransaction(retained, transaction)) {
1780
+ throw new Error(`Git metadata transaction identity changed: ${component}`);
1781
+ }
1782
+ const recovered = runNativeFileTransaction(retained, 'recover');
1783
+ if (recovered.status === 'applied') return;
1784
+ if (recovered.status === 'pending') {
1785
+ throw new TransitionError(
1786
+ 'apply-git-metadata',
1787
+ worktreeRoot,
1788
+ `${component}: ${recovered.reason || 'ownership is pending'}`,
1789
+ );
1790
+ }
1791
+ removeGitMetadataTransaction(retained);
1792
+ }
1793
+
1794
+ durable.writeJson(paths.record, {
1795
+ contract: transaction.contract,
1796
+ backend: transaction.backend,
1797
+ root: transaction.root,
1798
+ rootDev: transaction.rootDev,
1799
+ rootIno: transaction.rootIno,
1800
+ path: transaction.path,
1801
+ id: transaction.id,
1802
+ operation: transaction.operation,
1803
+ expected: transaction.expected,
1804
+ target: transaction.target,
1805
+ });
1806
+ durable.writeFile(paths.target, target, 0o644);
1807
+ const result = runNativeFileTransaction(transaction, 'apply', null, {
1808
+ crashBeforeInstall: testHooks.crashBeforeInstall === true,
1809
+ crashAfterInstall: testHooks.crashAfterInstall === true,
1810
+ });
1811
+ if (result.status === 'pending') {
1812
+ throw new TransitionError(
1813
+ 'apply-git-metadata',
1814
+ worktreeRoot,
1815
+ `${component}: ${result.reason || 'ownership is pending'}`,
1816
+ );
1817
+ }
1818
+ if (result.status === 'changed') {
1819
+ removeGitMetadataTransaction(transaction);
1820
+ throw new TransitionError(
1821
+ 'apply-git-metadata',
1822
+ worktreeRoot,
1823
+ `${component} changed before ordered apply`,
1824
+ );
1825
+ }
1826
+ }
1827
+
1828
+ function recoverGitMetadataTransactionsAtRoot(root, label) {
1829
+ for (const transaction of gitMetadataTransactionRecordsAtRoot(root)) {
1830
+ if (transaction.backend === 'git-guard' && guardOwnerIsActive(transaction)) {
1831
+ throw new TransitionError(
1832
+ 'recover-git-head-guard',
1833
+ label,
1834
+ `guard owner ${transaction.ownerPid} is still active`,
1835
+ );
1836
+ }
1837
+ const result = runNativeFileTransaction(transaction, 'recover');
1838
+ if (transaction.backend === 'git-guard') {
1839
+ if (result.status !== 'released') {
1840
+ throw new TransitionError(
1841
+ 'recover-git-head-guard',
1842
+ label,
1843
+ result.reason || result.status,
1844
+ );
1845
+ }
1846
+ removeGitMetadataTransaction(transaction);
1847
+ continue;
1848
+ }
1849
+ if (result.status === 'pending') {
1850
+ throw new TransitionError(
1851
+ 'recover-git-metadata',
1852
+ label,
1853
+ `${transaction.path}: ${result.reason || 'ownership is pending'}`,
1854
+ );
1855
+ }
1856
+ if (result.status === 'changed') removeGitMetadataTransaction(transaction);
1857
+ }
1858
+ }
1859
+
1860
+ function gitHeadRoots(worktreeRoot) {
1861
+ const common = gitCommonDirectoryFromMarker(worktreeRoot);
1862
+ const registrations = path.join(common, 'worktrees');
1863
+ let names = [];
1864
+ try {
1865
+ names = fs.readdirSync(registrations).sort();
1866
+ } catch (error) {
1867
+ if (error?.code !== 'ENOENT') throw error;
1868
+ }
1869
+ return [
1870
+ common,
1871
+ ...names.map((name) => path.join(registrations, name)),
1872
+ ];
1873
+ }
1874
+
1875
+ function recoverGitMetadataTransactions(worktreeRoot) {
1876
+ recoverGitWorktreeNamespaceTransactions(worktreeRoot);
1877
+ for (const root of gitHeadRoots(worktreeRoot)) {
1878
+ try {
1879
+ recoverGitMetadataTransactionsAtRoot(root, worktreeRoot);
1880
+ } catch (error) {
1881
+ if (error?.code === 'ENOENT') continue;
1882
+ throw error;
1883
+ }
1884
+ }
1885
+ }
1886
+
1887
+ function gitMetadataReceiptCrossed(worktreeRoot, operation, component) {
1888
+ recoverGitMetadataTransactions(worktreeRoot);
1889
+ const records = gitMetadataTransactionRecords(worktreeRoot, operation, component);
1890
+ if (records.length > 1) {
1891
+ throw new Error(`multiple Git metadata receipts exist for ${component}`);
1892
+ }
1893
+ if (records.length === 0) return false;
1894
+ const result = runNativeFileTransaction(records[0], 'receipt');
1895
+ if (result.status === 'crossed') return true;
1896
+ if (result.status === 'not-crossed') return false;
1897
+ throw new Error(
1898
+ `Git metadata receipt is unresolved: ${component}: ${result.reason || result.status}`,
1899
+ );
1900
+ }
1901
+
1902
+ function releaseGitMetadataReceipts(worktreeRoot, operation, component = null) {
1903
+ for (const transaction of gitMetadataTransactionRecords(worktreeRoot, operation, component)) {
1904
+ const result = runNativeFileTransaction(transaction, 'release');
1905
+ if (result.status === 'pending') {
1906
+ throw new Error(
1907
+ `Git metadata receipt cannot be released: ${transaction.path}: `
1908
+ + `${result.reason || 'pending'}`,
1909
+ );
1910
+ }
1911
+ if (result.status !== 'released') {
1912
+ throw new Error(
1913
+ `Git metadata receipt release failed: ${transaction.path}: ${result.status}`,
1914
+ );
1915
+ }
1916
+ removeGitMetadataTransaction(transaction);
1917
+ }
1918
+ }
1919
+
386
1920
  module.exports = {
387
1921
  TransitionError,
1922
+ applyGitMetadataTransaction,
388
1923
  applyIndexPatch,
389
1924
  applyFiles,
390
- applyPatch,
391
- checkoutInitialCard,
1925
+ branchReceiptCrossed,
1926
+ confinedPath,
392
1927
  createBundle,
393
- deleteBranchRef,
394
- fastForwardTo,
395
- fetchBranch,
396
- forceMoveToCard,
397
- holdInitialFiles,
1928
+ emptyTree,
1929
+ ensureEmptyIndex,
1930
+ gitMetadataReceiptCrossed,
398
1931
  importBundle,
1932
+ initializeHeadToCard,
399
1933
  initializeRepo,
400
- moveToCard,
401
- parkCommit,
402
- pruneParkedCommits,
403
- restoreInitialFiles,
404
- setBranchRef,
405
- setIndexTree,
1934
+ isApplyReceiptPath,
1935
+ moveHeadToCard,
1936
+ crossedFileReceiptPaths,
1937
+ recoverFileTransactions,
1938
+ recoverGitMetadataTransactions,
1939
+ releaseBranchReceipt,
1940
+ releaseFileReceipts,
1941
+ releaseGitMetadataReceipts,
1942
+ updateBranchRefs,
406
1943
  };