@git-stunts/git-warp 10.4.2 → 10.7.0

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.
@@ -0,0 +1,488 @@
1
+ /* eslint-disable @typescript-eslint/require-await -- all async methods match the port contract */
2
+ /**
3
+ * @fileoverview In-memory persistence adapter for WARP graph storage.
4
+ *
5
+ * Implements the same {@link GraphPersistencePort} contract as GitGraphAdapter
6
+ * but stores all data in Maps. Designed for fast unit/integration tests that
7
+ * don't need real Git I/O.
8
+ *
9
+ * SHA computation follows Git's object format so debugging is straightforward,
10
+ * but cross-adapter SHA matching is NOT guaranteed.
11
+ *
12
+ * @module infrastructure/adapters/InMemoryGraphAdapter
13
+ */
14
+
15
+ import { createHash } from 'node:crypto';
16
+ import { Readable } from 'node:stream';
17
+ import GraphPersistencePort from '../../ports/GraphPersistencePort.js';
18
+ import { validateOid, validateRef, validateLimit, validateConfigKey } from './adapterValidation.js';
19
+
20
+ /** Well-known SHA for Git's empty tree. */
21
+ const EMPTY_TREE_OID = '4b825dc642cb6eb9a060e54bf8d69288fbee4904';
22
+
23
+ // ── SHA helpers ─────────────────────────────────────────────────────────
24
+
25
+ /**
26
+ * Computes a Git blob SHA-1: `SHA1("blob " + len + "\0" + content)`.
27
+ * @param {Buffer} content
28
+ * @returns {string} 40-hex SHA
29
+ */
30
+ function hashBlob(content) {
31
+ const header = Buffer.from(`blob ${content.length}\0`);
32
+ return createHash('sha1').update(header).update(content).digest('hex');
33
+ }
34
+
35
+ /**
36
+ * Builds the binary tree buffer in Git's internal format and hashes it.
37
+ *
38
+ * Each entry is: `<mode> <path>\0<20-byte binary OID>`
39
+ * Entries are sorted by path (byte order), matching Git's canonical sort.
40
+ *
41
+ * @param {Array<{mode: string, path: string, oid: string}>} entries
42
+ * @returns {string} 40-hex SHA
43
+ */
44
+ function hashTree(entries) {
45
+ const sorted = [...entries].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
46
+ const parts = sorted.map(e => {
47
+ const prefix = Buffer.from(`${e.mode} ${e.path}\0`);
48
+ const oidBin = Buffer.from(e.oid, 'hex');
49
+ return Buffer.concat([prefix, oidBin]);
50
+ });
51
+ const body = Buffer.concat(parts);
52
+ const header = Buffer.from(`tree ${body.length}\0`);
53
+ return createHash('sha1').update(header).update(body).digest('hex');
54
+ }
55
+
56
+ /**
57
+ * Builds a Git-style commit string and hashes it.
58
+ * @param {{treeOid: string, parents: string[], message: string, author: string, date: string}} opts
59
+ * @returns {string} 40-hex SHA
60
+ */
61
+ function hashCommit({ treeOid, parents, message, author, date }) {
62
+ const lines = [`tree ${treeOid}`];
63
+ for (const p of parents) {
64
+ lines.push(`parent ${p}`);
65
+ }
66
+ lines.push(`author ${author} ${date}`);
67
+ lines.push(`committer ${author} ${date}`);
68
+ lines.push('');
69
+ lines.push(message);
70
+ const body = lines.join('\n');
71
+ const header = `commit ${Buffer.byteLength(body)}\0`;
72
+ return createHash('sha1').update(header).update(body).digest('hex');
73
+ }
74
+
75
+ // ── Adapter ─────────────────────────────────────────────────────────────
76
+
77
+ /**
78
+ * In-memory implementation of {@link GraphPersistencePort}.
79
+ *
80
+ * Data structures:
81
+ * - `_commits` — Map<sha, {treeOid, parents[], message, author, date}>
82
+ * - `_blobs` — Map<oid, Buffer>
83
+ * - `_trees` — Map<oid, Array<{mode, path, oid}>>
84
+ * - `_refs` — Map<refName, sha>
85
+ * - `_config` — Map<key, value>
86
+ *
87
+ * @extends GraphPersistencePort
88
+ */
89
+ export default class InMemoryGraphAdapter extends GraphPersistencePort {
90
+ /**
91
+ * @param {{ author?: string, clock?: { now: () => number } }} [options]
92
+ */
93
+ constructor({ author, clock } = {}) {
94
+ super();
95
+ this._author = author || 'InMemory <inmemory@test>';
96
+ this._clock = clock || { now: () => Date.now() };
97
+
98
+ /** @type {Map<string, {treeOid: string, parents: string[], message: string, author: string, date: string}>} */
99
+ this._commits = new Map();
100
+ /** @type {Map<string, Buffer>} */
101
+ this._blobs = new Map();
102
+ /** @type {Map<string, Array<{mode: string, path: string, oid: string}>>} */
103
+ this._trees = new Map();
104
+ /** @type {Map<string, string>} */
105
+ this._refs = new Map();
106
+ /** @type {Map<string, string>} */
107
+ this._config = new Map();
108
+ }
109
+
110
+ // ── TreePort ────────────────────────────────────────────────────────
111
+
112
+ /** @type {string} */
113
+ get emptyTree() {
114
+ return EMPTY_TREE_OID;
115
+ }
116
+
117
+ /**
118
+ * Creates a tree from mktree-formatted entries.
119
+ * @param {string[]} entries - Lines in `"<mode> <type> <oid>\t<path>"` format
120
+ * @returns {Promise<string>}
121
+ */
122
+ async writeTree(entries) {
123
+ const parsed = entries.map(line => {
124
+ const tabIdx = line.indexOf('\t');
125
+ if (tabIdx === -1) {
126
+ throw new Error(`Invalid mktree entry (missing tab): ${line}`);
127
+ }
128
+ const meta = line.slice(0, tabIdx);
129
+ const path = line.slice(tabIdx + 1);
130
+ const [mode, , oid] = meta.split(' ');
131
+ return { mode, path, oid };
132
+ });
133
+ const oid = hashTree(parsed);
134
+ this._trees.set(oid, parsed);
135
+ return oid;
136
+ }
137
+
138
+ /**
139
+ * @param {string} treeOid
140
+ * @returns {Promise<Record<string, string>>}
141
+ */
142
+ async readTreeOids(treeOid) {
143
+ validateOid(treeOid);
144
+ if (treeOid === EMPTY_TREE_OID) {
145
+ return {};
146
+ }
147
+ const entries = this._trees.get(treeOid);
148
+ if (!entries) {
149
+ throw new Error(`Tree not found: ${treeOid}`);
150
+ }
151
+ /** @type {Record<string, string>} */
152
+ const result = {};
153
+ for (const e of entries) {
154
+ result[e.path] = e.oid;
155
+ }
156
+ return result;
157
+ }
158
+
159
+ /**
160
+ * @param {string} treeOid
161
+ * @returns {Promise<Record<string, Buffer>>}
162
+ */
163
+ async readTree(treeOid) {
164
+ const oids = await this.readTreeOids(treeOid);
165
+ /** @type {Record<string, Buffer>} */
166
+ const files = {};
167
+ for (const [path, oid] of Object.entries(oids)) {
168
+ files[path] = await this.readBlob(oid);
169
+ }
170
+ return files;
171
+ }
172
+
173
+ // ── BlobPort ────────────────────────────────────────────────────────
174
+
175
+ /**
176
+ * @param {Buffer|string} content
177
+ * @returns {Promise<string>}
178
+ */
179
+ async writeBlob(content) {
180
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content);
181
+ const oid = hashBlob(buf);
182
+ this._blobs.set(oid, buf);
183
+ return oid;
184
+ }
185
+
186
+ /**
187
+ * @param {string} oid
188
+ * @returns {Promise<Buffer>}
189
+ */
190
+ async readBlob(oid) {
191
+ validateOid(oid);
192
+ const buf = this._blobs.get(oid);
193
+ if (!buf) {
194
+ throw new Error(`Blob not found: ${oid}`);
195
+ }
196
+ return buf;
197
+ }
198
+
199
+ // ── CommitPort ──────────────────────────────────────────────────────
200
+
201
+ /**
202
+ * @param {{ message: string, parents?: string[], sign?: boolean }} options
203
+ * @returns {Promise<string>}
204
+ */
205
+ async commitNode({ message, parents = [] }) {
206
+ for (const p of parents) {
207
+ validateOid(p);
208
+ }
209
+ return this._createCommit(EMPTY_TREE_OID, parents, message);
210
+ }
211
+
212
+ /**
213
+ * @param {{ treeOid: string, parents?: string[], message: string, sign?: boolean }} options
214
+ * @returns {Promise<string>}
215
+ */
216
+ async commitNodeWithTree({ treeOid, parents = [], message }) {
217
+ validateOid(treeOid);
218
+ for (const p of parents) {
219
+ validateOid(p);
220
+ }
221
+ return this._createCommit(treeOid, parents, message);
222
+ }
223
+
224
+ /**
225
+ * @param {string} sha
226
+ * @returns {Promise<string>}
227
+ */
228
+ async showNode(sha) {
229
+ validateOid(sha);
230
+ const commit = this._commits.get(sha);
231
+ if (!commit) {
232
+ throw new Error(`Commit not found: ${sha}`);
233
+ }
234
+ return commit.message;
235
+ }
236
+
237
+ /**
238
+ * @param {string} sha
239
+ * @returns {Promise<{sha: string, message: string, author: string, date: string, parents: string[]}>}
240
+ */
241
+ async getNodeInfo(sha) {
242
+ validateOid(sha);
243
+ const commit = this._commits.get(sha);
244
+ if (!commit) {
245
+ throw new Error(`Commit not found: ${sha}`);
246
+ }
247
+ return {
248
+ sha,
249
+ message: commit.message,
250
+ author: commit.author,
251
+ date: commit.date,
252
+ parents: [...commit.parents],
253
+ };
254
+ }
255
+
256
+ /**
257
+ * @param {string} sha
258
+ * @returns {Promise<boolean>}
259
+ */
260
+ async nodeExists(sha) {
261
+ validateOid(sha);
262
+ return this._commits.has(sha);
263
+ }
264
+
265
+ /**
266
+ * @param {string} ref
267
+ * @returns {Promise<number>}
268
+ */
269
+ async countNodes(ref) {
270
+ validateRef(ref);
271
+ const tip = this._resolveRef(ref);
272
+ if (!tip) {
273
+ throw new Error(`Ref not found: ${ref}`);
274
+ }
275
+ const visited = new Set();
276
+ const stack = [tip];
277
+ while (stack.length > 0) {
278
+ const sha = /** @type {string} */ (stack.pop());
279
+ if (visited.has(sha)) {
280
+ continue;
281
+ }
282
+ visited.add(sha);
283
+ const commit = this._commits.get(sha);
284
+ if (commit) {
285
+ for (const p of commit.parents) {
286
+ stack.push(p);
287
+ }
288
+ }
289
+ }
290
+ return visited.size;
291
+ }
292
+
293
+ /**
294
+ * @param {{ ref: string, limit?: number, format?: string }} options
295
+ * @returns {Promise<string>}
296
+ */
297
+ async logNodes({ ref, limit = 50, format: _format }) {
298
+ validateRef(ref);
299
+ validateLimit(limit);
300
+ const records = this._walkLog(ref, limit);
301
+ // Format param is accepted for port compatibility but always uses
302
+ // the GitLogParser-compatible layout (SHA\nauthor\ndate\nparents\nmessage).
303
+ if (!_format) {
304
+ return records.map(c => `commit ${c.sha}\nAuthor: ${c.author}\nDate: ${c.date}\n\n ${c.message}\n`).join('\n');
305
+ }
306
+ return records.map(c => this._formatCommitRecord(c)).join('\0') + (records.length > 0 ? '\0' : '');
307
+ }
308
+
309
+ /**
310
+ * @param {{ ref: string, limit?: number, format?: string }} options
311
+ * @returns {Promise<Readable>}
312
+ */
313
+ async logNodesStream({ ref, limit = 1000000, format: _format }) {
314
+ validateRef(ref);
315
+ validateLimit(limit);
316
+ const records = this._walkLog(ref, limit);
317
+ const formatted = records.map(c => this._formatCommitRecord(c)).join('\0') + (records.length > 0 ? '\0' : '');
318
+ return Readable.from([formatted]);
319
+ }
320
+
321
+ /**
322
+ * @returns {Promise<{ok: boolean, latencyMs: number}>}
323
+ */
324
+ async ping() {
325
+ return { ok: true, latencyMs: 0 };
326
+ }
327
+
328
+ // ── RefPort ─────────────────────────────────────────────────────────
329
+
330
+ /**
331
+ * @param {string} ref
332
+ * @param {string} oid
333
+ * @returns {Promise<void>}
334
+ */
335
+ async updateRef(ref, oid) {
336
+ validateRef(ref);
337
+ validateOid(oid);
338
+ this._refs.set(ref, oid);
339
+ }
340
+
341
+ /**
342
+ * @param {string} ref
343
+ * @returns {Promise<string|null>}
344
+ */
345
+ async readRef(ref) {
346
+ validateRef(ref);
347
+ return this._refs.get(ref) || null;
348
+ }
349
+
350
+ /**
351
+ * @param {string} ref
352
+ * @returns {Promise<void>}
353
+ */
354
+ async deleteRef(ref) {
355
+ validateRef(ref);
356
+ this._refs.delete(ref);
357
+ }
358
+
359
+ /**
360
+ * @param {string} prefix
361
+ * @returns {Promise<string[]>}
362
+ */
363
+ async listRefs(prefix) {
364
+ validateRef(prefix);
365
+ const result = [];
366
+ for (const key of this._refs.keys()) {
367
+ if (key.startsWith(prefix)) {
368
+ result.push(key);
369
+ }
370
+ }
371
+ return result.sort();
372
+ }
373
+
374
+ // ── ConfigPort ──────────────────────────────────────────────────────
375
+
376
+ /**
377
+ * @param {string} key
378
+ * @returns {Promise<string|null>}
379
+ */
380
+ async configGet(key) {
381
+ validateConfigKey(key);
382
+ return this._config.get(key) ?? null;
383
+ }
384
+
385
+ /**
386
+ * @param {string} key
387
+ * @param {string} value
388
+ * @returns {Promise<void>}
389
+ */
390
+ async configSet(key, value) {
391
+ validateConfigKey(key);
392
+ if (typeof value !== 'string') {
393
+ throw new Error('Config value must be a string');
394
+ }
395
+ this._config.set(key, value);
396
+ }
397
+
398
+ // ── Private helpers ─────────────────────────────────────────────────
399
+
400
+ /**
401
+ * @param {string} treeOid
402
+ * @param {string[]} parents
403
+ * @param {string} message
404
+ * @returns {string}
405
+ */
406
+ _createCommit(treeOid, parents, message) {
407
+ const date = new Date(this._clock.now()).toISOString();
408
+ const sha = hashCommit({
409
+ treeOid,
410
+ parents,
411
+ message,
412
+ author: this._author,
413
+ date,
414
+ });
415
+ this._commits.set(sha, {
416
+ treeOid,
417
+ parents: [...parents],
418
+ message,
419
+ author: this._author,
420
+ date,
421
+ });
422
+ return sha;
423
+ }
424
+
425
+ /**
426
+ * Resolves a ref name to a SHA. If the ref looks like a raw SHA, returns it.
427
+ * @param {string} ref
428
+ * @returns {string|null}
429
+ */
430
+ _resolveRef(ref) {
431
+ if (this._refs.has(ref)) {
432
+ return /** @type {string} */ (this._refs.get(ref));
433
+ }
434
+ if (this._commits.has(ref)) {
435
+ return ref;
436
+ }
437
+ return null;
438
+ }
439
+
440
+ /**
441
+ * Walks commit history from a ref, reverse chronological (newest first),
442
+ * up to limit. Matches `git log` default ordering for merge DAGs.
443
+ * @param {string} ref
444
+ * @param {number} limit
445
+ * @returns {Array<{sha: string, message: string, author: string, date: string, parents: string[]}>}
446
+ */
447
+ _walkLog(ref, limit) {
448
+ const tip = this._resolveRef(ref);
449
+ if (!tip) {
450
+ return [];
451
+ }
452
+ /** @type {Array<{sha: string, message: string, author: string, date: string, parents: string[]}>} */
453
+ const all = [];
454
+ const visited = new Set();
455
+ const queue = [tip];
456
+ let head = 0;
457
+ while (head < queue.length) {
458
+ const sha = /** @type {string} */ (queue[head++]);
459
+ if (visited.has(sha)) {
460
+ continue;
461
+ }
462
+ visited.add(sha);
463
+ const commit = this._commits.get(sha);
464
+ if (!commit) {
465
+ continue;
466
+ }
467
+ all.push({ sha, ...commit });
468
+ for (const p of commit.parents) {
469
+ if (!visited.has(p)) {
470
+ queue.push(p);
471
+ }
472
+ }
473
+ }
474
+ // Sort by date descending (reverse chronological), matching git log
475
+ all.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0));
476
+ return all.slice(0, limit);
477
+ }
478
+
479
+ /**
480
+ * Formats a commit record in GitLogParser's expected format:
481
+ * `<SHA>\n<author>\n<date>\n<parents>\n<message>`
482
+ * @param {{sha: string, message: string, author: string, date: string, parents: string[]}} c
483
+ * @returns {string}
484
+ */
485
+ _formatCommitRecord(c) {
486
+ return `${c.sha}\n${c.author}\n${c.date}\n${c.parents.join(' ')}\n${c.message}`;
487
+ }
488
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Shared input validation for persistence adapters.
3
+ *
4
+ * These functions are extracted from GitGraphAdapter so that both Git-backed
5
+ * and in-memory adapters apply identical validation rules. This prevents
6
+ * divergence and ensures conformance tests exercise the same constraints.
7
+ *
8
+ * @module infrastructure/adapters/adapterValidation
9
+ */
10
+
11
+ /**
12
+ * Validates that an OID is a safe hex string (4–64 characters).
13
+ * @param {string} oid - The OID to validate
14
+ * @throws {Error} If OID is invalid
15
+ */
16
+ export function validateOid(oid) {
17
+ if (!oid || typeof oid !== 'string') {
18
+ throw new Error('OID must be a non-empty string');
19
+ }
20
+ if (oid.length > 64) {
21
+ throw new Error(`OID too long: ${oid.length} chars. Maximum is 64`);
22
+ }
23
+ const validOidPattern = /^[0-9a-fA-F]{4,64}$/;
24
+ if (!validOidPattern.test(oid)) {
25
+ throw new Error(`Invalid OID format: ${oid}`);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Validates that a ref is safe to use in git commands.
31
+ * Prevents command injection via malicious ref names.
32
+ * @param {string} ref - The ref to validate
33
+ * @throws {Error} If ref contains invalid characters, is too long, or starts with -/--
34
+ */
35
+ export function validateRef(ref) {
36
+ if (!ref || typeof ref !== 'string') {
37
+ throw new Error('Ref must be a non-empty string');
38
+ }
39
+ if (ref.length > 1024) {
40
+ throw new Error(`Ref too long: ${ref.length} chars. Maximum is 1024`);
41
+ }
42
+ if (ref.startsWith('-')) {
43
+ throw new Error(`Invalid ref: ${ref}. Refs cannot start with - or --. See https://github.com/git-stunts/git-warp#security`);
44
+ }
45
+ const validRefPattern = /^[a-zA-Z0-9._/-]+((~\d*|\^\d*|\.\.[a-zA-Z0-9._/-]+)*)$/;
46
+ if (!validRefPattern.test(ref)) {
47
+ 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`);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Validates that a limit is a safe positive integer (max 10M).
53
+ * @param {number} limit - The limit to validate
54
+ * @throws {Error} If limit is invalid
55
+ */
56
+ export function validateLimit(limit) {
57
+ if (typeof limit !== 'number' || !Number.isFinite(limit)) {
58
+ throw new Error('Limit must be a finite number');
59
+ }
60
+ if (!Number.isInteger(limit)) {
61
+ throw new Error('Limit must be an integer');
62
+ }
63
+ if (limit <= 0) {
64
+ throw new Error('Limit must be a positive integer');
65
+ }
66
+ if (limit > 10_000_000) {
67
+ throw new Error(`Limit too large: ${limit}. Maximum is 10,000,000`);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Validates that a config key is safe and well-formed.
73
+ * @param {string} key - The config key to validate
74
+ * @throws {Error} If key is invalid
75
+ */
76
+ export function validateConfigKey(key) {
77
+ if (!key || typeof key !== 'string') {
78
+ throw new Error('Config key must be a non-empty string');
79
+ }
80
+ if (key.length > 256) {
81
+ throw new Error(`Config key too long: ${key.length} chars. Maximum is 256`);
82
+ }
83
+ if (key.startsWith('-')) {
84
+ throw new Error(`Invalid config key: ${key}. Keys cannot start with -`);
85
+ }
86
+ const validKeyPattern = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
87
+ if (!validKeyPattern.test(key)) {
88
+ throw new Error(`Invalid config key format: ${key}`);
89
+ }
90
+ }