amalgm 0.1.221 → 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.
@@ -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@8';
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-v8', `${digest}.json`);
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(`${baseOid}\nindex\n${indexDiff}\nworktree\n${worktreeDiff}`, 'utf8')
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 checkpointValue(machine, repoKey, baseOid, indexDiff, worktreeDiff) {
117
- const revision = revisionOf(baseOid, indexDiff, worktreeDiff);
118
- const bytes = Buffer.from(JSON.stringify({ indexDiff, worktreeDiff }), 'utf8');
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${revision || 'clean'}`,
124
- bytes,
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(existing, {
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 merely materialized the shared
254
- // checkpoint. Keep the writer's last transition because it remains part
255
- // of the reducer input. A newly parked commit still rides as metadata on
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(existing, {
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
- if (bytes > maxBytes) {
392
- return { baseOid, indexDiff: null, worktreeDiff: null, reason: 'too-large', bytes };
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
- * The coexistence engine.
4
+ * Ordered file-state application.
5
5
  *
6
- * The workspace rule: every writer — human, agent, or remote machine —
7
- * writes into the same working tree, and no writer's work is ever
8
- * silently lost. This module is the mechanism. A peer's contribution to
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
- * Per file, in order:
16
- * - working file already matches the peer's version → fold without
17
- * writing (this is what makes echoes and replays no-ops);
18
- * - working file untouched since the held version → take the peer's
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 here computes against the object database with scratch
26
- * index files; the only worktree mutation is the final write set, which
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
- * Pure checkpoint reduction. Apply one published transition
223
- * (`baseTree → targetTree`) to the currently reduced tree. No working file
224
- * is read or written. Disjoint changes combine, identical changes fold, and
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, { label = 'rail' } = {}) {
167
+ async function reduceTree(worktreeRoot, currentTree, baseTree, targetTree) {
228
168
  const updates = [];
229
- const conflicts = [];
230
-
231
- await withScratch(async (dir) => {
232
- let stored = 0;
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 the peer's contribution in this working tree from heldTree to
322
- * targetTree. Returns { applied, conflicts, heldTree, wrote } — applied
323
- * paths are folded into the returned heldTree; conflicted paths are left
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.applyMergedFiles(worktreeRoot, writes, label);
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
- const file = payloadFile(project.resourceId, key);
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