@git-stunts/git-warp 10.1.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 (143) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +16 -0
  3. package/README.md +480 -0
  4. package/SECURITY.md +30 -0
  5. package/bin/git-warp +24 -0
  6. package/bin/warp-graph.js +1574 -0
  7. package/index.d.ts +2366 -0
  8. package/index.js +180 -0
  9. package/package.json +129 -0
  10. package/scripts/install-git-warp.sh +258 -0
  11. package/scripts/uninstall-git-warp.sh +139 -0
  12. package/src/domain/WarpGraph.js +3157 -0
  13. package/src/domain/crdt/Dot.js +160 -0
  14. package/src/domain/crdt/LWW.js +154 -0
  15. package/src/domain/crdt/ORSet.js +371 -0
  16. package/src/domain/crdt/VersionVector.js +222 -0
  17. package/src/domain/entities/GraphNode.js +60 -0
  18. package/src/domain/errors/EmptyMessageError.js +47 -0
  19. package/src/domain/errors/ForkError.js +30 -0
  20. package/src/domain/errors/IndexError.js +23 -0
  21. package/src/domain/errors/OperationAbortedError.js +22 -0
  22. package/src/domain/errors/QueryError.js +39 -0
  23. package/src/domain/errors/SchemaUnsupportedError.js +17 -0
  24. package/src/domain/errors/ShardCorruptionError.js +56 -0
  25. package/src/domain/errors/ShardLoadError.js +57 -0
  26. package/src/domain/errors/ShardValidationError.js +61 -0
  27. package/src/domain/errors/StorageError.js +57 -0
  28. package/src/domain/errors/SyncError.js +30 -0
  29. package/src/domain/errors/TraversalError.js +23 -0
  30. package/src/domain/errors/WarpError.js +31 -0
  31. package/src/domain/errors/WormholeError.js +28 -0
  32. package/src/domain/errors/WriterError.js +39 -0
  33. package/src/domain/errors/index.js +21 -0
  34. package/src/domain/services/AnchorMessageCodec.js +99 -0
  35. package/src/domain/services/BitmapIndexBuilder.js +225 -0
  36. package/src/domain/services/BitmapIndexReader.js +435 -0
  37. package/src/domain/services/BoundaryTransitionRecord.js +463 -0
  38. package/src/domain/services/CheckpointMessageCodec.js +147 -0
  39. package/src/domain/services/CheckpointSerializerV5.js +281 -0
  40. package/src/domain/services/CheckpointService.js +384 -0
  41. package/src/domain/services/CommitDagTraversalService.js +156 -0
  42. package/src/domain/services/DagPathFinding.js +712 -0
  43. package/src/domain/services/DagTopology.js +239 -0
  44. package/src/domain/services/DagTraversal.js +245 -0
  45. package/src/domain/services/Frontier.js +108 -0
  46. package/src/domain/services/GCMetrics.js +101 -0
  47. package/src/domain/services/GCPolicy.js +122 -0
  48. package/src/domain/services/GitLogParser.js +205 -0
  49. package/src/domain/services/HealthCheckService.js +246 -0
  50. package/src/domain/services/HookInstaller.js +326 -0
  51. package/src/domain/services/HttpSyncServer.js +262 -0
  52. package/src/domain/services/IndexRebuildService.js +426 -0
  53. package/src/domain/services/IndexStalenessChecker.js +103 -0
  54. package/src/domain/services/JoinReducer.js +582 -0
  55. package/src/domain/services/KeyCodec.js +113 -0
  56. package/src/domain/services/LegacyAnchorDetector.js +67 -0
  57. package/src/domain/services/LogicalTraversal.js +351 -0
  58. package/src/domain/services/MessageCodecInternal.js +132 -0
  59. package/src/domain/services/MessageSchemaDetector.js +145 -0
  60. package/src/domain/services/MigrationService.js +55 -0
  61. package/src/domain/services/ObserverView.js +265 -0
  62. package/src/domain/services/PatchBuilderV2.js +669 -0
  63. package/src/domain/services/PatchMessageCodec.js +140 -0
  64. package/src/domain/services/ProvenanceIndex.js +337 -0
  65. package/src/domain/services/ProvenancePayload.js +242 -0
  66. package/src/domain/services/QueryBuilder.js +835 -0
  67. package/src/domain/services/StateDiff.js +300 -0
  68. package/src/domain/services/StateSerializerV5.js +156 -0
  69. package/src/domain/services/StreamingBitmapIndexBuilder.js +709 -0
  70. package/src/domain/services/SyncProtocol.js +593 -0
  71. package/src/domain/services/TemporalQuery.js +201 -0
  72. package/src/domain/services/TranslationCost.js +221 -0
  73. package/src/domain/services/TraversalService.js +8 -0
  74. package/src/domain/services/WarpMessageCodec.js +29 -0
  75. package/src/domain/services/WarpStateIndexBuilder.js +127 -0
  76. package/src/domain/services/WormholeService.js +353 -0
  77. package/src/domain/types/TickReceipt.js +285 -0
  78. package/src/domain/types/WarpTypes.js +209 -0
  79. package/src/domain/types/WarpTypesV2.js +200 -0
  80. package/src/domain/utils/CachedValue.js +140 -0
  81. package/src/domain/utils/EventId.js +89 -0
  82. package/src/domain/utils/LRUCache.js +112 -0
  83. package/src/domain/utils/MinHeap.js +114 -0
  84. package/src/domain/utils/RefLayout.js +280 -0
  85. package/src/domain/utils/WriterId.js +205 -0
  86. package/src/domain/utils/cancellation.js +33 -0
  87. package/src/domain/utils/canonicalStringify.js +42 -0
  88. package/src/domain/utils/defaultClock.js +20 -0
  89. package/src/domain/utils/defaultCodec.js +51 -0
  90. package/src/domain/utils/nullLogger.js +21 -0
  91. package/src/domain/utils/roaring.js +181 -0
  92. package/src/domain/utils/shardVersion.js +9 -0
  93. package/src/domain/warp/PatchSession.js +217 -0
  94. package/src/domain/warp/Writer.js +181 -0
  95. package/src/hooks/post-merge.sh +60 -0
  96. package/src/infrastructure/adapters/BunHttpAdapter.js +225 -0
  97. package/src/infrastructure/adapters/ClockAdapter.js +57 -0
  98. package/src/infrastructure/adapters/ConsoleLogger.js +150 -0
  99. package/src/infrastructure/adapters/DenoHttpAdapter.js +230 -0
  100. package/src/infrastructure/adapters/GitGraphAdapter.js +787 -0
  101. package/src/infrastructure/adapters/GlobalClockAdapter.js +5 -0
  102. package/src/infrastructure/adapters/NoOpLogger.js +62 -0
  103. package/src/infrastructure/adapters/NodeCryptoAdapter.js +32 -0
  104. package/src/infrastructure/adapters/NodeHttpAdapter.js +98 -0
  105. package/src/infrastructure/adapters/PerformanceClockAdapter.js +5 -0
  106. package/src/infrastructure/adapters/WebCryptoAdapter.js +121 -0
  107. package/src/infrastructure/codecs/CborCodec.js +384 -0
  108. package/src/ports/BlobPort.js +30 -0
  109. package/src/ports/ClockPort.js +25 -0
  110. package/src/ports/CodecPort.js +25 -0
  111. package/src/ports/CommitPort.js +114 -0
  112. package/src/ports/ConfigPort.js +31 -0
  113. package/src/ports/CryptoPort.js +38 -0
  114. package/src/ports/GraphPersistencePort.js +57 -0
  115. package/src/ports/HttpServerPort.js +25 -0
  116. package/src/ports/IndexStoragePort.js +39 -0
  117. package/src/ports/LoggerPort.js +68 -0
  118. package/src/ports/RefPort.js +51 -0
  119. package/src/ports/TreePort.js +51 -0
  120. package/src/visualization/index.js +26 -0
  121. package/src/visualization/layouts/converters.js +75 -0
  122. package/src/visualization/layouts/elkAdapter.js +86 -0
  123. package/src/visualization/layouts/elkLayout.js +95 -0
  124. package/src/visualization/layouts/index.js +29 -0
  125. package/src/visualization/renderers/ascii/box.js +16 -0
  126. package/src/visualization/renderers/ascii/check.js +271 -0
  127. package/src/visualization/renderers/ascii/colors.js +13 -0
  128. package/src/visualization/renderers/ascii/formatters.js +73 -0
  129. package/src/visualization/renderers/ascii/graph.js +344 -0
  130. package/src/visualization/renderers/ascii/history.js +335 -0
  131. package/src/visualization/renderers/ascii/index.js +14 -0
  132. package/src/visualization/renderers/ascii/info.js +245 -0
  133. package/src/visualization/renderers/ascii/materialize.js +255 -0
  134. package/src/visualization/renderers/ascii/path.js +240 -0
  135. package/src/visualization/renderers/ascii/progress.js +32 -0
  136. package/src/visualization/renderers/ascii/symbols.js +33 -0
  137. package/src/visualization/renderers/ascii/table.js +19 -0
  138. package/src/visualization/renderers/browser/index.js +1 -0
  139. package/src/visualization/renderers/svg/index.js +159 -0
  140. package/src/visualization/utils/ansi.js +14 -0
  141. package/src/visualization/utils/time.js +40 -0
  142. package/src/visualization/utils/truncate.js +40 -0
  143. package/src/visualization/utils/unicode.js +52 -0
