@git-stunts/git-warp 10.7.0 → 11.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +53 -32
  2. package/SECURITY.md +64 -0
  3. package/bin/cli/commands/check.js +168 -0
  4. package/bin/cli/commands/doctor/checks.js +422 -0
  5. package/bin/cli/commands/doctor/codes.js +46 -0
  6. package/bin/cli/commands/doctor/index.js +239 -0
  7. package/bin/cli/commands/doctor/types.js +89 -0
  8. package/bin/cli/commands/history.js +73 -0
  9. package/bin/cli/commands/info.js +139 -0
  10. package/bin/cli/commands/install-hooks.js +128 -0
  11. package/bin/cli/commands/materialize.js +99 -0
  12. package/bin/cli/commands/path.js +88 -0
  13. package/bin/cli/commands/query.js +194 -0
  14. package/bin/cli/commands/registry.js +28 -0
  15. package/bin/cli/commands/seek.js +592 -0
  16. package/bin/cli/commands/trust.js +154 -0
  17. package/bin/cli/commands/verify-audit.js +113 -0
  18. package/bin/cli/commands/view.js +45 -0
  19. package/bin/cli/infrastructure.js +336 -0
  20. package/bin/cli/schemas.js +177 -0
  21. package/bin/cli/shared.js +244 -0
  22. package/bin/cli/types.js +85 -0
  23. package/bin/presenters/index.js +214 -0
  24. package/bin/presenters/json.js +66 -0
  25. package/bin/presenters/text.js +543 -0
  26. package/bin/warp-graph.js +19 -2824
  27. package/index.d.ts +32 -2
  28. package/index.js +2 -0
  29. package/package.json +9 -7
  30. package/src/domain/WarpGraph.js +106 -3252
  31. package/src/domain/errors/QueryError.js +2 -2
  32. package/src/domain/errors/TrustError.js +29 -0
  33. package/src/domain/errors/index.js +1 -0
  34. package/src/domain/services/AuditMessageCodec.js +137 -0
  35. package/src/domain/services/AuditReceiptService.js +471 -0
  36. package/src/domain/services/AuditVerifierService.js +693 -0
  37. package/src/domain/services/HttpSyncServer.js +36 -22
  38. package/src/domain/services/MessageCodecInternal.js +3 -0
  39. package/src/domain/services/MessageSchemaDetector.js +2 -2
  40. package/src/domain/services/SyncAuthService.js +69 -3
  41. package/src/domain/services/WarpMessageCodec.js +4 -1
  42. package/src/domain/trust/TrustCanonical.js +42 -0
  43. package/src/domain/trust/TrustCrypto.js +111 -0
  44. package/src/domain/trust/TrustEvaluator.js +180 -0
  45. package/src/domain/trust/TrustRecordService.js +274 -0
  46. package/src/domain/trust/TrustStateBuilder.js +209 -0
  47. package/src/domain/trust/canonical.js +68 -0
  48. package/src/domain/trust/reasonCodes.js +64 -0
  49. package/src/domain/trust/schemas.js +160 -0
  50. package/src/domain/trust/verdict.js +42 -0
  51. package/src/domain/types/git-cas.d.ts +20 -0
  52. package/src/domain/utils/RefLayout.js +59 -0
  53. package/src/domain/warp/PatchSession.js +18 -0
  54. package/src/domain/warp/Writer.js +18 -3
  55. package/src/domain/warp/_internal.js +26 -0
  56. package/src/domain/warp/_wire.js +58 -0
  57. package/src/domain/warp/_wiredMethods.d.ts +100 -0
  58. package/src/domain/warp/checkpoint.methods.js +397 -0
  59. package/src/domain/warp/fork.methods.js +323 -0
  60. package/src/domain/warp/materialize.methods.js +188 -0
  61. package/src/domain/warp/materializeAdvanced.methods.js +339 -0
  62. package/src/domain/warp/patch.methods.js +529 -0
  63. package/src/domain/warp/provenance.methods.js +284 -0
  64. package/src/domain/warp/query.methods.js +279 -0
  65. package/src/domain/warp/subscribe.methods.js +272 -0
  66. package/src/domain/warp/sync.methods.js +549 -0
  67. package/src/infrastructure/adapters/GitGraphAdapter.js +67 -1
  68. package/src/infrastructure/adapters/InMemoryGraphAdapter.js +36 -0
  69. package/src/ports/CommitPort.js +10 -0
  70. package/src/ports/RefPort.js +17 -0
  71. package/src/hooks/post-merge.sh +0 -60
