gent-cli 15.0.0 → 21.0.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.
@@ -1,54 +1,350 @@
1
1
  /**
2
- * Object Store
3
- * Persist content-addressed blobs inside .gent/objects.
2
+ * ============================================================================
3
+ * Object Store - the single interface to repository objects
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Read and write canonical Git objects. Every object id in Gent comes from
8
+ * here or from git-objects.js; no other module may hash or lay out objects.
9
+ *
10
+ * LAYOUT:
11
+ * <gitdir>/objects/<oid[0:2]>/<oid[2:]> zlib(framing)
12
+ * <gitdir>/objects/pack/*.pack|.idx read through the pack backend
13
+ *
14
+ * DURABILITY:
15
+ * Loose writes go to a temp file in the same directory, are fsync'd, then
16
+ * renamed. An object that already exists is left alone — content addressing
17
+ * makes a rewrite pointless and a partial rewrite dangerous.
18
+ *
19
+ * TRUST:
20
+ * read() always re-validates the framing. Untrusted sources (network,
21
+ * import, migration) additionally recompute the id: a mismatch is a
22
+ * corruption error, never a cache miss.
23
+ *
24
+ * See docs/git-compat/format-contract.md section 2.
25
+ * ============================================================================
4
26
  */
5
27
 
6
28
  const fs = require('fs').promises;
29
+ const fsSync = require('fs');
7
30
  const path = require('path');
8
- const { pathExists } = require('./fileSystem');
31
+ const zlib = require('zlib');
32
+ const crypto = require('crypto');
33
+ const { promisify } = require('util');
9
34
 
10
- /**
11
- * Compute object file path for hash.
12
- * @param {String} gentPath
13
- * @param {String} hash
14
- * @returns {String}
15
- */
16
- function getObjectPath(gentPath, hash) {
17
- return path.join(gentPath, 'objects', `${hash}.blob`);
18
- }
35
+ const deflate = promisify(zlib.deflate);
36
+ const inflate = promisify(zlib.inflate);
19
37
 
