gent-cli 2.1.0 → 5.0.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.
@@ -0,0 +1,337 @@
1
+ /**
2
+ * ============================================================================
3
+ * Hash Engine - Content-Addressable Object Store
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Git-like blob/tree storage using SHA-256 content addressing.
8
+ * Files are stored once (deduplicated) and retrieved by hash.
9
+ *
10
+ * STORAGE LAYOUT:
11
+ * .gent/objects/<2-char-prefix>/<remaining-hash>
12
+ * Example: .gent/objects/ab/cdef1234567890...
13
+ *
14
+ * OBJECT FORMAT (on disk):
15
+ * zlib-compressed( "<type> <size>\0<content>" )
16
+ * Where type = "blob" | "tree"
17
+ *
18
+ * HASHING ALGORITHMS:
19
+ * 1. SHA-256 (crypto.createHash) — for content-addressable storage
20
+ * Same approach as git but uses SHA-256 instead of SHA-1.
21
+ * Input: type header + null byte + raw content
22
+ * Output: 64-char hex string
23
+ *
24
+ * 2. FNV-1a 32-bit — for fast line-level fingerprinting
25
+ * Used by diff engine to quickly compare lines.
26
+ * Non-cryptographic, optimized for speed over collision resistance.
27
+ *
28
+ * DEDUPLICATION:
29
+ * Before writing, check if object file exists → skip if so.
30
+ * Identical content always produces same hash → automatic dedup.
31
+ *
32
+ * COMPRESSION:
33
+ * zlib.deflate before write, zlib.inflate on read.
34
+ * Typically 60-80% size reduction for text files.
35
+ *
36
+ * BACKEND EXPECTATIONS:
37
+ * Backend should implement equivalent object store:
38
+ * - POST /api/repos/:id/push/ receives base64-encoded blobs
39
+ * - Backend computes same SHA-256 hash to verify integrity
40
+ * - Store in DB or filesystem with same addressing scheme
41
+ * - GET /api/repos/:id/pull/ returns base64 blob data
42
+ *
43
+ * ============================================================================
44
+ */
45
+
46
+ const crypto = require('crypto');
47
+ const fs = require('fs').promises;
48
+ const path = require('path');
49
+ const zlib = require('zlib');
50
+ const { promisify } = require('util');
51
+
52
+ const deflate = promisify(zlib.deflate);
53
+ const inflate = promisify(zlib.inflate);
54
+
55
+ // ─── Primitive Hashing ───────────────────────────────────
56
+
57
+ /**
58
+ * SHA-256 hash of raw input.
59
+ * @param {Buffer|String} input
60
+ * @returns {String}
61
+ */
62
+ function sha256(input) {
63
+ return crypto.createHash('sha256').update(input).digest('hex');
64
+ }
65
+
66
+ /**
67
+ * FNV-1a 32-bit fast non-crypto hash for line tracking.
68
+ * @param {String} value
69
+ * @returns {String}
70
+ */
71
+ function fnv1a32(value) {
72
+ let hash = 0x811c9dc5;
73
+ for (let i = 0; i < value.length; i++) {
74
+ hash ^= value.charCodeAt(i);
75
+ hash = (hash >>> 0) * 0x01000193;
76
+ }
77
+ return (hash >>> 0).toString(16).padStart(8, '0');
78
+ }
79
+
80
+ /**
81
+ * Hash each line of text.
82
+ * @param {String} text
83
+ * @returns {Array<{ lineNumber: number, hash: string }>}
84
+ */
85
+ function hashLines(text) {
86
+ const lines = splitLines(text);
87
+ return lines.map((line, index) => ({
88
+ lineNumber: index + 1,
89
+ hash: fnv1a32(line)
90
+ }));
91
+ }
92
+
93
+ /**
94
+ * Split text into lines (normalizes CRLF).
95
+ * @param {String} text
96
+ * @returns {Array<String>}
97
+ */
98
+ function splitLines(text) {
99
+ if (!text) return [];
100
+ return text.replace(/\r\n/g, '\n').split('\n');
101
+ }
102
+
103
+ /**
104
+ * Check if buffer looks binary (contains null bytes).
105
+ * @param {Buffer} buffer
106
+ * @returns {Boolean}
107
+ */
108
+ function isBinaryBuffer(buffer) {
109
+ const probeLength = Math.min(buffer.length, 8000);
110
+ for (let i = 0; i < probeLength; i++) {
111
+ if (buffer[i] === 0) return true;
112
+ }
113
+ return false;
114
+ }
115
+
116
+ // ─── Content-Addressable Object Hashing ──────────────────
117
+
118
+ /**
119
+ * Hash content with type prefix: "<type> <size>\0<content>"
120
+ * @param {String} type - 'blob' | 'tree'
121
+ * @param {Buffer|String} content
122
+ * @returns {String} SHA-256 hex
123
+ */
124
+ function hashObject(type, content) {
125
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8');
126
+ const header = `${type} ${buf.length}\0`;
127
+ const store = Buffer.concat([Buffer.from(header), buf]);
128
+ return crypto.createHash('sha256').update(store).digest('hex');
129
+ }
130
+
131
+ /**
132
+ * Hash content as blob.
133
+ * @param {Buffer|String} content
134
+ * @returns {String}
135
+ */
136
+ function hashBlob(content) {
137
+ return hashObject('blob', content);
138
+ }
139
+
140
+ /**
141
+ * Hash a tree structure.
142
+ * @param {Array<{mode: String, name: String, hash: String, type: String}>} entries
143
+ * @returns {String}
144
+ */
145
+ function hashTree(entries) {
146
+ return hashObject('tree', serializeTree(entries));
147
+ }
148
+
149
+ // ─── Object Store (disk read/write) ─────────────────────
150
+
151
+ /**
152
+ * Filesystem path for object: objects/ab/cdef1234...
153
+ */
154
+ function objectPath(gentPath, hash) {
155
+ return path.join(gentPath, 'objects', hash.substring(0, 2), hash.substring(2));
156
+ }
157
+
158
+ /**
159
+ * Check if object exists in store.
160
+ * @param {String} gentPath
161
+ * @param {String} hash
162
+ * @returns {Promise<Boolean>}
163
+ */
164
+ async function objectExists(gentPath, hash) {
165
+ try {
166
+ await fs.access(objectPath(gentPath, hash));
167
+ return true;
168
+ } catch {
169
+ return false;
170
+ }
171
+ }
172
+
173
+ /**
174
+ * Store blob (file content). Compressed with zlib. Deduplicates.
175
+ * @param {String} gentPath
176
+ * @param {Buffer|String} content
177
+ * @returns {Promise<String>} hash
178
+ */
179
+ async function storeBlob(gentPath, content) {
180
+ const buf = Buffer.isBuffer(content) ? content : Buffer.from(content, 'utf-8');
181
+ const hash = hashBlob(buf);
182
+
183
+ if (await objectExists(gentPath, hash)) return hash;
184
+
185
+ const header = `blob ${buf.length}\0`;
186
+ const store = Buffer.concat([Buffer.from(header), buf]);
187
+ const compressed = await deflate(store);
188
+
189
+ const objPath = objectPath(gentPath, hash);
190
+ await fs.mkdir(path.dirname(objPath), { recursive: true });
191
+ await fs.writeFile(objPath, compressed);
192
+
193
+ return hash;
194
+ }
195
+
196
+ /**
197
+ * Read blob raw content from store.
198
+ * @param {String} gentPath
199
+ * @param {String} hash
200
+ * @returns {Promise<Buffer>}
201
+ */
202
+ async function readBlob(gentPath, hash) {
203
+ const objPath = objectPath(gentPath, hash);
204
+ const compressed = await fs.readFile(objPath);
205
+ const raw = await inflate(compressed);
206
+ const nullIndex = raw.indexOf(0);
207
+ return raw.slice(nullIndex + 1);
208
+ }
209
+
210
+ /**
211
+ * Read blob as UTF-8 string.
212
+ * @param {String} gentPath
213
+ * @param {String} hash
214
+ * @returns {Promise<String>}
215
+ */
216
+ async function readBlobAsString(gentPath, hash) {
217
+ const buf = await readBlob(gentPath, hash);
218
+ return buf.toString('utf-8');
219
+ }
220
+
221
+ // ─── Tree Objects ────────────────────────────────────────
222
+
223
+ /**
224
+ * Serialize tree entries to deterministic JSON (sorted by name).
225
+ */
226
+ function serializeTree(entries) {
227
+ const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
228
+ return JSON.stringify(sorted);
229
+ }
230
+
231
+ /**
232
+ * Deserialize tree JSON.
233
+ */
234
+ function deserializeTree(data) {
235
+ return JSON.parse(data);
236
+ }
237
+
238
+ /**
239
+ * Store tree object. Entry: { mode, name, hash, type }
240
+ * @param {String} gentPath
241
+ * @param {Array} entries
242
+ * @returns {Promise<String>} tree hash
243
+ */
244
+ async function storeTree(gentPath, entries) {
245
+ const serialized = serializeTree(entries);
246
+ const hash = hashObject('tree', serialized);
247
+
248
+ if (await objectExists(gentPath, hash)) return hash;
249
+
250
+ const header = `tree ${Buffer.byteLength(serialized)}\0`;
251
+ const store = Buffer.concat([Buffer.from(header), Buffer.from(serialized)]);
252
+ const compressed = await deflate(store);
253
+
254
+ const objPath = objectPath(gentPath, hash);
255
+ await fs.mkdir(path.dirname(objPath), { recursive: true });
256
+ await fs.writeFile(objPath, compressed);
257
+
258
+ return hash;
259
+ }
260
+
261
+ /**
262
+ * Read tree entries from store.
263
+ * @param {String} gentPath
264
+ * @param {String} hash
265
+ * @returns {Promise<Array>}
266
+ */
267
+ async function readTree(gentPath, hash) {
268
+ const buf = await readBlob(gentPath, hash);
269
+ return deserializeTree(buf.toString('utf-8'));
270
+ }
271
+
272
+ // ─── Snapshot Helpers ────────────────────────────────────
273
+
274
+ /**
275
+ * Snapshot single file → store blob, return tree entry.
276
+ * @param {String} gentPath
277
+ * @param {String} cwd
278
+ * @param {String} relativePath
279
+ * @returns {Promise<{mode, name, hash, type}>}
280
+ */
281
+ async function snapshotFile(gentPath, cwd, relativePath) {
282
+ const fullPath = path.join(cwd, relativePath);
283
+ const content = await fs.readFile(fullPath);
284
+ const hash = await storeBlob(gentPath, content);
285
+ return { mode: '100644', name: relativePath, hash, type: 'blob' };
286
+ }
287
+
288
+ /**
289
+ * Snapshot multiple files → store blobs + tree.
290
+ * @param {String} gentPath
291
+ * @param {String} cwd
292
+ * @param {Array<String>} files
293
+ * @returns {Promise<{treeHash: String, entries: Array}>}
294
+ */
295
+ async function snapshotFiles(gentPath, cwd, files) {
296
+ const entries = [];
297
+ for (const file of files) {
298
+ const entry = await snapshotFile(gentPath, cwd, file);
299
+ entries.push(entry);
300
+ }
301
+ const treeHash = await storeTree(gentPath, entries);
302
+ return { treeHash, entries };
303
+ }
304
+
305
+ /**
306
+ * Build lookup map: filePath → blobHash from tree entries.
307
+ * @param {Array} entries
308
+ * @returns {Map<String, String>}
309
+ */
310
+ function treeToMap(entries) {
311
+ const map = new Map();
312
+ for (const e of entries) map.set(e.name, e.hash);
313
+ return map;
314
+ }
315
+
316
+ module.exports = {
317
+ // Primitives
318
+ sha256,
319
+ fnv1a32,
320
+ hashLines,
321
+ splitLines,
322
+ isBinaryBuffer,
323
+ // Content-addressable
324
+ hashObject,
325
+ hashBlob,
326
+ hashTree,
327
+ objectExists,
328
+ storeBlob,
329
+ readBlob,
330
+ readBlobAsString,
331
+ storeTree,
332
+ readTree,
333
+ // Snapshots
334
+ snapshotFile,
335
+ snapshotFiles,
336
+ treeToMap
337
+ };