@@ -0,0 +1,549 @@
1
+ /**
2
+ * Sync methods for WarpGraph — frontier, status, sync protocol, and HTTP serve.
3
+ *
4
+ * Every function uses `this` bound to a WarpGraph instance at runtime
5
+ * via wireWarpMethods().
6
+ *
7
+ * @module domain/warp/sync.methods
8
+ */
9
+
10
+ import {
11
+ SyncError,
12
+ OperationAbortedError,
13
+ QueryError,
14
+ E_NO_STATE_MSG,
15
+ DEFAULT_SYNC_SERVER_MAX_BYTES,
16
+ DEFAULT_SYNC_WITH_RETRIES,
17
+ DEFAULT_SYNC_WITH_BASE_DELAY_MS,
18
+ DEFAULT_SYNC_WITH_MAX_DELAY_MS,
19
+ DEFAULT_SYNC_WITH_TIMEOUT_MS,
20
+ } from './_internal.js';
21
+ import {
22
+ createSyncRequest as createSyncRequestImpl,
23
+ processSyncRequest as processSyncRequestImpl,
24
+ applySyncResponse as applySyncResponseImpl,
25
+ syncNeeded as syncNeededImpl,
26
+ } from '../services/SyncProtocol.js';
27
+ import { retry, timeout, RetryExhaustedError, TimeoutError } from '@git-stunts/alfred';
28
+ import { checkAborted } from '../utils/cancellation.js';
29
+ import { createFrontier, updateFrontier } from '../services/Frontier.js';
30
+ import { buildWriterRef } from '../utils/RefLayout.js';
31
+ import { collectGCMetrics } from '../services/GCMetrics.js';
32
+ import HttpSyncServer from '../services/HttpSyncServer.js';
33
+ import { signSyncRequest, canonicalizePath } from '../services/SyncAuthService.js';
34
+
35
+ // ── Private helpers ─────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * Normalizes a sync endpoint path to ensure it starts with '/'.
39
+ * Returns '/sync' if no path is provided.
40
+ *
41
+ * @param {string|undefined|null} path - The sync path to normalize
42
+ * @returns {string} Normalized path starting with '/'
43
+ * @private
44
+ */
45
+ function normalizeSyncPath(path) {
46
+ if (!path) {
47
+ return '/sync';
48
+ }
49
+ return path.startsWith('/') ? path : `/${path}`;
50
+ }
51
+
52
+ /**
53
+ * Builds auth headers for an outgoing sync request if auth is configured.
54
+ *
55
+ * @param {Object} params
56
+ * @param {{ secret: string, keyId?: string }|undefined} params.auth
57
+ * @param {string} params.bodyStr - Serialized request body
58
+ * @param {URL} params.targetUrl
59
+ * @param {import('../../ports/CryptoPort.js').default} params.crypto
60
+ * @returns {Promise<Record<string, string>>}
61
+ * @private
62
+ */
63
+ async function buildSyncAuthHeaders({ auth, bodyStr, targetUrl, crypto }) {
64
+ if (!auth || !auth.secret) {
65
+ return {};
66
+ }
67
+ const bodyBuf = new TextEncoder().encode(bodyStr);
68
+ return await signSyncRequest(
69
+ {
70
+ method: 'POST',
71
+ path: canonicalizePath(targetUrl.pathname + (targetUrl.search || '')),
72
+ contentType: 'application/json',
73
+ body: bodyBuf,
74
+ secret: auth.secret,
75
+ keyId: auth.keyId || 'default',
76
+ },
77
+ { crypto },
78
+ );
79
+ }
80
+
81
+ // ── Exported methods ────────────────────────────────────────────────────────
82
+
83
+ /**
84
+ * Returns the current frontier — a Map of writerId → tip SHA.
85
+ *
86
+ * @this {import('../WarpGraph.js').default}
87
+ * @returns {Promise<Map<string, string>>} Frontier map
88
+ * @throws {Error} If listing refs fails
89
+ */
90
+ export async function getFrontier() {
91
+ const writerIds = await this.discoverWriters();
92
+ const frontier = createFrontier();
93
+
94
+ for (const writerId of writerIds) {
95
+ const writerRef = buildWriterRef(this._graphName, writerId);
96
+ const tipSha = await this._persistence.readRef(writerRef);
97
+ if (tipSha) {
98
+ updateFrontier(frontier, writerId, tipSha);
99
+ }
100
+ }
101
+
102
+ return frontier;
103
+ }
104
+
105
+ /**
106
+ * Checks whether any writer tip has changed since the last materialize.
107
+ *
108
+ * O(writers) comparison of stored writer tip SHAs against current refs.
109
+ * Cheap "has anything changed?" check without materialization.
110
+ *
111
+ * @this {import('../WarpGraph.js').default}
112
+ * @returns {Promise<boolean>} True if frontier has changed (or never materialized)
113
+ * @throws {Error} If listing refs fails
114
+ */
115
+ export async function hasFrontierChanged() {
116
+ if (this._lastFrontier === null) {
117
+ return true;
118
+ }
119
+
120
+ const current = await this.getFrontier();
121
+
122
+ if (current.size !== this._lastFrontier.size) {
123
+ return true;
124
+ }
125
+
126
+ for (const [writerId, tipSha] of current) {
127
+ if (this._lastFrontier.get(writerId) !== tipSha) {
128
+ return true;
129
+ }
130
+ }
131
+
132
+ return false;
133
+ }
134
+
135
+ /**
136
+ * Returns a lightweight status snapshot of the graph's operational state.
137
+ *
138
+ * This method is O(writers) and does NOT trigger materialization.
139
+ *
140
+ * @this {import('../WarpGraph.js').default}
141
+ * @returns {Promise<{
142
+ * cachedState: 'fresh' | 'stale' | 'none',
143
+ * patchesSinceCheckpoint: number,
144
+ * tombstoneRatio: number,
145
+ * writers: number,
146
+ * frontier: Record<string, string>,
147
+ * }>} The graph status
148
+ * @throws {Error} If listing refs fails
149
+ */
150
+ export async function status() {
151
+ // Fetch frontier once, reuse for both staleness check and return value
152
+ const frontier = await this.getFrontier();
153
+
154
+ // Determine cachedState
155
+ /** @type {'fresh' | 'stale' | 'none'} */
156
+ let cachedState;
157
+ if (this._cachedState === null) {
158
+ cachedState = 'none';
159
+ } else if (this._stateDirty || !this._lastFrontier ||
160
+ frontier.size !== this._lastFrontier.size ||
161
+ ![...frontier].every(([w, sha]) => /** @type {Map<string, string>} */ (this._lastFrontier).get(w) === sha)) {
162
+ cachedState = 'stale';
163
+ } else {
164
+ cachedState = 'fresh';
165
+ }
166
+
167
+ // patchesSinceCheckpoint
168
+ const patchesSinceCheckpoint = this._patchesSinceCheckpoint;
169
+
170
+ // tombstoneRatio
171
+ let tombstoneRatio = 0;
172
+ if (this._cachedState) {
173
+ const metrics = collectGCMetrics(this._cachedState);
174
+ tombstoneRatio = metrics.tombstoneRatio;
175
+ }
176
+
177
+ // writers
178
+ const writers = frontier.size;
179
+
180
+ // Convert frontier Map to plain object
181
+ const frontierObj = Object.fromEntries(frontier);
182
+
183
+ return {
184
+ cachedState,
185
+ patchesSinceCheckpoint,
186
+ tombstoneRatio,
187
+ writers,
188
+ frontier: frontierObj,
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Creates a sync request to send to a remote peer.
194
+ * The request contains the local frontier for comparison.
195
+ *
196
+ * @this {import('../WarpGraph.js').default}
197
+ * @returns {Promise<import('../services/SyncProtocol.js').SyncRequest>} The sync request
198
+ * @throws {Error} If listing refs fails
199
+ *
200
+ * @example
201
+ * const request = await graph.createSyncRequest();
202
+ * // Send request to remote peer...
203
+ */
204
+ export async function createSyncRequest() {
205
+ const frontier = await this.getFrontier();
206
+ return createSyncRequestImpl(frontier);
207
+ }
208
+
209
+ /**
210
+ * Processes an incoming sync request and returns patches the requester needs.
211
+ *
212
+ * @this {import('../WarpGraph.js').default}
213
+ * @param {import('../services/SyncProtocol.js').SyncRequest} request - The incoming sync request
214
+ * @returns {Promise<import('../services/SyncProtocol.js').SyncResponse>} The sync response
215
+ * @throws {Error} If listing refs or reading patches fails
216
+ *
217
+ * @example
218
+ * // Receive request from remote peer
219
+ * const response = await graph.processSyncRequest(request);
220
+ * // Send response back to requester...
221
+ */
222
+ export async function processSyncRequest(request) {
223
+ const localFrontier = await this.getFrontier();
224
+ return await processSyncRequestImpl(
225
+ request,
226
+ localFrontier,
227
+ /** @type {any} */ (this._persistence), // TODO(ts-cleanup): narrow port type
228
+ this._graphName,
229
+ { codec: this._codec }
230
+ );
231
+ }
232
+
233
+ /**
234
+ * Applies a sync response to the local graph state.
235
+ * Updates the cached state with received patches.
236
+ *
237
+ * **Requires a cached state.**
238
+ *
239
+ * @this {import('../WarpGraph.js').default}
240
+ * @param {import('../services/SyncProtocol.js').SyncResponse} response - The sync response
241
+ * @returns {{state: import('../services/JoinReducer.js').WarpStateV5, applied: number}} Result with updated state
242
+ * @throws {import('../errors/QueryError.js').default} If no cached state exists (code: `E_NO_STATE`)
243
+ *
244
+ * @example
245
+ * await graph.materialize(); // Cache state first
246
+ * const result = graph.applySyncResponse(response);
247
+ * console.log(`Applied ${result.applied} patches from remote`);
248
+ */
249
+ export function applySyncResponse(response) {
250
+ if (!this._cachedState) {
251
+ throw new QueryError(E_NO_STATE_MSG, {
252
+ code: 'E_NO_STATE',
253
+ });
254
+ }
255
+
256
+ const currentFrontier = /** @type {any} */ (this._cachedState.observedFrontier); // TODO(ts-cleanup): narrow port type
257
+ const result = /** @type {{state: import('../services/JoinReducer.js').WarpStateV5, frontier: Map<string, string>, applied: number}} */ (applySyncResponseImpl(response, this._cachedState, currentFrontier));
258
+
259
+ // Update cached state
260
+ this._cachedState = result.state;
261
+
262
+ // Keep _lastFrontier in sync so hasFrontierChanged() won't misreport stale.
263
+ // Merge the response's per-writer tips into the stored frontier snapshot.
264
+ if (this._lastFrontier && Array.isArray(response.patches)) {
265
+ for (const { writerId, sha } of response.patches) {
266
+ if (writerId && sha) {
267
+ this._lastFrontier.set(writerId, sha);
268
+ }
269
+ }
270
+ }
271
+
272
+ // Track patches for GC
273
+ this._patchesSinceGC += result.applied;
274
+
275
+ // State is now in sync with the frontier — clear dirty flag
276
+ this._stateDirty = false;
277
+
278
+ return result;
279
+ }
280
+
281
+ /**
282
+ * Checks if sync is needed with a remote frontier.
283
+ *
284
+ * @this {import('../WarpGraph.js').default}
285
+ * @param {Map<string, string>} remoteFrontier - The remote peer's frontier
286
+ * @returns {Promise<boolean>} True if sync would transfer any patches
287
+ * @throws {Error} If listing refs fails
288
+ */
289
+ export async function syncNeeded(remoteFrontier) {
290
+ const localFrontier = await this.getFrontier();
291
+ return syncNeededImpl(localFrontier, remoteFrontier);
292
+ }
293
+
294
+ /**
295
+ * Syncs with a remote peer (HTTP or direct graph instance).
296
+ *
297
+ * @this {import('../WarpGraph.js').default}
298
+ * @param {string|import('../WarpGraph.js').default} remote - URL or peer graph instance
299
+ * @param {Object} [options]
300
+ * @param {string} [options.path='/sync'] - Sync path (HTTP mode)
301
+ * @param {number} [options.retries=3] - Retry count
302
+ * @param {number} [options.baseDelayMs=250] - Base backoff delay
303
+ * @param {number} [options.maxDelayMs=2000] - Max backoff delay
304
+ * @param {number} [options.timeoutMs=10000] - Request timeout
305
+ * @param {AbortSignal} [options.signal] - Abort signal
306
+ * @param {(event: {type: string, attempt: number, durationMs?: number, status?: number, error?: Error}) => void} [options.onStatus]
307
+ * @param {boolean} [options.materialize=false] - Auto-materialize after sync
308
+ * @param {{ secret: string, keyId?: string }} [options.auth] - Client auth credentials
309
+ * @returns {Promise<{applied: number, attempts: number, state?: import('../services/JoinReducer.js').WarpStateV5}>}
310
+ */
311
+ export async function syncWith(remote, options = {}) {
312
+ const t0 = this._clock.now();
313
+ const {
314
+ path = '/sync',
315
+ retries = DEFAULT_SYNC_WITH_RETRIES,
316
+ baseDelayMs = DEFAULT_SYNC_WITH_BASE_DELAY_MS,
317
+ maxDelayMs = DEFAULT_SYNC_WITH_MAX_DELAY_MS,
318
+ timeoutMs = DEFAULT_SYNC_WITH_TIMEOUT_MS,
319
+ signal,
320
+ onStatus,
321
+ materialize: materializeAfterSync = false,
322
+ auth,
323
+ } = options;
324
+
325
+ const hasPathOverride = Object.prototype.hasOwnProperty.call(options, 'path');
326
+ const isDirectPeer = remote && typeof remote === 'object' &&
327
+ typeof remote.processSyncRequest === 'function';
328
+ let targetUrl = null;
329
+ if (!isDirectPeer) {
330
+ try {
331
+ targetUrl = remote instanceof URL ? new URL(remote.toString()) : new URL(/** @type {string} */ (remote));
332
+ } catch {
333
+ throw new SyncError('Invalid remote URL', {
334
+ code: 'E_SYNC_REMOTE_URL',
335
+ context: { remote },
336
+ });
337
+ }
338
+
339
+ if (!['http:', 'https:'].includes(targetUrl.protocol)) {
340
+ throw new SyncError('Unsupported remote URL protocol', {
341
+ code: 'E_SYNC_REMOTE_URL',
342
+ context: { protocol: targetUrl.protocol },
343
+ });
344
+ }
345
+
346
+ const normalizedPath = normalizeSyncPath(path);
347
+ if (!targetUrl.pathname || targetUrl.pathname === '/') {
348
+ targetUrl.pathname = normalizedPath;
349
+ } else if (hasPathOverride) {
350
+ targetUrl.pathname = normalizedPath;
351
+ }
352
+ targetUrl.hash = '';
353
+ }
354
+ let attempt = 0;
355
+ const emit = (/** @type {string} */ type, /** @type {Record<string, any>} */ payload = {}) => {
356
+ if (typeof onStatus === 'function') {
357
+ onStatus(/** @type {any} */ ({ type, attempt, ...payload })); // TODO(ts-cleanup): type sync protocol
358
+ }
359
+ };
360
+ const shouldRetry = (/** @type {any} */ err) => { // TODO(ts-cleanup): type error
361
+ if (isDirectPeer) { return false; }
362
+ if (err instanceof SyncError) {
363
+ return ['E_SYNC_REMOTE', 'E_SYNC_TIMEOUT', 'E_SYNC_NETWORK'].includes(err.code);
364
+ }
365
+ return err instanceof TimeoutError;
366
+ };
367
+ const executeAttempt = async () => {
368
+ checkAborted(signal, 'syncWith');
369
+ attempt += 1;
370
+ const attemptStart = this._clock.now();
371
+ emit('connecting');
372
+ const request = await this.createSyncRequest();
373
+ emit('requestBuilt');
374
+ let response;
375
+ if (isDirectPeer) {
376
+ emit('requestSent');
377
+ response = await remote.processSyncRequest(request);
378
+ emit('responseReceived');
379
+ } else {
380
+ emit('requestSent');
381
+ const bodyStr = JSON.stringify(request);
382
+ const authHeaders = await buildSyncAuthHeaders({
383
+ auth, bodyStr, targetUrl: /** @type {URL} */ (targetUrl), crypto: this._crypto,
384
+ });
385
+ let res;
386
+ try {
387
+ res = await timeout(timeoutMs, (timeoutSignal) => {
388
+ const combinedSignal = signal
389
+ ? AbortSignal.any([timeoutSignal, signal])
390
+ : timeoutSignal;
391
+ return fetch(/** @type {URL} */ (targetUrl).toString(), {
392
+ method: 'POST',
393
+ headers: {
394
+ 'content-type': 'application/json',
395
+ 'accept': 'application/json',
396
+ ...authHeaders,
397
+ },
398
+ body: bodyStr,
399
+ signal: combinedSignal,
400
+ });
401
+ });
402
+ } catch (err) {
403
+ if (/** @type {any} */ (err)?.name === 'AbortError') { // TODO(ts-cleanup): type error
404
+ throw new OperationAbortedError('syncWith', { reason: 'Signal received' });
405
+ }
406
+ if (err instanceof TimeoutError) {
407
+ throw new SyncError('Sync request timed out', {
408
+ code: 'E_SYNC_TIMEOUT',
409
+ context: { timeoutMs },
410
+ });
411
+ }
412
+ throw new SyncError('Network error', {
413
+ code: 'E_SYNC_NETWORK',
414
+ context: { message: /** @type {any} */ (err)?.message }, // TODO(ts-cleanup): type error
415
+ });
416
+ }
417
+
418
+ emit('responseReceived', { status: res.status });
419
+
420
+ if (res.status >= 500) {
421
+ throw new SyncError(`Remote error: ${res.status}`, {
422
+ code: 'E_SYNC_REMOTE',
423
+ context: { status: res.status },
424
+ });
425
+ }
426
+
427
+ if (res.status >= 400) {
428
+ throw new SyncError(`Protocol error: ${res.status}`, {
429
+ code: 'E_SYNC_PROTOCOL',
430
+ context: { status: res.status },
431
+ });
432
+ }
433
+
434
+ try {
435
+ response = await res.json();
436
+ } catch {
437
+ throw new SyncError('Invalid JSON response', {
438
+ code: 'E_SYNC_PROTOCOL',
439
+ context: { status: res.status },
440
+ });
441
+ }
442
+ }
443
+
444
+ if (!this._cachedState) {
445
+ await this.materialize();
446
+ emit('materialized');
447
+ }
448
+
449
+ if (!response || typeof response !== 'object' ||
450
+ response.type !== 'sync-response' ||
451
+ !response.frontier || typeof response.frontier !== 'object' || Array.isArray(response.frontier) ||
452
+ !Array.isArray(response.patches)) {
453
+ throw new SyncError('Invalid sync response', {
454
+ code: 'E_SYNC_PROTOCOL',
455
+ });
456
+ }
457
+
458
+ const result = this.applySyncResponse(response);
459
+ emit('applied', { applied: result.applied });
460
+
461
+ const durationMs = this._clock.now() - attemptStart;
462
+ emit('complete', { durationMs, applied: result.applied });
463
+ return { applied: result.applied, attempts: attempt };
464
+ };
465
+
466
+ try {
467
+ const syncResult = await retry(executeAttempt, {
468
+ retries,
469
+ delay: baseDelayMs,
470
+ maxDelay: maxDelayMs,
471
+ backoff: 'exponential',
472
+ jitter: 'decorrelated',
473
+ signal,
474
+ shouldRetry,
475
+ onRetry: (/** @type {Error} */ error, /** @type {number} */ attemptNumber, /** @type {number} */ delayMs) => {
476
+ if (typeof onStatus === 'function') {
477
+ onStatus(/** @type {any} */ ({ type: 'retrying', attempt: attemptNumber, delayMs, error })); // TODO(ts-cleanup): type sync protocol
478
+ }
479
+ },
480
+ });
481
+
482
+ this._logTiming('syncWith', t0, { metrics: `${syncResult.applied} patches applied` });
483
+
484
+ if (materializeAfterSync) {
485
+ if (!this._cachedState) { await this.materialize(); }
486
+ return { ...syncResult, state: /** @type {import('../services/JoinReducer.js').WarpStateV5} */ (this._cachedState) };
487
+ }
488
+ return syncResult;
489
+ } catch (err) {
490
+ this._logTiming('syncWith', t0, { error: /** @type {Error} */ (err) });
491
+ if (/** @type {any} */ (err)?.name === 'AbortError') { // TODO(ts-cleanup): type error
492
+ const abortedError = new OperationAbortedError('syncWith', { reason: 'Signal received' });
493
+ if (typeof onStatus === 'function') {
494
+ onStatus({ type: 'failed', attempt, error: abortedError });
495
+ }
496
+ throw abortedError;
497
+ }
498
+ if (err instanceof RetryExhaustedError) {
499
+ const cause = /** @type {Error} */ (err.cause || err);
500
+ if (typeof onStatus === 'function') {
501
+ onStatus({ type: 'failed', attempt: err.attempts, error: cause });
502
+ }
503
+ throw cause;
504
+ }
505
+ if (typeof onStatus === 'function') {
506
+ onStatus({ type: 'failed', attempt, error: /** @type {Error} */ (err) });
507
+ }
508
+ throw err;
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Starts a built-in sync server for this graph.
514
+ *
515
+ * @this {import('../WarpGraph.js').default}
516
+ * @param {Object} options
517
+ * @param {number} options.port - Port to listen on
518
+ * @param {string} [options.host='127.0.0.1'] - Host to bind
519
+ * @param {string} [options.path='/sync'] - Path to handle sync requests
520
+ * @param {number} [options.maxRequestBytes=4194304] - Max request size in bytes
521
+ * @param {import('../../ports/HttpServerPort.js').default} options.httpPort - HTTP server adapter (required)
522
+ * @param {{ keys: Record<string, string>, mode?: 'enforce'|'log-only' }} [options.auth] - Auth configuration
523
+ * @returns {Promise<{close: () => Promise<void>, url: string}>} Server handle
524
+ * @throws {Error} If port is not a number
525
+ * @throws {Error} If httpPort adapter is not provided
526
+ */
527
+ export async function serve({ port, host = '127.0.0.1', path = '/sync', maxRequestBytes = DEFAULT_SYNC_SERVER_MAX_BYTES, httpPort, auth } = /** @type {any} */ ({})) { // TODO(ts-cleanup): needs options type
528
+ if (typeof port !== 'number') {
529
+ throw new Error('serve() requires a numeric port');
530
+ }
531
+ if (!httpPort) {
532
+ throw new Error('serve() requires an httpPort adapter');
533
+ }
534
+
535
+ const authConfig = auth
536
+ ? { ...auth, crypto: this._crypto, logger: this._logger || undefined }
537
+ : undefined;
538
+
539
+ const httpServer = new HttpSyncServer({
540
+ httpPort,
541
+ graph: this,
542
+ path,
543
+ host,
544
+ maxRequestBytes,
545
+ auth: authConfig,
546
+ });
547
+
548
+ return await httpServer.listen(port);
549
+ }
@@ -114,6 +114,26 @@ function getExitCode(err) {
114
114
  return err?.details?.code ?? err?.exitCode ?? err?.code;
115
115
  }
116
116
 
117
+ /**
118
+ * Checks if a Git error indicates a dangling or missing object.
119
+ * Exit code 128 with specific stderr patterns means the ref exists but
120
+ * points to a missing object. Other exit-128 failures (bad repo, corrupt
121
+ * index, permission errors) are NOT considered dangling and will re-throw.
122
+ * @param {GitError} err
123
+ * @returns {boolean}
124
+ */
125
+ function isDanglingObjectError(err) {
126
+ if (getExitCode(err) !== 128) {
127
+ return false;
128
+ }
129
+ const stderr = (err.details?.stderr || '').toLowerCase();
130
+ return (
131
+ stderr.includes('bad object') ||
132
+ stderr.includes('not a valid object name') ||
133
+ stderr.includes('does not point to a valid object')
134
+ );
135
+ }
136
+
117
137
  /**
118
138
  * Checks whether a Git ref exists without resolving it.
119
139
  * @param {function(Object): Promise<string>} execute - The git command executor function
@@ -129,6 +149,9 @@ async function refExists(execute, ref) {
129
149
  if (getExitCode(err) === 1) {
130
150
  return false;
131
151
  }
152
+ if (isDanglingObjectError(err)) {
153
+ return false;
154
+ }
132
155
  throw err;
133
156
  }
134
157
  }
@@ -325,6 +348,20 @@ export default class GitGraphAdapter extends GraphPersistencePort {
325
348
  };
326
349
  }
327
350
 
351
+ /**
352
+ * Retrieves the tree OID for a given commit SHA.
353
+ * @param {string} sha - The commit SHA to query
354
+ * @returns {Promise<string>} The tree OID pointed to by the commit
355
+ * @throws {Error} If the SHA is invalid
356
+ */
357
+ async getCommitTree(sha) {
358
+ this._validateOid(sha);
359
+ const output = await this._executeWithRetry({
360
+ args: ['rev-parse', `${sha}^{tree}`]
361
+ });
362
+ return output.trim();
363
+ }
364
+
328
365
  /**
329
366
  * Returns raw git log output for a ref.
330
367
  * @param {Object} options
@@ -493,7 +530,7 @@ export default class GitGraphAdapter extends GraphPersistencePort {
493
530
  /**
494
531
  * Reads the OID a ref points to.
495
532
  * @param {string} ref - The ref name
496
- * @returns {Promise<string|null>} The OID, or null if the ref does not exist
533
+ * @returns {Promise<string|null>} The OID, or null if the ref does not exist or points to a dangling/missing object
497
534
  * @throws {Error} If the ref format is invalid
498
535
  */
499
536
  async readRef(ref) {
@@ -511,10 +548,39 @@ export default class GitGraphAdapter extends GraphPersistencePort {
511
548
  if (getExitCode(err) === 1) {
512
549
  return null;
513
550
  }
551
+ if (isDanglingObjectError(err)) {
552
+ return null;
553
+ }
514
554
  throw err;
515
555
  }
516
556
  }
517
557
 
558
+ /**
559
+ * Atomically updates a ref using compare-and-swap semantics.
560
+ *
561
+ * Uses `git update-ref ref newOid expectedOid` which is atomic CAS.
562
+ * Fails if the ref does not currently point to expectedOid.
563
+ *
564
+ * @param {string} ref - The ref name
565
+ * @param {string} newOid - The new OID to set
566
+ * @param {string|null} expectedOid - The expected current OID, or null if the ref must not exist
567
+ * @returns {Promise<void>}
568
+ * @throws {Error} If the ref does not match the expected value (CAS mismatch)
569
+ */
570
+ async compareAndSwapRef(ref, newOid, expectedOid) {
571
+ this._validateRef(ref);
572
+ this._validateOid(newOid);
573
+ // null means "ref must not exist" → use zero OID
574
+ const oldArg = expectedOid || '0'.repeat(newOid.length);
575
+ if (expectedOid) {
576
+ this._validateOid(expectedOid);
577
+ }
578
+ // Direct call — CAS failures are semantically expected and must NOT be retried.
579
+ await this.plumbing.execute({
580
+ args: ['update-ref', ref, newOid, oldArg],
581
+ });
582
+ }
583
+
518
584
  /**
519
585
  * Deletes a ref.
520
586
  * @param {string} ref - The ref name to delete
@@ -253,6 +253,19 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort {
253
253
  };
254
254
  }
255
255
 
256
+ /**
257
+ * @param {string} sha
258
+ * @returns {Promise<string>}
259
+ */
260
+ async getCommitTree(sha) {
261
+ validateOid(sha);
262
+ const commit = this._commits.get(sha);
263
+ if (!commit) {
264
+ throw new Error(`Commit not found: ${sha}`);
265
+ }
266
+ return commit.treeOid;
267
+ }
268
+
256
269
  /**
257
270
  * @param {string} sha
258
271
  * @returns {Promise<boolean>}
@@ -356,6 +369,29 @@ export default class InMemoryGraphAdapter extends GraphPersistencePort {
356
369
  this._refs.delete(ref);
357
370
  }
358
371
 
372
+ /**
373
+ * Atomically updates a ref using compare-and-swap semantics.
374
+ * @param {string} ref - The ref name
375
+ * @param {string} newOid - The new OID to set
376
+ * @param {string|null} expectedOid - Expected current OID, or null if ref must not exist
377
+ * @returns {Promise<void>}
378
+ * @throws {Error} If the ref does not match the expected value (CAS mismatch)
379
+ */
380
+ async compareAndSwapRef(ref, newOid, expectedOid) {
381
+ validateRef(ref);
382
+ validateOid(newOid);
383
+ if (expectedOid) {
384
+ validateOid(expectedOid);
385
+ }
386
+ const current = this._refs.get(ref) || null;
387
+ if (current !== expectedOid) {
388
+ throw new Error(
389
+ `CAS mismatch on ${ref}: expected ${expectedOid || '(none)'}, got ${current || '(none)'}`,
390
+ );
391
+ }
392
+ this._refs.set(ref, newOid);
393
+ }
394
+
359
395
  /**
360
396
  * @param {string} prefix
361
397
  * @returns {Promise<string[]>}