20
- /**
21
- * Save object if missing.
22
- * @param {String} gentPath
23
- * @param {String} hash
24
- * @param {Buffer} content
25
- */
26
- async function writeObject(gentPath, hash, content) {
27
- const objectPath = getObjectPath(gentPath, hash);
38
+ const {
39
+ OID_HEX_LENGTH,
40
+ assertObjectId,
41
+ isObjectId,
42
+ frameObject,
43
+ hashObject,
44
+ unframeObject,
45
+ parseTree,
46
+ parseCommit,
47
+ parseTag,
48
+ MalformedObjectError,
49
+ MAX_TAG_PEEL_DEPTH
50
+ } = require('./git-objects');
28
51
 
29
- if (!await pathExists(objectPath)) {
30
- await fs.writeFile(objectPath, content);
52
+ /** Raised when a requested object is genuinely absent (as opposed to broken). */
53
+ class ObjectNotFoundError extends Error {
54
+ constructor(oid) {
55
+ super(`object ${oid} not found`);
56
+ this.name = 'ObjectNotFoundError';
57
+ this.code = 'GENT_OBJECT_NOT_FOUND';
58
+ this.oid = oid;
31
59
  }
32
60
  }
33
61
 
34
- /**
35
- * Read object as buffer.
36
- * @param {String} gentPath
37
- * @param {String} hash
38
- * @returns {Promise<Buffer|null>}
39
- */
40
- async function readObject(gentPath, hash) {
41
- const objectPath = getObjectPath(gentPath, hash);
62
+ class ObjectStore {
63
+ /**
64
+ * @param {String} objectsDir - <gitdir>/objects
65
+ * @param {Object} [options]
66
+ * @param {Object} [options.packBackend] - set by pack-store.js in Phase 4
67
+ */
68
+ constructor(objectsDir, options = {}) {
69
+ this.objectsDir = objectsDir;
70
+ this.packBackend = options.packBackend || null;
71
+ }
72
+
73
+ /**
74
+ * @param {String} oid
75
+ * @returns {String}
76
+ */
77
+ loosePath(oid) {
78
+ assertObjectId(oid);
79
+ return path.join(this.objectsDir, oid.slice(0, 2), oid.slice(2));
80
+ }
81
+
82
+ /**
83
+ * @param {String} oid
84
+ * @returns {Promise<Boolean>}
85
+ */
86
+ async has(oid) {
87
+ if (!isObjectId(oid)) return false;
88
+ try {
89
+ await fs.access(this.loosePath(oid));
90
+ return true;
91
+ } catch {
92
+ /* fall through to packs */
93
+ }
94
+ return this.packBackend ? this.packBackend.has(oid) : false;
95
+ }
96
+
97
+ /**
98
+ * @param {String} oid
99
+ * @param {Object} [options]
100
+ * @param {Boolean} [options.trusted=true] - false re-verifies the id
101
+ * @returns {Promise<{oid, type, size, payload}>}
102
+ */
103
+ async read(oid, options = {}) {
104
+ assertObjectId(oid);
105
+ const trusted = options.trusted !== false;
106
+
107
+ let framed = null;
108
+ try {
109
+ framed = await inflate(await fs.readFile(this.loosePath(oid)));
110
+ } catch (error) {
111
+ if (error.code !== 'ENOENT') {
112
+ throw new MalformedObjectError(`object ${oid} could not be decompressed: ${error.message}`, { oid });
113
+ }
114
+ }
115
+
116
+ if (framed) {
117
+ const { type, size, payload } = unframeObject(framed);
118
+ if (!trusted && hashObject(type, payload) !== oid) {
119
+ throw new MalformedObjectError(`object ${oid} does not hash to its own name`, { oid });
120
+ }
121
+ return { oid, type, size, payload };
122
+ }
123
+
124
+ if (this.packBackend) {
125
+ const packed = await this.packBackend.read(oid);
126
+ if (packed) {
127
+ if (!trusted && hashObject(packed.type, packed.payload) !== oid) {
128
+ throw new MalformedObjectError(`packed object ${oid} does not hash to its own name`, { oid });
129
+ }
130
+ return { oid, ...packed };
131
+ }
132
+ }
133
+
134
+ throw new ObjectNotFoundError(oid);
135
+ }
136
+
137
+ /**
138
+ * @param {String} oid
139
+ * @param {String} expectedType
140
+ * @returns {Promise<{oid, type, size, payload}>}
141
+ */
142
+ async readTyped(oid, expectedType) {
143
+ const object = await this.read(oid);
144
+ if (object.type !== expectedType) {
145
+ throw new MalformedObjectError(`expected ${oid} to be a ${expectedType}, found a ${object.type}`, { oid });
146
+ }
147
+ return object;
148
+ }
149
+
150
+ /**
151
+ * Type of an object without materialising it where the backend allows.
152
+ * @param {String} oid
153
+ * @returns {Promise<String>}
154
+ */
155
+ async typeOf(oid) {
156
+ return (await this.read(oid)).type;
157
+ }
158
+
159
+ /**
160
+ * Store an object, computing its id.
161
+ * @param {String} type
162
+ * @param {Buffer} payload
163
+ * @returns {Promise<String>} oid
164
+ */
165
+ async write(type, payload) {
166
+ const framed = frameObject(type, payload);
167
+ const oid = crypto.createHash('sha256').update(framed).digest('hex');
168
+ await this._writeLoose(oid, framed);
169
+ return oid;
170
+ }
171
+
172
+ /**
173
+ * Store an object whose id was supplied by an untrusted peer.
174
+ * @param {String} oid
175
+ * @param {String} type
176
+ * @param {Buffer} payload
177
+ * @returns {Promise<String>} oid
178
+ */
179
+ async writeVerified(oid, type, payload) {
180
+ const framed = frameObject(type, payload);
181
+ const actual = crypto.createHash('sha256').update(framed).digest('hex');
182
+ if (actual !== assertObjectId(oid)) {
183
+ throw new MalformedObjectError(`refusing to store object under ${oid}: its contents hash to ${actual}`, { oid, actual });
184
+ }
185
+ await this._writeLoose(oid, framed);
186
+ return oid;
187
+ }
188
+
189
+ /**
190
+ * @param {String} oid
191
+ * @param {Buffer} framed
192
+ */
193
+ async _writeLoose(oid, framed) {
194
+ const target = this.loosePath(oid);
195
+ try {
196
+ await fs.access(target);
197
+ return; // already present
198
+ } catch {
199
+ /* not present — write it */
200
+ }
42
201
 
43
- if (!await pathExists(objectPath)) {
44
- return null;
202
+ const dir = path.dirname(target);
203
+ await fs.mkdir(dir, { recursive: true });
204
+
205
+ const compressed = await deflate(framed);
206
+ const tmp = path.join(dir, `tmp_obj_${process.pid}_${crypto.randomBytes(6).toString('hex')}`);
207
+
208
+ let handle;
209
+ try {
210
+ handle = await fs.open(tmp, 'wx', 0o444);
211
+ await handle.writeFile(compressed);
212
+ await handle.sync();
213
+ } finally {
214
+ if (handle) await handle.close();
215
+ }
216
+
217
+ try {
218
+ await fs.rename(tmp, target);
219
+ } catch (error) {
220
+ await fs.rm(tmp, { force: true });
221
+ if (error.code !== 'EEXIST') throw error; // lost a benign race
222
+ }
223
+ }
224
+
225
+ // ─── Typed convenience readers ───────────────────────
226
+
227
+ /**
228
+ * @param {String} oid
229
+ * @returns {Promise<Buffer>}
230
+ */
231
+ async readBlob(oid) {
232
+ return (await this.readTyped(oid, 'blob')).payload;
233
+ }
234
+
235
+ /**
236
+ * @param {String} oid
237
+ * @returns {Promise<Array>}
238
+ */
239
+ async readTree(oid) {
240
+ return parseTree((await this.readTyped(oid, 'tree')).payload);
241
+ }
242
+
243
+ /**
244
+ * @param {String} oid
245
+ * @returns {Promise<Object>}
246
+ */
247
+ async readCommit(oid) {
248
+ const object = await this.readTyped(oid, 'commit');
249
+ return { oid, ...parseCommit(object.payload) };
45
250
  }
46
251
 
47
- return fs.readFile(objectPath);
252
+ /**
253
+ * @param {String} oid
254
+ * @returns {Promise<Object>}
255
+ */
256
+ async readTag(oid) {
257
+ const object = await this.readTyped(oid, 'tag');
258
+ return { oid, ...parseTag(object.payload) };
259
+ }
260
+
261
+ /**
262
+ * Follow tag objects to the first non-tag object.
263
+ * @param {String} oid
264
+ * @returns {Promise<{oid: String, type: String}>}
265
+ */
266
+ async peel(oid) {
267
+ const seen = new Set();
268
+ let current = assertObjectId(oid);
269
+
270
+ for (let depth = 0; depth <= MAX_TAG_PEEL_DEPTH; depth++) {
271
+ if (seen.has(current)) {
272
+ throw new MalformedObjectError(`tag chain from ${oid} is cyclic at ${current}`, { oid });
273
+ }
274
+ seen.add(current);
275
+
276
+ const object = await this.read(current);
277
+ if (object.type !== 'tag') return { oid: current, type: object.type };
278
+ current = parseTag(object.payload).object;
279
+ }
280
+
281
+ throw new MalformedObjectError(`tag chain from ${oid} exceeds ${MAX_TAG_PEEL_DEPTH} levels`, { oid });
282
+ }
283
+
284
+ /**
285
+ * Every loose object id in the store.
286
+ * @returns {AsyncGenerator<String>}
287
+ */
288
+ async *listLoose() {
289
+ let prefixes;
290
+ try {
291
+ prefixes = await fs.readdir(this.objectsDir, { withFileTypes: true });
292
+ } catch (error) {
293
+ if (error.code === 'ENOENT') return;
294
+ throw error;
295
+ }
296
+
297
+ for (const prefix of prefixes) {
298
+ if (!prefix.isDirectory() || !/^[0-9a-f]{2}$/.test(prefix.name)) continue;
299
+ const names = await fs.readdir(path.join(this.objectsDir, prefix.name));
300
+ for (const name of names) {
301
+ const oid = prefix.name + name;
302
+ if (oid.length === OID_HEX_LENGTH && isObjectId(oid)) yield oid;
303
+ }
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Loose oids plus every oid the pack backend knows about.
309
+ * @returns {Promise<Set<String>>}
310
+ */
311
+ async listAll() {
312
+ const all = new Set();
313
+ for await (const oid of this.listLoose()) all.add(oid);
314
+ if (this.packBackend) {
315
+ for (const oid of await this.packBackend.listAll()) all.add(oid);
316
+ }
317
+ return all;
318
+ }
319
+
320
+ /**
321
+ * Byte size of the loose object files, for `gent summary`.
322
+ * @returns {Promise<Number>}
323
+ */
324
+ async looseByteSize() {
325
+ let total = 0;
326
+ for await (const oid of this.listLoose()) {
327
+ try {
328
+ total += (await fs.stat(this.loosePath(oid))).size;
329
+ } catch { /* raced with maintenance */ }
330
+ }
331
+ return total;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Synchronous existence probe used by preflight paths that cannot await.
337
+ * @param {String} objectsDir
338
+ * @param {String} oid
339
+ * @returns {Boolean}
340
+ */
341
+ function looseObjectExistsSync(objectsDir, oid) {
342
+ if (!isObjectId(oid)) return false;
343
+ return fsSync.existsSync(path.join(objectsDir, oid.slice(0, 2), oid.slice(2)));
48
344
  }
49
345
 
50
346
  module.exports = {
51
- getObjectPath,
52
- writeObject,
53
- readObject
347
+ ObjectStore,
348
+ ObjectNotFoundError,
349
+ looseObjectExistsSync
54
350
  };