amalgm 0.1.220 → 0.1.234
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -1
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +39 -2
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +180 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +47 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +28 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +152 -12
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +6 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +105 -67
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +214 -23
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +99 -206
- package/runtime/scripts/amalgm-mcp/workspace/card.js +30 -9
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +3 -2
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +194 -20
- package/runtime/scripts/amalgm-mcp/workspace/git.js +23 -5
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +24 -186
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +13 -1
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +323 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +127 -104
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +106 -137
|
@@ -25,7 +25,7 @@ const { machineId } = require('./identity');
|
|
|
25
25
|
const { listRepoCards } = require('./cards');
|
|
26
26
|
const { captureCheckpoint } = require('./git');
|
|
27
27
|
|
|
28
|
-
const CHECKPOINT_CONTRACT = 'repo-checkpoint@
|
|
28
|
+
const CHECKPOINT_CONTRACT = 'repo-checkpoint@9';
|
|
29
29
|
const MAX_INLINE_DIFF_BYTES = 512 * 1024;
|
|
30
30
|
const PUBLISH_QUIET_MS = 25;
|
|
31
31
|
const RECONCILE_INTERVAL_MS = 30_000;
|
|
@@ -54,14 +54,45 @@ function checkpointFile(machine, repoKey) {
|
|
|
54
54
|
.update(`${CHECKPOINT_CONTRACT}|${machine}|${repoKey}`)
|
|
55
55
|
.digest('hex')
|
|
56
56
|
.slice(0, 24);
|
|
57
|
-
return path.join(AMALGM_DIR, 'state', 'repo-checkpoints-
|
|
57
|
+
return path.join(AMALGM_DIR, 'state', 'repo-checkpoints-v9', `${digest}.json`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Git can print the same tree in different file order depending on whether
|
|
62
|
+
* a path is tracked or still untracked. File order is transport formatting,
|
|
63
|
+
* not content identity, so sort complete file patches before hashing them.
|
|
64
|
+
*/
|
|
65
|
+
function patchSections(diff) {
|
|
66
|
+
if (!diff) return [];
|
|
67
|
+
if (!diff.startsWith('diff --git ')) return [diff];
|
|
68
|
+
const sections = [];
|
|
69
|
+
let start = 0;
|
|
70
|
+
for (;;) {
|
|
71
|
+
const next = diff.indexOf('\ndiff --git ', start + 1);
|
|
72
|
+
if (next === -1) {
|
|
73
|
+
sections.push(diff.slice(start));
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
sections.push(diff.slice(start, next + 1));
|
|
77
|
+
start = next + 1;
|
|
78
|
+
}
|
|
79
|
+
return sections.sort((left, right) => (
|
|
80
|
+
left < right ? -1 : left > right ? 1 : 0
|
|
81
|
+
));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function canonicalPatch(diff) {
|
|
85
|
+
return patchSections(diff).join('');
|
|
58
86
|
}
|
|
59
87
|
|
|
60
88
|
function revisionOf(baseOid, indexDiff, worktreeDiff = '') {
|
|
61
89
|
return indexDiff === '' && worktreeDiff === ''
|
|
62
90
|
? null
|
|
63
91
|
: crypto.createHash('sha256')
|
|
64
|
-
.update(
|
|
92
|
+
.update(
|
|
93
|
+
`${baseOid}\nindex\n${canonicalPatch(indexDiff)}\nworktree\n${canonicalPatch(worktreeDiff)}`,
|
|
94
|
+
'utf8',
|
|
95
|
+
)
|
|
65
96
|
.digest('hex');
|
|
66
97
|
}
|
|
67
98
|
|
|
@@ -89,6 +120,17 @@ function readCheckpoint(machine, repoKey) {
|
|
|
89
120
|
}
|
|
90
121
|
}
|
|
91
122
|
|
|
123
|
+
function stampCheckpointMutation(machine, repoKey, checkpoint, mutationId) {
|
|
124
|
+
if (!mutationId) return false;
|
|
125
|
+
const existing = readCheckpoint(machine, repoKey);
|
|
126
|
+
if (!existing || !sameTransition(existing, checkpoint)) return false;
|
|
127
|
+
if (existing.mutationId === mutationId) return true;
|
|
128
|
+
const file = checkpointFile(machine, repoKey);
|
|
129
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
130
|
+
fs.writeFileSync(file, `${JSON.stringify({ ...existing, mutationId })}\n`);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
92
134
|
function valueIdentity(value) {
|
|
93
135
|
return JSON.stringify({
|
|
94
136
|
revision: value?.revision ?? null,
|
|
@@ -113,25 +155,100 @@ function sameTransition(left, right) {
|
|
|
113
155
|
&& parkedIdentity(left.parked) === parkedIdentity(right.parked);
|
|
114
156
|
}
|
|
115
157
|
|
|
116
|
-
function
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
if (bytes.length <= MAX_INLINE_DIFF_BYTES) return { revision, indexDiff, worktreeDiff };
|
|
158
|
+
function sectionEntry(machine, repoKey, baseOid, layer, text, external) {
|
|
159
|
+
if (!external) return { text };
|
|
160
|
+
const digest = crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
120
161
|
return {
|
|
121
|
-
revision,
|
|
122
162
|
payload: require('./objects').publishPayload(
|
|
123
|
-
`${CHECKPOINT_CONTRACT}\n${machine}\n${repoKey}\n${baseOid}\n${
|
|
124
|
-
|
|
163
|
+
`${CHECKPOINT_CONTRACT}\n${machine}\n${repoKey}\n${baseOid}\n${layer}\n${digest}`,
|
|
164
|
+
Buffer.from(text, 'utf8'),
|
|
125
165
|
repoKey,
|
|
126
166
|
),
|
|
127
167
|
};
|
|
128
168
|
}
|
|
129
169
|
|
|
170
|
+
function checkpointValue(machine, repoKey, baseOid, indexDiff, worktreeDiff) {
|
|
171
|
+
const revision = revisionOf(baseOid, indexDiff, worktreeDiff);
|
|
172
|
+
const canonicalIndex = canonicalPatch(indexDiff);
|
|
173
|
+
const canonicalWorktree = canonicalPatch(worktreeDiff);
|
|
174
|
+
const bytes = Buffer.from(
|
|
175
|
+
JSON.stringify({ indexDiff: canonicalIndex, worktreeDiff: canonicalWorktree }),
|
|
176
|
+
'utf8',
|
|
177
|
+
);
|
|
178
|
+
if (bytes.length <= MAX_INLINE_DIFF_BYTES) {
|
|
179
|
+
return { revision, indexDiff: canonicalIndex, worktreeDiff: canonicalWorktree };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const sections = [
|
|
183
|
+
...patchSections(canonicalIndex).map((text, index) => ({
|
|
184
|
+
key: `index:${index}`,
|
|
185
|
+
layer: 'index',
|
|
186
|
+
text,
|
|
187
|
+
bytes: Buffer.byteLength(text, 'utf8'),
|
|
188
|
+
})),
|
|
189
|
+
...patchSections(canonicalWorktree).map((text, index) => ({
|
|
190
|
+
key: `worktree:${index}`,
|
|
191
|
+
layer: 'worktree',
|
|
192
|
+
text,
|
|
193
|
+
bytes: Buffer.byteLength(text, 'utf8'),
|
|
194
|
+
})),
|
|
195
|
+
];
|
|
196
|
+
const external = new Set(
|
|
197
|
+
sections.filter((section) => section.bytes > MAX_INLINE_DIFF_BYTES)
|
|
198
|
+
.map((section) => section.key),
|
|
199
|
+
);
|
|
200
|
+
let inlineBytes = sections
|
|
201
|
+
.filter((section) => !external.has(section.key))
|
|
202
|
+
.reduce((total, section) => total + section.bytes, 0);
|
|
203
|
+
for (const section of sections
|
|
204
|
+
.filter((candidate) => !external.has(candidate.key))
|
|
205
|
+
.sort((left, right) => right.bytes - left.bytes || left.key.localeCompare(right.key))) {
|
|
206
|
+
if (inlineBytes <= MAX_INLINE_DIFF_BYTES) break;
|
|
207
|
+
external.add(section.key);
|
|
208
|
+
inlineBytes -= section.bytes;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
revision,
|
|
213
|
+
sections: {
|
|
214
|
+
index: sections
|
|
215
|
+
.filter((section) => section.layer === 'index')
|
|
216
|
+
.map((section) => sectionEntry(
|
|
217
|
+
machine,
|
|
218
|
+
repoKey,
|
|
219
|
+
baseOid,
|
|
220
|
+
section.layer,
|
|
221
|
+
section.text,
|
|
222
|
+
external.has(section.key),
|
|
223
|
+
)),
|
|
224
|
+
worktree: sections
|
|
225
|
+
.filter((section) => section.layer === 'worktree')
|
|
226
|
+
.map((section) => sectionEntry(
|
|
227
|
+
machine,
|
|
228
|
+
repoKey,
|
|
229
|
+
baseOid,
|
|
230
|
+
section.layer,
|
|
231
|
+
section.text,
|
|
232
|
+
external.has(section.key),
|
|
233
|
+
)),
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
130
238
|
function checkpointValueReady(value) {
|
|
239
|
+
if (value?.sections) {
|
|
240
|
+
return ['index', 'worktree'].every((layer) => (
|
|
241
|
+
Array.isArray(value.sections[layer])
|
|
242
|
+
&& value.sections[layer].every((section) => (
|
|
243
|
+
typeof section?.text === 'string'
|
|
244
|
+
|| section?.payload?.kind === 'chunks'
|
|
245
|
+
))
|
|
246
|
+
));
|
|
247
|
+
}
|
|
131
248
|
return !value?.payload || value.payload.kind === 'chunks';
|
|
132
249
|
}
|
|
133
250
|
|
|
134
|
-
function checkpointForSharedState(
|
|
251
|
+
function checkpointForSharedState({
|
|
135
252
|
machine,
|
|
136
253
|
repoKey,
|
|
137
254
|
branch,
|
|
@@ -140,9 +257,6 @@ function checkpointForSharedState(existing, {
|
|
|
140
257
|
parked,
|
|
141
258
|
capturedAt = new Date().toISOString(),
|
|
142
259
|
}) {
|
|
143
|
-
if (existing?.baseOid === baseOid && existing.branch === branch) {
|
|
144
|
-
return { ...existing, parked };
|
|
145
|
-
}
|
|
146
260
|
if (parked.length === 0) return null;
|
|
147
261
|
return {
|
|
148
262
|
checkpoint: CHECKPOINT_CONTRACT,
|
|
@@ -158,9 +272,12 @@ function checkpointForSharedState(existing, {
|
|
|
158
272
|
}
|
|
159
273
|
|
|
160
274
|
function localParkedEntries(existing, reduced, card) {
|
|
275
|
+
const published = new Set((reduced?.parked || []).filter((entry) => (
|
|
276
|
+
Number.isInteger(entry.version) && entry.version > 0
|
|
277
|
+
)).map((entry) => entry.oid));
|
|
161
278
|
const held = existing?.baseOid === card.head?.oid
|
|
162
279
|
&& existing.branch === card.head?.branch
|
|
163
|
-
? existing.parked || []
|
|
280
|
+
? (existing.parked || []).filter((entry) => !published.has(entry.oid))
|
|
164
281
|
: [];
|
|
165
282
|
const unpublished = (reduced?.parked || []).filter((entry) => (
|
|
166
283
|
!Number.isInteger(entry.version) || entry.version < 1
|
|
@@ -171,6 +288,36 @@ function localParkedEntries(existing, reduced, card) {
|
|
|
171
288
|
function readCheckpointValue(baseOid, value) {
|
|
172
289
|
let indexDiff = typeof value?.indexDiff === 'string' ? value.indexDiff : '';
|
|
173
290
|
let worktreeDiff = typeof value?.worktreeDiff === 'string' ? value.worktreeDiff : '';
|
|
291
|
+
if (value?.sections) {
|
|
292
|
+
const readSections = (layer) => {
|
|
293
|
+
if (!Array.isArray(value.sections[layer])) return { state: 'payload-invalid', text: null };
|
|
294
|
+
const parts = [];
|
|
295
|
+
for (const section of value.sections[layer]) {
|
|
296
|
+
if (typeof section?.text === 'string') {
|
|
297
|
+
parts.push(section.text);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (section?.payload?.kind === 'pending') {
|
|
301
|
+
return { state: 'payload-pending', text: null };
|
|
302
|
+
}
|
|
303
|
+
if (!section?.payload) return { state: 'payload-invalid', text: null };
|
|
304
|
+
const payload = require('./objects').readPayload(section.payload);
|
|
305
|
+
if (payload.state !== 'ready') return { state: payload.state, text: null };
|
|
306
|
+
parts.push(payload.bytes.toString('utf8'));
|
|
307
|
+
}
|
|
308
|
+
return { state: 'ready', text: parts.join('') };
|
|
309
|
+
};
|
|
310
|
+
const index = readSections('index');
|
|
311
|
+
if (index.state !== 'ready') {
|
|
312
|
+
return { state: index.state, indexDiff: null, worktreeDiff: null };
|
|
313
|
+
}
|
|
314
|
+
const worktree = readSections('worktree');
|
|
315
|
+
if (worktree.state !== 'ready') {
|
|
316
|
+
return { state: worktree.state, indexDiff: null, worktreeDiff: null };
|
|
317
|
+
}
|
|
318
|
+
indexDiff = index.text;
|
|
319
|
+
worktreeDiff = worktree.text;
|
|
320
|
+
}
|
|
174
321
|
if (value?.payload) {
|
|
175
322
|
if (value.payload.kind === 'pending') {
|
|
176
323
|
return { state: 'payload-pending', indexDiff: null, worktreeDiff: null };
|
|
@@ -208,6 +355,17 @@ function checkpointBaseReady(card, reduced, publication) {
|
|
|
208
355
|
);
|
|
209
356
|
}
|
|
210
357
|
|
|
358
|
+
function checkpointWasAcknowledged(checkpoint, card, localRevision, version) {
|
|
359
|
+
return Boolean(
|
|
360
|
+
checkpoint
|
|
361
|
+
&& Number.isInteger(version)
|
|
362
|
+
&& version > 0
|
|
363
|
+
&& checkpoint.baseOid === card.head?.oid
|
|
364
|
+
&& checkpoint.branch === card.head?.branch
|
|
365
|
+
&& checkpoint.to?.revision === localRevision
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
211
369
|
function checkpointMayPublish(repoKey) {
|
|
212
370
|
return !require('./reducer').isMaterializing(repoKey);
|
|
213
371
|
}
|
|
@@ -250,12 +408,11 @@ async function publishPass(replicaId = null) {
|
|
|
250
408
|
continue;
|
|
251
409
|
}
|
|
252
410
|
|
|
253
|
-
// Exact equality means the working tree
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
// a no-op transition; the working tree itself is never echoed.
|
|
411
|
+
// Exact equality means the working tree has no pending file write.
|
|
412
|
+
// Remove an accepted local transition. A newly archived commit still
|
|
413
|
+
// rides as metadata on one no-op transition until the rail accepts it.
|
|
257
414
|
if (sharedRevision === localRevision) {
|
|
258
|
-
const refreshed = checkpointForSharedState(
|
|
415
|
+
const refreshed = checkpointForSharedState({
|
|
259
416
|
machine,
|
|
260
417
|
repoKey,
|
|
261
418
|
branch: card.head.branch,
|
|
@@ -269,6 +426,21 @@ async function publishPass(replicaId = null) {
|
|
|
269
426
|
),
|
|
270
427
|
parked,
|
|
271
428
|
});
|
|
429
|
+
if (!refreshed) {
|
|
430
|
+
const project = wall.projectForRepo(repoKey);
|
|
431
|
+
const version = wall.mutationVersion(
|
|
432
|
+
project?.resourceId,
|
|
433
|
+
existing?.mutationId,
|
|
434
|
+
repoKey,
|
|
435
|
+
);
|
|
436
|
+
if (checkpointWasAcknowledged(existing, card, localRevision, version)) {
|
|
437
|
+
run.skips += 1;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
fs.rmSync(file, { force: true });
|
|
441
|
+
run.skips += 1;
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
272
444
|
if (refreshed && !sameTransition(existing, refreshed)) {
|
|
273
445
|
fs.writeFileSync(file, `${JSON.stringify(refreshed)}\n`);
|
|
274
446
|
appendStateEvent({
|
|
@@ -410,6 +582,7 @@ module.exports = {
|
|
|
410
582
|
checkpointBaseReady,
|
|
411
583
|
checkpointForSharedState,
|
|
412
584
|
checkpointMayPublish,
|
|
585
|
+
checkpointWasAcknowledged,
|
|
413
586
|
localParkedEntries,
|
|
414
587
|
},
|
|
415
588
|
CHECKPOINT_CONTRACT,
|
|
@@ -425,6 +598,7 @@ module.exports = {
|
|
|
425
598
|
repoKeyOfCard,
|
|
426
599
|
revisionOf,
|
|
427
600
|
sameTransition,
|
|
601
|
+
stampCheckpointMutation,
|
|
428
602
|
startCheckpointService,
|
|
429
603
|
stopCheckpointService,
|
|
430
604
|
};
|
|
@@ -360,6 +360,23 @@ async function captureWorktreeDiff(worktreeRoot, { maxBytes = Infinity } = {}) {
|
|
|
360
360
|
* Untracked, non-ignored files belong to the working-tree layer. Keeping
|
|
361
361
|
* these deltas separate preserves what the user deliberately staged.
|
|
362
362
|
*/
|
|
363
|
+
function checkpointCaptureResult({
|
|
364
|
+
baseOid,
|
|
365
|
+
observedOid,
|
|
366
|
+
indexDiff,
|
|
367
|
+
worktreeDiff,
|
|
368
|
+
bytes,
|
|
369
|
+
maxBytes,
|
|
370
|
+
}) {
|
|
371
|
+
if (observedOid !== baseOid) {
|
|
372
|
+
return { baseOid, indexDiff: null, worktreeDiff: null, reason: 'head-moved', bytes };
|
|
373
|
+
}
|
|
374
|
+
if (bytes > maxBytes) {
|
|
375
|
+
return { baseOid, indexDiff: null, worktreeDiff: null, reason: 'too-large', bytes };
|
|
376
|
+
}
|
|
377
|
+
return { baseOid, indexDiff, worktreeDiff, bytes };
|
|
378
|
+
}
|
|
379
|
+
|
|
363
380
|
async function captureCheckpoint(worktreeRoot, { maxBytes = Infinity } = {}) {
|
|
364
381
|
const baseOid = await headOid(worktreeRoot);
|
|
365
382
|
if (!baseOid) return { baseOid: null, indexDiff: null, worktreeDiff: null, reason: 'unborn' };
|
|
@@ -388,18 +405,19 @@ async function captureCheckpoint(worktreeRoot, { maxBytes = Infinity } = {}) {
|
|
|
388
405
|
worktreeParts.push(creation.stdout);
|
|
389
406
|
bytes += Buffer.byteLength(creation.stdout);
|
|
390
407
|
}
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
return {
|
|
408
|
+
const observedOid = await headOid(worktreeRoot);
|
|
409
|
+
return checkpointCaptureResult({
|
|
395
410
|
baseOid,
|
|
411
|
+
observedOid,
|
|
396
412
|
indexDiff: staged.stdout,
|
|
397
413
|
worktreeDiff: worktreeParts.join(''),
|
|
398
414
|
bytes,
|
|
399
|
-
|
|
415
|
+
maxBytes,
|
|
416
|
+
});
|
|
400
417
|
}
|
|
401
418
|
|
|
402
419
|
module.exports = {
|
|
420
|
+
_test: { checkpointCaptureResult },
|
|
403
421
|
GitError,
|
|
404
422
|
captureCheckpoint,
|
|
405
423
|
captureWorktreeDiff,
|
|
@@ -1,33 +1,21 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* Ordered file-state application.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* a local working tree is represented as a git tree oid ("base commit
|
|
10
|
-
* plus exactly the peer content folded in so far"), and the only verb is
|
|
11
|
-
* advance: move that contribution from the tree we hold to the tree the
|
|
12
|
-
* peer just published, file by file, around whatever else lives in the
|
|
13
|
-
* working tree.
|
|
6
|
+
* Every official transition changes a tree from one exact state to another.
|
|
7
|
+
* Transitions are applied in rail order. Different paths combine; when two
|
|
8
|
+
* official transitions touch the same path, the later transition wins.
|
|
14
9
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* version verbatim (create, update, or delete);
|
|
20
|
-
* - both sides changed text → content-level three-way merge; a clean
|
|
21
|
-
* merge writes, an overlapping one holds the file aside untouched
|
|
22
|
-
* with a named conflict kind. Both versions survive: local in the
|
|
23
|
-
* tree, the peer's in their checkpoint.
|
|
10
|
+
* The visible working tree may also contain a pending local write. Applying
|
|
11
|
+
* an earlier official transition advances the confirmed tree underneath that
|
|
12
|
+
* write without replacing its visible bytes. The pending write is published
|
|
13
|
+
* later from the new confirmed state.
|
|
24
14
|
*
|
|
25
|
-
* Everything
|
|
26
|
-
*
|
|
27
|
-
* goes through transition.applyMergedFiles so it lands in the journal.
|
|
15
|
+
* Everything computes against the object database with scratch index files;
|
|
16
|
+
* the only worktree mutation is the final ordered write set.
|
|
28
17
|
*/
|
|
29
18
|
|
|
30
|
-
const crypto = require('crypto');
|
|
31
19
|
const fs = require('fs');
|
|
32
20
|
const os = require('os');
|
|
33
21
|
const path = require('path');
|
|
@@ -171,124 +159,20 @@ function workMatches(work, entry, blobBytes) {
|
|
|
171
159
|
&& work.content.equals(blobBytes);
|
|
172
160
|
}
|
|
173
161
|
|
|
174
|
-
function isText(buffer) {
|
|
175
|
-
return buffer !== null && !buffer.includes(0);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** Content-level three-way merge; { ok, content } — never writes markers to disk. */
|
|
179
|
-
async function mergeFileContents(ours, base, theirs, labels = {}) {
|
|
180
|
-
return withScratch(async (dir) => {
|
|
181
|
-
const oursFile = path.join(dir, 'ours');
|
|
182
|
-
const baseFile = path.join(dir, 'base');
|
|
183
|
-
const theirsFile = path.join(dir, 'theirs');
|
|
184
|
-
await fs.promises.writeFile(oursFile, ours);
|
|
185
|
-
await fs.promises.writeFile(baseFile, base);
|
|
186
|
-
await fs.promises.writeFile(theirsFile, theirs);
|
|
187
|
-
const conflictCodes = Array.from({ length: 127 }, (_, i) => i + 1);
|
|
188
|
-
const result = await run(
|
|
189
|
-
[
|
|
190
|
-
'merge-file', '-p',
|
|
191
|
-
'-L', labels.ours || 'current',
|
|
192
|
-
'-L', labels.base || 'base',
|
|
193
|
-
'-L', labels.theirs || 'incoming',
|
|
194
|
-
oursFile, baseFile, theirsFile,
|
|
195
|
-
],
|
|
196
|
-
dir,
|
|
197
|
-
{ okCodes: conflictCodes, binary: true },
|
|
198
|
-
);
|
|
199
|
-
return { ok: result.exitCode === 0, content: result.stdout };
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
function sameEntry(left, right) {
|
|
204
|
-
return left === null
|
|
205
|
-
? right === null
|
|
206
|
-
: right !== null && left.mode === right.mode && left.type === right.type && left.oid === right.oid;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async function storedBlob(worktreeRoot, content, scratchDir, name) {
|
|
210
|
-
const file = path.join(scratchDir, name);
|
|
211
|
-
await fs.promises.writeFile(file, content);
|
|
212
|
-
const { stdout } = await run(['hash-object', '-w', file], worktreeRoot);
|
|
213
|
-
return stdout.trim();
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function conflictCopyPath(relPath, label) {
|
|
217
|
-
const digest = crypto.createHash('sha256').update(`${label}\0${relPath}`).digest('hex').slice(0, 16);
|
|
218
|
-
return `.amalgm-conflicts/${digest}/${relPath}`;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
162
|
/**
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
* overlaps produce the same conflict bytes and paths on every machine.
|
|
163
|
+
* Apply one official transition (`baseTree → targetTree`) to the current
|
|
164
|
+
* confirmed tree. Every path changed by the transition takes its target
|
|
165
|
+
* state. Rail order alone decides the winner.
|
|
226
166
|
*/
|
|
227
|
-
async function reduceTree(worktreeRoot, currentTree, baseTree, targetTree
|
|
167
|
+
async function reduceTree(worktreeRoot, currentTree, baseTree, targetTree) {
|
|
228
168
|
const updates = [];
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
for (const relPath of await changedPaths(worktreeRoot, baseTree, targetTree)) {
|
|
234
|
-
if (unsafePath(relPath)) {
|
|
235
|
-
conflicts.push({ path: relPath, kind: 'unsafe-path', label });
|
|
236
|
-
continue;
|
|
237
|
-
}
|
|
238
|
-
const base = await entryAt(worktreeRoot, baseTree, relPath);
|
|
239
|
-
const current = await entryAt(worktreeRoot, currentTree, relPath);
|
|
240
|
-
const target = await entryAt(worktreeRoot, targetTree, relPath);
|
|
241
|
-
|
|
242
|
-
if (sameEntry(current, target) || sameEntry(base, target)) continue;
|
|
243
|
-
if (sameEntry(current, base)) {
|
|
244
|
-
updates.push({ path: relPath, entry: target });
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const baseBytes = base?.type === 'blob' ? await readBlob(worktreeRoot, base.oid) : null;
|
|
249
|
-
const currentBytes = current?.type === 'blob' ? await readBlob(worktreeRoot, current.oid) : null;
|
|
250
|
-
const targetBytes = target?.type === 'blob' ? await readBlob(worktreeRoot, target.oid) : null;
|
|
251
|
-
const mergeable = base && current && target
|
|
252
|
-
&& base.type === 'blob' && current.type === 'blob' && target.type === 'blob'
|
|
253
|
-
&& base.mode !== '120000' && current.mode !== '120000' && target.mode !== '120000'
|
|
254
|
-
&& isText(baseBytes) && isText(currentBytes) && isText(targetBytes);
|
|
255
|
-
|
|
256
|
-
if (mergeable) {
|
|
257
|
-
const merged = await mergeFileContents(currentBytes, baseBytes, targetBytes, {
|
|
258
|
-
ours: 'current',
|
|
259
|
-
base: 'shared-base',
|
|
260
|
-
theirs: `rail-${label}`,
|
|
261
|
-
});
|
|
262
|
-
const oid = await storedBlob(worktreeRoot, merged.content, dir, `merged-${stored++}`);
|
|
263
|
-
updates.push({ path: relPath, entry: { mode: current.mode, type: 'blob', oid } });
|
|
264
|
-
if (!merged.ok) conflicts.push({ path: relPath, kind: 'content-overlap', label });
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Binary/delete/add conflicts retain the current path and, when the
|
|
269
|
-
// incoming side has bytes, preserve those bytes at one deterministic
|
|
270
|
-
// conflict path. The reducer's conflict record preserves delete intent.
|
|
271
|
-
const kind = !target
|
|
272
|
-
? 'delete-vs-change'
|
|
273
|
-
: !current
|
|
274
|
-
? 'change-vs-delete'
|
|
275
|
-
: !base
|
|
276
|
-
? 'add-add'
|
|
277
|
-
: 'binary-overlap';
|
|
278
|
-
const conflict = { path: relPath, kind, label };
|
|
279
|
-
if (targetBytes) {
|
|
280
|
-
const copyPath = conflictCopyPath(relPath, label);
|
|
281
|
-
const oid = await storedBlob(worktreeRoot, targetBytes, dir, `conflict-${stored++}`);
|
|
282
|
-
updates.push({ path: copyPath, entry: { mode: '100644', type: 'blob', oid } });
|
|
283
|
-
conflict.copyPath = copyPath;
|
|
284
|
-
}
|
|
285
|
-
conflicts.push(conflict);
|
|
286
|
-
}
|
|
287
|
-
});
|
|
169
|
+
for (const relPath of await changedPaths(worktreeRoot, baseTree, targetTree)) {
|
|
170
|
+
if (unsafePath(relPath)) throw new Error(`unsafe path: ${relPath}`);
|
|
171
|
+
updates.push({ path: relPath, entry: await entryAt(worktreeRoot, targetTree, relPath) });
|
|
172
|
+
}
|
|
288
173
|
|
|
289
174
|
return {
|
|
290
175
|
tree: updates.length ? await updateHeldTree(worktreeRoot, currentTree, updates) : currentTree,
|
|
291
|
-
conflicts,
|
|
292
176
|
};
|
|
293
177
|
}
|
|
294
178
|
|
|
@@ -318,81 +202,35 @@ function unsafePath(relPath) {
|
|
|
318
202
|
}
|
|
319
203
|
|
|
320
204
|
/**
|
|
321
|
-
* Advance
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
* exactly as the working tree had them, each with a named kind.
|
|
205
|
+
* Advance confirmed state from heldTree to targetTree. A working file that
|
|
206
|
+
* differs from heldTree is a later pending local write, so it stays visible
|
|
207
|
+
* while the confirmed tree advances underneath it.
|
|
325
208
|
*/
|
|
326
209
|
async function advance(worktreeRoot, heldTree, targetTree, { label } = {}) {
|
|
327
210
|
const applied = [];
|
|
328
|
-
const conflicts = [];
|
|
329
211
|
const writes = [];
|
|
330
212
|
|
|
331
213
|
for (const relPath of await changedPaths(worktreeRoot, heldTree, targetTree)) {
|
|
332
|
-
if (unsafePath(relPath)) {
|
|
333
|
-
conflicts.push({ path: relPath, kind: 'unsafe-path' });
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
214
|
+
if (unsafePath(relPath)) throw new Error(`unsafe path: ${relPath}`);
|
|
336
215
|
const held = await entryAt(worktreeRoot, heldTree, relPath);
|
|
337
216
|
const target = await entryAt(worktreeRoot, targetTree, relPath);
|
|
338
|
-
if ((held && held.type !== 'blob') || (target && target.type !== 'blob'))
|
|
339
|
-
conflicts.push({ path: relPath, kind: 'unsupported-entry' });
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
217
|
+
if ((held && held.type !== 'blob') || (target && target.type !== 'blob')) continue;
|
|
342
218
|
const heldBytes = held ? await readBlob(worktreeRoot, held.oid) : null;
|
|
343
219
|
const targetBytes = target ? await readBlob(worktreeRoot, target.oid) : null;
|
|
344
220
|
const work = workFileState(worktreeRoot, relPath);
|
|
345
221
|
|
|
346
|
-
if (workMatches(work, target, targetBytes)) {
|
|
347
|
-
// Already there — a replayed or echoed change folds without a write.
|
|
348
|
-
applied.push({ path: relPath, entry: target });
|
|
349
|
-
continue;
|
|
350
|
-
}
|
|
351
222
|
if (workMatches(work, held, heldBytes)) {
|
|
352
|
-
// Untouched locally since what we hold — take the peer's version.
|
|
353
223
|
writes.push(target
|
|
354
224
|
? { path: relPath, content: targetBytes, mode: target.mode }
|
|
355
225
|
: { path: relPath, content: null });
|
|
356
|
-
applied.push({ path: relPath, entry: target });
|
|
357
|
-
continue;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// The working file differs from both what we hold and what the peer
|
|
361
|
-
// published: two writers touched it. Merge or preserve — never pick.
|
|
362
|
-
if (!target) {
|
|
363
|
-
conflicts.push({ path: relPath, kind: 'delete-vs-edit' });
|
|
364
|
-
continue;
|
|
365
|
-
}
|
|
366
|
-
if (!work) {
|
|
367
|
-
conflicts.push({ path: relPath, kind: 'edit-vs-delete' });
|
|
368
|
-
continue;
|
|
369
|
-
}
|
|
370
|
-
if (!held) {
|
|
371
|
-
conflicts.push({ path: relPath, kind: 'add-add' });
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
const mergeable = work.kind === 'file'
|
|
375
|
-
&& target.mode !== '120000'
|
|
376
|
-
&& held.mode !== '120000'
|
|
377
|
-
&& isText(work.content) && isText(heldBytes) && isText(targetBytes);
|
|
378
|
-
if (!mergeable) {
|
|
379
|
-
conflicts.push({ path: relPath, kind: 'binary-overlap' });
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
const merged = await mergeFileContents(work.content, heldBytes, targetBytes);
|
|
383
|
-
if (!merged.ok) {
|
|
384
|
-
conflicts.push({ path: relPath, kind: 'content-overlap' });
|
|
385
|
-
continue;
|
|
386
226
|
}
|
|
387
|
-
writes.push({ path: relPath, content: merged.content, mode: work.exec ? '100755' : '100644' });
|
|
388
227
|
applied.push({ path: relPath, entry: target });
|
|
389
228
|
}
|
|
390
229
|
|
|
391
|
-
if (writes.length) await transition.
|
|
230
|
+
if (writes.length) await transition.applyFiles(worktreeRoot, writes, label);
|
|
392
231
|
const newHeldTree = applied.length ? await updateHeldTree(worktreeRoot, heldTree, applied) : heldTree;
|
|
393
232
|
return {
|
|
394
233
|
applied: applied.map((a) => a.path),
|
|
395
|
-
conflicts,
|
|
396
234
|
heldTree: newHeldTree,
|
|
397
235
|
wrote: writes.length,
|
|
398
236
|
};
|
|
@@ -132,7 +132,10 @@ function publishPayload(key, bytesInput, repoKey) {
|
|
|
132
132
|
if (!project?.resourceId) {
|
|
133
133
|
return { kind: 'pending', contract: PAYLOAD_CONTRACT, checksum };
|
|
134
134
|
}
|
|
135
|
-
|
|
135
|
+
// Payload content is its identity. A later checkpoint that still contains
|
|
136
|
+
// the same large file must reference the already-uploaded bytes instead of
|
|
137
|
+
// creating another upload merely because the surrounding checkpoint moved.
|
|
138
|
+
const file = payloadFile(project.resourceId, checksum);
|
|
136
139
|
let manifest = readJson(file);
|
|
137
140
|
if (!manifest || manifest.checksum !== checksum) {
|
|
138
141
|
manifest = {
|
|
@@ -282,6 +285,15 @@ function readPayload(locator) {
|
|
|
282
285
|
if (bytes.length !== locator.bytes || sha256(bytes) !== locator.checksum) {
|
|
283
286
|
throw new Error('checkpoint payload verification failed');
|
|
284
287
|
}
|
|
288
|
+
writeJson(payloadFile(locator.resourceId, locator.checksum), {
|
|
289
|
+
contract: PAYLOAD_CONTRACT,
|
|
290
|
+
resourceId: locator.resourceId,
|
|
291
|
+
repoKey: null,
|
|
292
|
+
checksum: locator.checksum,
|
|
293
|
+
bytes: locator.bytes,
|
|
294
|
+
chunks: locator.chunks.map((chunk) => ({ ...chunk, uploaded: true })),
|
|
295
|
+
createdAt: new Date().toISOString(),
|
|
296
|
+
});
|
|
285
297
|
return { state: 'ready', bytes };
|
|
286
298
|
}
|
|
287
299
|
|