@@ -0,0 +1,787 @@
1
+ /**
2
+ * @fileoverview Git-backed persistence adapter for WARP graph storage.
3
+ *
4
+ * This module provides the concrete implementation of {@link GraphPersistencePort}
5
+ * that translates high-level graph operations into Git plumbing commands. It serves
6
+ * as the primary adapter in the hexagonal architecture, bridging the domain layer
7
+ * to the underlying Git storage substrate.
8
+ *
9
+ * ## Architecture Role
10
+ *
11
+ * In WARP's hexagonal architecture, GitGraphAdapter sits at the infrastructure layer:
12
+ *
13
+ * ```
14
+ * Domain (WarpGraph, JoinReducer)
15
+ * ↓
16
+ * Ports (GraphPersistencePort - abstract interface)
17
+ * ↓
18
+ * Adapters (GitGraphAdapter - this module)
19
+ * ↓
20
+ * External (@git-stunts/plumbing → Git)
21
+ * ```
22
+ *
23
+ * All graph data is stored as Git commits pointing to the well-known empty tree
24
+ * (`4b825dc642cb6eb9a060e54bf8d69288fbee4904`). This design means no files appear
25
+ * in the working directory, yet all data inherits Git's content-addressing,
26
+ * cryptographic integrity, and distributed replication capabilities.
27
+ *
28
+ * ## Multi-Writer Concurrency
29
+ *
30
+ * WARP supports multiple concurrent writers without coordination. Each writer
31
+ * maintains an independent patch chain under `refs/warp/<graph>/writers/<writerId>`.
32
+ * This adapter handles the inevitable lock contention via automatic retry with
33
+ * exponential backoff for transient Git errors (ref locks, I/O timeouts).
34
+ *
35
+ * ## Security
36
+ *
37
+ * All user-supplied inputs (refs, OIDs, config keys) are validated before being
38
+ * passed to Git commands to prevent command injection attacks. See the private
39
+ * `_validate*` methods for validation rules.
40
+ *
41
+ * @module infrastructure/adapters/GitGraphAdapter
42
+ * @see {@link GraphPersistencePort} for the abstract interface contract
43
+ * @see {@link https://git-scm.com/book/en/v2/Git-Internals-Plumbing-and-Porcelain} for Git plumbing concepts
44
+ */
45
+
46
+ import { retry } from '@git-stunts/alfred';
47
+ import GraphPersistencePort from '../../ports/GraphPersistencePort.js';
48
+
49
+ /**
50
+ * Transient Git errors that are safe to retry automatically.
51
+ *
52
+ * These patterns represent temporary conditions that resolve on their own:
53
+ *
54
+ * - **"cannot lock ref"**: Another process holds the ref lock (common in multi-writer
55
+ * scenarios where multiple writers attempt concurrent commits). Git uses file-based
56
+ * locking (`<ref>.lock` files), so concurrent writes naturally contend.
57
+ *
58
+ * - **"resource temporarily unavailable"**: OS-level I/O contention, typically from
59
+ * file descriptor limits or NFS lock issues on network filesystems.
60
+ *
61
+ * - **"connection timed out"**: Network issues when the Git repository is accessed
62
+ * over a network protocol (SSH, HTTPS) or when using NFS-mounted storage.
63
+ *
64
+ * Non-transient errors (e.g., "repository not found", "permission denied") are NOT
65
+ * retried and propagate immediately to the caller.
66
+ *
67
+ * @type {string[]}
68
+ * @private
69
+ */
70
+ const TRANSIENT_ERROR_PATTERNS = [
71
+ 'cannot lock ref',
72
+ 'resource temporarily unavailable',
73
+ 'connection timed out',
74
+ ];
75
+
76
+ /**
77
+ * Determines if an error is transient and safe to retry.
78
+ * @param {Error} error - The error to check
79
+ * @returns {boolean} True if the error is transient
80
+ */
81
+ function isTransientError(error) {
82
+ const message = (error.message || '').toLowerCase();
83
+ const stderr = (error.details?.stderr || '').toLowerCase();
84
+ const searchText = `${message} ${stderr}`;
85
+ return TRANSIENT_ERROR_PATTERNS.some(pattern => searchText.includes(pattern));
86
+ }
87
+
88
+ /**
89
+ * Default retry options for git operations.
90
+ * Uses exponential backoff with decorrelated jitter.
91
+ * @type {import('@git-stunts/alfred').RetryOptions}
92
+ */
93
+ const DEFAULT_RETRY_OPTIONS = {
94
+ retries: 3,
95
+ delay: 100,
96
+ maxDelay: 2000,
97
+ backoff: 'exponential',
98
+ jitter: 'decorrelated',
99
+ shouldRetry: isTransientError,
100
+ };
101
+
102
+ /**
103
+ * Extracts the exit code from a Git command error.
104
+ * Checks multiple possible locations where the exit code may be stored.
105
+ * @param {Error} err - The error object
106
+ * @returns {number|undefined} The exit code if found
107
+ */
108
+ function getExitCode(err) {
109
+ return err?.details?.code ?? err?.exitCode ?? err?.code;
110
+ }
111
+
112
+ /**
113
+ * Checks whether a Git ref exists without resolving it.
114
+ * @param {function(Object): Promise<string>} execute - The git command executor function
115
+ * @param {string} ref - The ref to check (e.g., 'refs/warp/events/writers/alice')
116
+ * @returns {Promise<boolean>} True if the ref exists, false otherwise
117
+ * @throws {Error} If the git command fails for reasons other than a missing ref
118
+ */
119
+ async function refExists(execute, ref) {
120
+ try {
121
+ await execute({ args: ['show-ref', '--verify', '--quiet', ref] });
122
+ return true;
123
+ } catch (err) {
124
+ if (getExitCode(err) === 1) {
125
+ return false;
126
+ }
127
+ throw err;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Concrete implementation of {@link GraphPersistencePort} using Git plumbing commands.
133
+ *
134
+ * This adapter translates abstract graph persistence operations into Git plumbing
135
+ * commands (`commit-tree`, `hash-object`, `update-ref`, `cat-file`, etc.). It serves
136
+ * as the bridge between WARP's domain logic and Git's content-addressed storage.
137
+ *
138
+ * Implements all five focused ports via the composite GraphPersistencePort:
139
+ * - {@link CommitPort} — commit creation, reading, logging, counting, ping
140
+ * - {@link BlobPort} — blob read/write
141
+ * - {@link TreePort} — tree read/write, emptyTree getter
142
+ * - {@link RefPort} — ref update/read/delete
143
+ * - {@link ConfigPort} — git config get/set
144
+ *
145
+ * ## Retry Strategy
146
+ *
147
+ * All write operations use automatic retry with exponential backoff to handle
148
+ * transient Git errors. This is essential for multi-writer scenarios where
149
+ * concurrent writers may contend for ref locks:
150
+ *
151
+ * - **Retries**: 3 attempts by default
152
+ * - **Initial delay**: 100ms
153
+ * - **Max delay**: 2000ms (2 seconds)
154
+ * - **Backoff**: Exponential with decorrelated jitter to prevent thundering herd
155
+ * - **Retry condition**: Only transient errors (see {@link TRANSIENT_ERROR_PATTERNS})
156
+ *
157
+ * Custom retry options can be provided via the constructor to tune behavior
158
+ * for specific deployment environments (e.g., longer delays for NFS storage).
159
+ *
160
+ * ## Thread Safety
161
+ *
162
+ * This adapter is safe for concurrent use from multiple async contexts within
163
+ * the same Node.js process. Git's file-based locking provides external
164
+ * synchronization, and the retry logic handles lock contention gracefully.
165
+ *
166
+ * @extends GraphPersistencePort
167
+ * @implements {CommitPort}
168
+ * @implements {BlobPort}
169
+ * @implements {TreePort}
170
+ * @implements {RefPort}
171
+ * @implements {ConfigPort}
172
+ * @see {@link GraphPersistencePort} for the abstract interface contract
173
+ * @see {@link DEFAULT_RETRY_OPTIONS} for retry configuration details
174
+ *
175
+ * @example
176
+ * // Basic usage with default retry options
177
+ * import Plumbing from '@git-stunts/plumbing';
178
+ * import GitGraphAdapter from './GitGraphAdapter.js';
179
+ *
180
+ * const plumbing = new Plumbing({ cwd: '/path/to/repo' });
181
+ * const adapter = new GitGraphAdapter({ plumbing });
182
+ *
183
+ * // Create a commit pointing to the empty tree
184
+ * const sha = await adapter.commitNode({ message: 'patch data...' });
185
+ *
186
+ * @example
187
+ * // Custom retry options for high-latency storage
188
+ * const adapter = new GitGraphAdapter({
189
+ * plumbing,
190
+ * retryOptions: {
191
+ * retries: 5,
192
+ * delay: 200,
193
+ * maxDelay: 5000,
194
+ * }
195
+ * });
196
+ */
197
+ export default class GitGraphAdapter extends GraphPersistencePort {
198
+ /**
199
+ * Creates a new GitGraphAdapter instance.
200
+ *
201
+ * @param {Object} options - Configuration options
202
+ * @param {import('@git-stunts/plumbing').default} options.plumbing - The Git plumbing
203
+ * instance to use for executing Git commands. Must be initialized with a valid
204
+ * repository path.
205
+ * @param {import('@git-stunts/alfred').RetryOptions} [options.retryOptions={}] - Custom
206
+ * retry options to override the defaults. Useful for tuning retry behavior based
207
+ * on deployment environment:
208
+ * - `retries` (number): Maximum retry attempts (default: 3)
209
+ * - `delay` (number): Initial delay in ms (default: 100)
210
+ * - `maxDelay` (number): Maximum delay cap in ms (default: 2000)
211
+ * - `backoff` ('exponential'|'linear'|'constant'): Backoff strategy
212
+ * - `jitter` ('full'|'decorrelated'|'none'): Jitter strategy
213
+ * - `shouldRetry` (function): Custom predicate for retryable errors
214
+ *
215
+ * @throws {Error} If plumbing is not provided
216
+ *
217
+ * @example
218
+ * const adapter = new GitGraphAdapter({
219
+ * plumbing: new Plumbing({ cwd: '/repo' }),
220
+ * retryOptions: { retries: 5, delay: 200 }
221
+ * });
222
+ */
223
+ constructor({ plumbing, retryOptions = {} }) {
224
+ super();
225
+ if (!plumbing) {
226
+ throw new Error('plumbing is required');
227
+ }
228
+ this.plumbing = plumbing;
229
+ this._retryOptions = { ...DEFAULT_RETRY_OPTIONS, ...retryOptions };
230
+ }
231
+
232
+ /**
233
+ * Executes a git command with retry logic.
234
+ * @param {Object} options - Options to pass to plumbing.execute
235
+ * @returns {Promise<string>} Command output
236
+ * @private
237
+ */
238
+ async _executeWithRetry(options) {
239
+ return await retry(() => this.plumbing.execute(options), this._retryOptions);
240
+ }
241
+
242
+ /**
243
+ * The well-known SHA for Git's empty tree object.
244
+ * @type {string}
245
+ * @readonly
246
+ */
247
+ get emptyTree() {
248
+ return this.plumbing.emptyTree;
249
+ }
250
+
251
+ /**
252
+ * Creates a commit pointing to the empty tree.
253
+ * @param {Object} options
254
+ * @param {string} options.message - The commit message (typically CBOR-encoded patch data)
255
+ * @param {string[]} [options.parents=[]] - Parent commit SHAs
256
+ * @param {boolean} [options.sign=false] - Whether to GPG-sign the commit
257
+ * @returns {Promise<string>} The SHA of the created commit
258
+ * @throws {Error} If any parent OID is invalid
259
+ */
260
+ async commitNode({ message, parents = [], sign = false }) {
261
+ for (const p of parents) {
262
+ this._validateOid(p);
263
+ }
264
+ const parentArgs = parents.flatMap(p => ['-p', p]);
265
+ const signArgs = sign ? ['-S'] : [];
266
+ const args = ['commit-tree', this.emptyTree, ...parentArgs, ...signArgs, '-m', message];
267
+
268
+ const oid = await this._executeWithRetry({ args });
269
+ return oid.trim();
270
+ }
271
+
272
+ /**
273
+ * Creates a commit pointing to a custom tree (not the empty tree).
274
+ * Used for WARP patch commits that have attachment trees.
275
+ * @param {Object} options
276
+ * @param {string} options.treeOid - The tree OID to point to
277
+ * @param {string[]} [options.parents=[]] - Parent commit SHAs
278
+ * @param {string} options.message - Commit message
279
+ * @param {boolean} [options.sign=false] - Whether to GPG sign
280
+ * @returns {Promise<string>} The created commit SHA
281
+ */
282
+ async commitNodeWithTree({ treeOid, parents = [], message, sign = false }) {
283
+ this._validateOid(treeOid);
284
+ for (const p of parents) {
285
+ this._validateOid(p);
286
+ }
287
+ const parentArgs = parents.flatMap(p => ['-p', p]);
288
+ const signArgs = sign ? ['-S'] : [];
289
+ const args = ['commit-tree', treeOid, ...parentArgs, ...signArgs, '-m', message];
290
+
291
+ const oid = await this._executeWithRetry({ args });
292
+ return oid.trim();
293
+ }
294
+
295
+ /**
296
+ * Retrieves the raw commit message for a given SHA.
297
+ * @param {string} sha - The commit SHA to read
298
+ * @returns {Promise<string>} The raw commit message content
299
+ * @throws {Error} If the SHA is invalid
300
+ */
301
+ async showNode(sha) {
302
+ this._validateOid(sha);
303
+ return await this._executeWithRetry({ args: ['show', '-s', '--format=%B', sha] });
304
+ }
305
+
306
+ /**
307
+ * Gets full commit metadata for a node.
308
+ * @param {string} sha - The commit SHA to retrieve
309
+ * @returns {Promise<{sha: string, message: string, author: string, date: string, parents: string[]}>}
310
+ * Full commit metadata including SHA, message, author, date, and parent SHAs
311
+ * @throws {Error} If the SHA is invalid or the commit format is malformed
312
+ */
313
+ async getNodeInfo(sha) {
314
+ this._validateOid(sha);
315
+ // Format: SHA, author, date, parents (space-separated), then message
316
+ // Using %x00 to separate fields for reliable parsing
317
+ const format = '%H%x00%an <%ae>%x00%aI%x00%P%x00%B';
318
+ const output = await this._executeWithRetry({
319
+ args: ['show', '-s', `--format=${format}`, sha]
320
+ });
321
+
322
+ const parts = output.split('\x00');
323
+ if (parts.length < 5) {
324
+ throw new Error(`Invalid commit format for SHA ${sha}`);
325
+ }
326
+
327
+ const [commitSha, author, date, parentsStr, ...messageParts] = parts;
328
+ const message = messageParts.join('\x00'); // In case message contained NUL (shouldn't happen)
329
+ const parents = parentsStr ? parentsStr.split(' ').filter(p => p) : [];
330
+
331
+ return {
332
+ sha: commitSha.trim(),
333
+ message,
334
+ author: author.trim(),
335
+ date: date.trim(),
336
+ parents,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Returns raw git log output for a ref.
342
+ * @param {Object} options
343
+ * @param {string} options.ref - The Git ref to log from
344
+ * @param {number} [options.limit=50] - Maximum number of commits to return
345
+ * @param {string} [options.format] - Custom format string for git log
346
+ * @returns {Promise<string>} The raw log output
347
+ * @throws {Error} If the ref is invalid or the limit is out of range
348
+ */
349
+ async logNodes({ ref, limit = 50, format }) {
350
+ this._validateRef(ref);
351
+ this._validateLimit(limit);
352
+ const args = ['log', `-${limit}`];
353
+ if (format) {
354
+ args.push(`--format=${format}`);
355
+ }
356
+ args.push(ref);
357
+ return await this._executeWithRetry({ args });
358
+ }
359
+
360
+ /**
361
+ * Streams git log output for the given ref.
362
+ * Uses the -z flag to produce NUL-terminated output, which:
363
+ * - Ensures reliable parsing of commits with special characters in messages
364
+ * - Ignores the i18n.logOutputEncoding config setting for consistent output
365
+ * @param {Object} options
366
+ * @param {string} options.ref - The ref to log from
367
+ * @param {number} [options.limit=1000000] - Maximum number of commits to return
368
+ * @param {string} [options.format] - Custom format string for git log
369
+ * @returns {Promise<import('node:stream').Readable>} A readable stream of git log output (NUL-terminated records)
370
+ * @throws {Error} If the ref is invalid or the limit is out of range
371
+ */
372
+ async logNodesStream({ ref, limit = 1000000, format }) {
373
+ this._validateRef(ref);
374
+ this._validateLimit(limit);
375
+ // -z flag ensures NUL-terminated output and ignores i18n.logOutputEncoding config
376
+ const args = ['log', '-z', `-${limit}`];
377
+ if (format) {
378
+ // Strip NUL bytes from format - git -z flag handles NUL termination automatically
379
+ // Node.js child_process rejects args containing null bytes
380
+ // eslint-disable-next-line no-control-regex
381
+ const cleanFormat = format.replace(/\x00/g, '');
382
+ args.push(`--format=${cleanFormat}`);
383
+ }
384
+ args.push(ref);
385
+ return await this.plumbing.executeStream({ args });
386
+ }
387
+
388
+ /**
389
+ * Validates that a ref is safe to use in git commands.
390
+ * Prevents command injection via malicious ref names.
391
+ * @param {string} ref - The ref to validate
392
+ * @throws {Error} If ref contains invalid characters, is too long, or starts with -/--
393
+ * @private
394
+ */
395
+ _validateRef(ref) {
396
+ if (!ref || typeof ref !== 'string') {
397
+ throw new Error('Ref must be a non-empty string');
398
+ }
399
+ // Prevent buffer overflow attacks with extremely long refs
400
+ if (ref.length > 1024) {
401
+ throw new Error(`Ref too long: ${ref.length} chars. Maximum is 1024`);
402
+ }
403
+ // Prevent git option injection (must check before pattern matching)
404
+ if (ref.startsWith('-') || ref.startsWith('--')) {
405
+ throw new Error(`Invalid ref: ${ref}. Refs cannot start with - or --. See https://github.com/git-stunts/git-warp#security`);
406
+ }
407
+ // Allow alphanumeric, ., /, -, _ in names
408
+ // Allow ancestry operators: ^ or ~ optionally followed by digits
409
+ // Allow range operators: .. between names
410
+ const validRefPattern = /^[a-zA-Z0-9._/-]+((~\d*|\^\d*|\.\.[a-zA-Z0-9._/-]+)*)$/;
411
+ if (!validRefPattern.test(ref)) {
412
+ throw new Error(`Invalid ref format: ${ref}. Only alphanumeric characters, ., /, -, _, ^, ~, and range operators are allowed. See https://github.com/git-stunts/git-warp#ref-validation`);
413
+ }
414
+ }
415
+
416
+ /**
417
+ * Writes content as a Git blob and returns its OID.
418
+ * @param {Buffer|string} content - The blob content to write
419
+ * @returns {Promise<string>} The Git OID of the created blob
420
+ */
421
+ async writeBlob(content) {
422
+ const oid = await this._executeWithRetry({
423
+ args: ['hash-object', '-w', '--stdin'],
424
+ input: content,
425
+ });
426
+ return oid.trim();
427
+ }
428
+
429
+ /**
430
+ * Creates a Git tree from mktree-formatted entries.
431
+ * @param {string[]} entries - Lines in git mktree format (e.g., "100644 blob <oid>\t<path>")
432
+ * @returns {Promise<string>} The Git OID of the created tree
433
+ */
434
+ async writeTree(entries) {
435
+ const oid = await this._executeWithRetry({
436
+ args: ['mktree'],
437
+ input: `${entries.join('\n')}\n`,
438
+ });
439
+ return oid.trim();
440
+ }
441
+
442
+ /**
443
+ * Reads a tree and returns a map of path to content.
444
+ * Processes blobs sequentially to avoid spawning too many concurrent reads.
445
+ * @param {string} treeOid - The tree OID to read
446
+ * @returns {Promise<Record<string, Buffer>>} Map of file path to blob content
447
+ */
448
+ async readTree(treeOid) {
449
+ const oids = await this.readTreeOids(treeOid);
450
+ const files = {};
451
+ // Process sequentially to avoid spawning thousands of concurrent readBlob calls
452
+ for (const [path, oid] of Object.entries(oids)) {
453
+ files[path] = await this.readBlob(oid);
454
+ }
455
+ return files;
456
+ }
457
+
458
+ /**
459
+ * Reads a tree and returns a map of path to blob OID.
460
+ * Useful for lazy-loading shards without reading all blob contents.
461
+ * @param {string} treeOid - The tree OID to read
462
+ * @returns {Promise<Record<string, string>>} Map of file path to blob OID
463
+ * @throws {Error} If the tree OID is invalid
464
+ */
465
+ async readTreeOids(treeOid) {
466
+ this._validateOid(treeOid);
467
+ const output = await this._executeWithRetry({
468
+ args: ['ls-tree', '-r', '-z', treeOid]
469
+ });
470
+
471
+ const oids = {};
472
+ // NUL-separated records: "mode type oid\tpath\0"
473
+ const records = output.split('\0');
474
+ for (const record of records) {
475
+ if (!record) {
476
+ continue;
477
+ }
478
+ // Format: "mode type oid\tpath"
479
+ const tabIndex = record.indexOf('\t');
480
+ if (tabIndex === -1) {
481
+ continue;
482
+ }
483
+ const meta = record.slice(0, tabIndex);
484
+ const path = record.slice(tabIndex + 1);
485
+ const [, , oid] = meta.split(' ');
486
+ oids[path] = oid;
487
+ }
488
+ return oids;
489
+ }
490
+
491
+ /**
492
+ * Reads the content of a Git blob.
493
+ * @param {string} oid - The blob OID to read
494
+ * @returns {Promise<Buffer>} The blob content
495
+ * @throws {Error} If the OID is invalid
496
+ */
497
+ async readBlob(oid) {
498
+ this._validateOid(oid);
499
+ const stream = await this.plumbing.executeStream({
500
+ args: ['cat-file', 'blob', oid]
501
+ });
502
+ return await stream.collect({ asString: false });
503
+ }
504
+
505
+ /**
506
+ * Updates a ref to point to an OID.
507
+ * @param {string} ref - The ref name (e.g., 'refs/warp/events/writers/alice')
508
+ * @param {string} oid - The OID to point to
509
+ * @returns {Promise<void>}
510
+ * @throws {Error} If the ref or OID is invalid
511
+ */
512
+ async updateRef(ref, oid) {
513
+ this._validateRef(ref);
514
+ this._validateOid(oid);
515
+ await this._executeWithRetry({
516
+ args: ['update-ref', ref, oid]
517
+ });
518
+ }
519
+
520
+ /**
521
+ * Reads the OID a ref points to.
522
+ * @param {string} ref - The ref name
523
+ * @returns {Promise<string|null>} The OID, or null if the ref does not exist
524
+ * @throws {Error} If the ref format is invalid
525
+ */
526
+ async readRef(ref) {
527
+ this._validateRef(ref);
528
+ const exists = await refExists(this._executeWithRetry.bind(this), ref);
529
+ if (!exists) {
530
+ return null;
531
+ }
532
+ try {
533
+ const oid = await this._executeWithRetry({
534
+ args: ['rev-parse', ref]
535
+ });
536
+ return oid.trim();
537
+ } catch (err) {
538
+ if (getExitCode(err) === 1) {
539
+ return null;
540
+ }
541
+ throw err;
542
+ }
543
+ }
544
+
545
+ /**
546
+ * Deletes a ref.
547
+ * @param {string} ref - The ref name to delete
548
+ * @returns {Promise<void>}
549
+ * @throws {Error} If the ref format is invalid
550
+ */
551
+ async deleteRef(ref) {
552
+ this._validateRef(ref);
553
+ await this._executeWithRetry({
554
+ args: ['update-ref', '-d', ref]
555
+ });
556
+ }
557
+
558
+ /**
559
+ * Validates that an OID is safe to use in git commands.
560
+ * @param {string} oid - The OID to validate
561
+ * @throws {Error} If OID is invalid
562
+ * @private
563
+ */
564
+ _validateOid(oid) {
565
+ if (!oid || typeof oid !== 'string') {
566
+ throw new Error('OID must be a non-empty string');
567
+ }
568
+ if (oid.length > 64) {
569
+ throw new Error(`OID too long: ${oid.length} chars. Maximum is 64`);
570
+ }
571
+ const validOidPattern = /^[0-9a-fA-F]{4,64}$/;
572
+ if (!validOidPattern.test(oid)) {
573
+ throw new Error(`Invalid OID format: ${oid}`);
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Validates that a limit is a safe positive integer.
579
+ * @param {number} limit - The limit to validate
580
+ * @throws {Error} If limit is invalid
581
+ * @private
582
+ */
583
+ _validateLimit(limit) {
584
+ if (typeof limit !== 'number' || !Number.isFinite(limit)) {
585
+ throw new Error('Limit must be a finite number');
586
+ }
587
+ if (!Number.isInteger(limit)) {
588
+ throw new Error('Limit must be an integer');
589
+ }
590
+ if (limit <= 0) {
591
+ throw new Error('Limit must be a positive integer');
592
+ }
593
+ if (limit > 10_000_000) {
594
+ throw new Error(`Limit too large: ${limit}. Maximum is 10,000,000`);
595
+ }
596
+ }
597
+
598
+ /**
599
+ * Checks if a node (commit) exists in the repository.
600
+ * Uses `git cat-file -e` for efficient existence checking without loading content.
601
+ * @param {string} sha - The commit SHA to check
602
+ * @returns {Promise<boolean>} True if the node exists, false otherwise
603
+ * @throws {Error} If the SHA format is invalid
604
+ */
605
+ async nodeExists(sha) {
606
+ this._validateOid(sha);
607
+ try {
608
+ await this._executeWithRetry({ args: ['cat-file', '-e', sha] });
609
+ return true;
610
+ } catch (err) {
611
+ if (getExitCode(err) === 1) {
612
+ return false;
613
+ }
614
+ throw err;
615
+ }
616
+ }
617
+
618
+ /**
619
+ * Lists refs matching a prefix.
620
+ * @param {string} prefix - The ref prefix to match (e.g., 'refs/warp/events/writers/')
621
+ * @returns {Promise<string[]>} Array of matching ref paths
622
+ * @throws {Error} If the prefix is invalid
623
+ */
624
+ async listRefs(prefix) {
625
+ this._validateRef(prefix);
626
+ const output = await this._executeWithRetry({
627
+ args: ['for-each-ref', '--format=%(refname)', prefix]
628
+ });
629
+ // Parse output - one ref per line, filter empty lines
630
+ return output.split('\n').filter(line => line.trim());
631
+ }
632
+
633
+ /**
634
+ * Pings the repository to verify accessibility.
635
+ * Uses `git rev-parse --is-inside-work-tree` as a lightweight check.
636
+ *
637
+ * Note: latencyMs includes retry overhead if retries occur, so it may not
638
+ * reflect single-trip repository latency in degraded conditions.
639
+ *
640
+ * @returns {Promise<{ok: boolean, latencyMs: number}>} Health check result with latency
641
+ */
642
+ async ping() {
643
+ const start = Date.now();
644
+ try {
645
+ await this._executeWithRetry({ args: ['rev-parse', '--is-inside-work-tree'] });
646
+ const latencyMs = Date.now() - start;
647
+ return { ok: true, latencyMs };
648
+ } catch {
649
+ const latencyMs = Date.now() - start;
650
+ return { ok: false, latencyMs };
651
+ }
652
+ }
653
+
654
+ /**
655
+ * Counts nodes reachable from a ref without loading them into memory.
656
+ * Uses `git rev-list --count` for O(1) memory efficiency.
657
+ * @param {string} ref - Git ref to count from (e.g., 'HEAD', 'main', SHA)
658
+ * @returns {Promise<number>} The count of reachable nodes
659
+ * @throws {Error} If the ref is invalid
660
+ */
661
+ async countNodes(ref) {
662
+ this._validateRef(ref);
663
+ const output = await this._executeWithRetry({
664
+ args: ['rev-list', '--count', ref]
665
+ });
666
+ return parseInt(output.trim(), 10);
667
+ }
668
+
669
+ /**
670
+ * Checks if one commit is an ancestor of another.
671
+ * Uses `git merge-base --is-ancestor` for efficient ancestry testing.
672
+ *
673
+ * @param {string} potentialAncestor - The commit that might be an ancestor
674
+ * @param {string} descendant - The commit that might be a descendant
675
+ * @returns {Promise<boolean>} True if potentialAncestor is an ancestor of descendant
676
+ * @throws {Error} If either OID is invalid
677
+ */
678
+ async isAncestor(potentialAncestor, descendant) {
679
+ this._validateOid(potentialAncestor);
680
+ this._validateOid(descendant);
681
+ try {
682
+ await this._executeWithRetry({
683
+ args: ['merge-base', '--is-ancestor', potentialAncestor, descendant]
684
+ });
685
+ return true; // Exit code 0 means it IS an ancestor
686
+ } catch (err) {
687
+ if (this._getExitCode(err) === 1) {
688
+ return false; // Exit code 1 means it is NOT an ancestor
689
+ }
690
+ throw err; // Re-throw unexpected errors
691
+ }
692
+ }
693
+
694
+ /**
695
+ * Reads a git config value.
696
+ * @param {string} key - The config key to read (e.g., 'warp.writerId.events')
697
+ * @returns {Promise<string|null>} The config value or null if not set
698
+ * @throws {Error} If the key format is invalid
699
+ */
700
+ async configGet(key) {
701
+ this._validateConfigKey(key);
702
+ try {
703
+ const value = await this._executeWithRetry({
704
+ args: ['config', '--get', key]
705
+ });
706
+ // Preserve empty-string values; only drop trailing newline
707
+ return value.replace(/\n$/, '');
708
+ } catch (err) {
709
+ if (this._isConfigKeyNotFound(err)) {
710
+ return null;
711
+ }
712
+ throw err;
713
+ }
714
+ }
715
+
716
+ /**
717
+ * Sets a git config value.
718
+ * @param {string} key - The config key to set (e.g., 'warp.writerId.events')
719
+ * @param {string} value - The value to set
720
+ * @returns {Promise<void>}
721
+ * @throws {Error} If the key format is invalid or value is not a string
722
+ */
723
+ async configSet(key, value) {
724
+ this._validateConfigKey(key);
725
+ if (typeof value !== 'string') {
726
+ throw new Error('Config value must be a string');
727
+ }
728
+ await this._executeWithRetry({
729
+ args: ['config', key, value]
730
+ });
731
+ }
732
+
733
+ /**
734
+ * Validates that a config key is safe to use in git commands.
735
+ * @param {string} key - The config key to validate
736
+ * @throws {Error} If key is invalid
737
+ * @private
738
+ */
739
+ _validateConfigKey(key) {
740
+ if (!key || typeof key !== 'string') {
741
+ throw new Error('Config key must be a non-empty string');
742
+ }
743
+ if (key.length > 256) {
744
+ throw new Error(`Config key too long: ${key.length} chars. Maximum is 256`);
745
+ }
746
+ // Prevent git option injection
747
+ if (key.startsWith('-')) {
748
+ throw new Error(`Invalid config key: ${key}. Keys cannot start with -`);
749
+ }
750
+ // Allow section.subsection.key format
751
+ const validKeyPattern = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
752
+ if (!validKeyPattern.test(key)) {
753
+ throw new Error(`Invalid config key format: ${key}`);
754
+ }
755
+ }
756
+
757
+ /**
758
+ * Extracts the exit code from a Git command error.
759
+ * Delegates to the standalone getExitCode helper.
760
+ * @param {Error} err - The error object
761
+ * @returns {number|undefined} The exit code if found
762
+ * @private
763
+ */
764
+ _getExitCode(err) {
765
+ return getExitCode(err);
766
+ }
767
+
768
+ /**
769
+ * Checks if an error indicates a config key was not found.
770
+ * Exit code 1 from `git config --get` means the key doesn't exist.
771
+ * @param {Error} err - The error object
772
+ * @returns {boolean} True if the error indicates key not found
773
+ * @private
774
+ */
775
+ _isConfigKeyNotFound(err) {
776
+ // Primary check: exit code 1 means key not found for git config --get
777
+ if (this._getExitCode(err) === 1) {
778
+ return true;
779
+ }
780
+ // Fallback for wrapped errors where exit code is embedded in message.
781
+ // This is intentionally conservative - only matches the exact pattern
782
+ // from git config failures to avoid false positives from unrelated errors.
783
+ const msg = (err.message || '').toLowerCase();
784
+ const stderr = (err.details?.stderr || '').toLowerCase();
785
+ return msg.includes('exit code 1') || stderr.includes('exit code 1');
786
+ }
787
+ }