@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.
- package/SECURITY.md +89 -1
- package/bin/warp-graph.js +205 -69
- package/index.d.ts +24 -0
- package/package.json +1 -2
- package/src/domain/WarpGraph.js +72 -15
- package/src/domain/services/HttpSyncServer.js +74 -6
- package/src/domain/services/SyncAuthService.js +396 -0
- package/src/infrastructure/adapters/GitGraphAdapter.js +9 -56
- package/src/infrastructure/adapters/InMemoryGraphAdapter.js +488 -0
- package/src/infrastructure/adapters/adapterValidation.js +90 -0
- package/src/visualization/renderers/ascii/seek.js +172 -22
|
@@ -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
|
+
}
|