amalgm 0.1.238 → 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.
- package/lib/shared-realtime-tunnel.js +22 -14
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +81 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +114 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +119 -54
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- 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 —
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* rail carries its settled outcome.
|
|
14
|
-
* lands as one line in a visible
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
201
|
-
*
|
|
202
|
-
*
|
|
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
|
|
231
|
+
async function compareAndSwapHead(worktreeRoot, {
|
|
205
232
|
expectedBranch,
|
|
206
233
|
expectedOid,
|
|
207
234
|
targetBranch,
|
|
208
235
|
targetOid,
|
|
236
|
+
operation,
|
|
209
237
|
}) {
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
'-C', worktreeRoot, '
|
|
241
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
*
|
|
272
|
-
*
|
|
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
|
|
277
|
-
expectedBranch,
|
|
278
|
-
expectedOid,
|
|
401
|
+
async function initializeHeadToCard(worktreeRoot, {
|
|
279
402
|
targetBranch,
|
|
280
403
|
targetOid,
|
|
404
|
+
operation,
|
|
281
405
|
}) {
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
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
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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.
|
|
318
|
-
*
|
|
319
|
-
*
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
358
|
-
if (
|
|
359
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
391
|
-
|
|
1925
|
+
branchReceiptCrossed,
|
|
1926
|
+
confinedPath,
|
|
392
1927
|
createBundle,
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
forceMoveToCard,
|
|
397
|
-
holdInitialFiles,
|
|
1928
|
+
emptyTree,
|
|
1929
|
+
ensureEmptyIndex,
|
|
1930
|
+
gitMetadataReceiptCrossed,
|
|
398
1931
|
importBundle,
|
|
1932
|
+
initializeHeadToCard,
|
|
399
1933
|
initializeRepo,
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
1934
|
+
isApplyReceiptPath,
|
|
1935
|
+
moveHeadToCard,
|
|
1936
|
+
crossedFileReceiptPaths,
|
|
1937
|
+
recoverFileTransactions,
|
|
1938
|
+
recoverGitMetadataTransactions,
|
|
1939
|
+
releaseBranchReceipt,
|
|
1940
|
+
releaseFileReceipts,
|
|
1941
|
+
releaseGitMetadataReceipts,
|
|
1942
|
+
updateBranchRefs,
|
|
406
1943
|
};
|