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.
- package/README.md +1 -1
- package/package.json +7 -4
- package/src/commands/canonical.js +246 -0
- package/src/commands/clone.js +2 -13
- package/src/commands/pet.js +23 -45
- package/src/commands/push.js +1 -1
- package/src/commands/share.js +40 -15
- package/src/commands/web.js +30 -15
- package/src/index.js +70 -40
- package/src/utils/api-client.js +6 -9
- package/src/utils/attributes.js +280 -0
- package/src/utils/canonical-journal.js +122 -0
- package/src/utils/feature-support.js +92 -0
- package/src/utils/feature-support.json +210 -0
- package/src/utils/gent-ops.js +836 -0
- package/src/utils/git-config.js +606 -0
- package/src/utils/git-index.js +581 -0
- package/src/utils/git-objects.js +537 -0
- package/src/utils/ignore.js +365 -0
- package/src/utils/lockfile.js +219 -0
- package/src/utils/merge-engine.js +10 -7
- package/src/utils/merge-ops.js +283 -0
- package/src/utils/migrate.js +257 -0
- package/src/utils/object-store.js +332 -36
- package/src/utils/packfile.js +812 -0
- package/src/utils/refs.js +595 -0
- package/src/utils/repository.js +533 -0
- package/src/utils/smart-http.js +195 -0
- package/src/utils/stash-ops.js +355 -0
- package/src/utils/user-config.js +10 -0
- package/src/utils/web-urls.js +125 -0
- package/src/utils/worktree.js +676 -0
|
@@ -0,0 +1,537 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Git Objects - canonical serialization and parsing
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Byte-exact encoding and decoding of the four Git object types under
|
|
8
|
+
* SHA-256. This module owns object identity: nothing else in Gent may
|
|
9
|
+
* compute an object id.
|
|
10
|
+
*
|
|
11
|
+
* FRAMING:
|
|
12
|
+
* "<type> <payload byte length>\0" + payload, hashed with SHA-256.
|
|
13
|
+
*
|
|
14
|
+
* BYTE PRESERVATION:
|
|
15
|
+
* Commits and tags carry unknown headers, multi-line signatures and exact
|
|
16
|
+
* message bytes. Parsing is for display only — serialize(parse(x)) === x for
|
|
17
|
+
* every object this module accepts. Callers re-emitting a stored object must
|
|
18
|
+
* still prefer its raw payload; the round trip is a safety net, not a
|
|
19
|
+
* licence to rewrite history.
|
|
20
|
+
*
|
|
21
|
+
* See docs/git-compat/format-contract.md sections 1, 3, 4, 5.
|
|
22
|
+
* ============================================================================
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const crypto = require('crypto');
|
|
26
|
+
|
|
27
|
+
const OID_RAW_LENGTH = 32;
|
|
28
|
+
const OID_HEX_LENGTH = 64;
|
|
29
|
+
const OBJECT_TYPES = Object.freeze(['blob', 'tree', 'commit', 'tag']);
|
|
30
|
+
const NULL_OID = '0'.repeat(OID_HEX_LENGTH);
|
|
31
|
+
|
|
32
|
+
/** Modes Gent is allowed to write. Others are read-only pass-through. */
|
|
33
|
+
const MODE = Object.freeze({
|
|
34
|
+
TREE: 0o40000,
|
|
35
|
+
REGULAR: 0o100644,
|
|
36
|
+
EXECUTABLE: 0o100755,
|
|
37
|
+
SYMLINK: 0o120000,
|
|
38
|
+
GITLINK: 0o160000
|
|
39
|
+
});
|
|
40
|
+
const WRITABLE_MODES = new Set(Object.values(MODE));
|
|
41
|
+
|
|
42
|
+
const MAX_TAG_PEEL_DEPTH = 32;
|
|
43
|
+
|
|
44
|
+
/** Raised for any object that does not satisfy the format contract. */
|
|
45
|
+
class MalformedObjectError extends Error {
|
|
46
|
+
constructor(message, details) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'MalformedObjectError';
|
|
49
|
+
this.code = 'GENT_MALFORMED_OBJECT';
|
|
50
|
+
Object.assign(this, details || {});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ─── Object ids ──────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {String} value
|
|
58
|
+
* @returns {Boolean}
|
|
59
|
+
*/
|
|
60
|
+
function isObjectId(value) {
|
|
61
|
+
return typeof value === 'string' && /^[0-9a-f]{64}$/.test(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {String} value
|
|
66
|
+
* @param {String} [what]
|
|
67
|
+
* @returns {String} the same value
|
|
68
|
+
*/
|
|
69
|
+
function assertObjectId(value, what) {
|
|
70
|
+
if (!isObjectId(value)) {
|
|
71
|
+
throw new MalformedObjectError(`${what || 'object id'} is not a 64-character lowercase hex SHA-256: ${JSON.stringify(value)}`);
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {String} hex
|
|
78
|
+
* @returns {Buffer} 32 raw bytes
|
|
79
|
+
*/
|
|
80
|
+
function oidToRaw(hex) {
|
|
81
|
+
return Buffer.from(assertObjectId(hex), 'hex');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* @param {Buffer} buf
|
|
86
|
+
* @param {Number} [offset]
|
|
87
|
+
* @returns {String} 64 hex characters
|
|
88
|
+
*/
|
|
89
|
+
function rawToOid(buf, offset = 0) {
|
|
90
|
+
if (buf.length < offset + OID_RAW_LENGTH) {
|
|
91
|
+
throw new MalformedObjectError('truncated object id');
|
|
92
|
+
}
|
|
93
|
+
return buf.toString('hex', offset, offset + OID_RAW_LENGTH);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Framing ─────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the exact bytes that are hashed and stored.
|
|
100
|
+
* @param {String} type
|
|
101
|
+
* @param {Buffer} payload
|
|
102
|
+
* @returns {Buffer}
|
|
103
|
+
*/
|
|
104
|
+
function frameObject(type, payload) {
|
|
105
|
+
if (!OBJECT_TYPES.includes(type)) {
|
|
106
|
+
throw new MalformedObjectError(`unknown object type '${type}'`);
|
|
107
|
+
}
|
|
108
|
+
if (!Buffer.isBuffer(payload)) {
|
|
109
|
+
throw new TypeError('object payload must be a Buffer — no implicit text encoding on the identity path');
|
|
110
|
+
}
|
|
111
|
+
return Buffer.concat([Buffer.from(`${type} ${payload.length}\0`, 'latin1'), payload]);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {String} type
|
|
116
|
+
* @param {Buffer} payload
|
|
117
|
+
* @returns {String} object id
|
|
118
|
+
*/
|
|
119
|
+
function hashObject(type, payload) {
|
|
120
|
+
return crypto.createHash('sha256').update(frameObject(type, payload)).digest('hex');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Split stored bytes back into type and payload, validating the declared size.
|
|
125
|
+
* @param {Buffer} framed
|
|
126
|
+
* @returns {{type: String, size: Number, payload: Buffer}}
|
|
127
|
+
*/
|
|
128
|
+
function unframeObject(framed) {
|
|
129
|
+
const nul = framed.indexOf(0);
|
|
130
|
+
if (nul < 0) throw new MalformedObjectError('object header has no NUL terminator');
|
|
131
|
+
|
|
132
|
+
const header = framed.toString('latin1', 0, nul);
|
|
133
|
+
const space = header.indexOf(' ');
|
|
134
|
+
if (space < 0) throw new MalformedObjectError(`object header has no size: ${JSON.stringify(header)}`);
|
|
135
|
+
|
|
136
|
+
const type = header.slice(0, space);
|
|
137
|
+
const sizeText = header.slice(space + 1);
|
|
138
|
+
if (!OBJECT_TYPES.includes(type)) {
|
|
139
|
+
throw new MalformedObjectError(`unknown object type '${type}'`);
|
|
140
|
+
}
|
|
141
|
+
if (!/^(0|[1-9][0-9]*)$/.test(sizeText)) {
|
|
142
|
+
throw new MalformedObjectError(`object size is not a canonical decimal: ${JSON.stringify(sizeText)}`);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const size = Number(sizeText);
|
|
146
|
+
const payload = framed.subarray(nul + 1);
|
|
147
|
+
if (payload.length !== size) {
|
|
148
|
+
throw new MalformedObjectError(`object size mismatch: header says ${size}, payload is ${payload.length}`);
|
|
149
|
+
}
|
|
150
|
+
return { type, size, payload };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Trees ───────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Git's base_name_compare: bytewise, with a directory's virtual next byte '/'.
|
|
157
|
+
* @param {{name: String, mode: Number}} a
|
|
158
|
+
* @param {{name: String, mode: Number}} b
|
|
159
|
+
* @returns {Number}
|
|
160
|
+
*/
|
|
161
|
+
function compareTreeEntries(a, b) {
|
|
162
|
+
const an = Buffer.from(a.name, 'utf8');
|
|
163
|
+
const bn = Buffer.from(b.name, 'utf8');
|
|
164
|
+
const common = Math.min(an.length, bn.length);
|
|
165
|
+
const cmp = an.compare(bn, 0, common, 0, common);
|
|
166
|
+
if (cmp !== 0) return cmp;
|
|
167
|
+
|
|
168
|
+
let ac = an.length > common ? an[common] : (a.mode === MODE.TREE ? 0x2f : 0);
|
|
169
|
+
let bc = bn.length > common ? bn[common] : (b.mode === MODE.TREE ? 0x2f : 0);
|
|
170
|
+
return ac < bc ? -1 : ac > bc ? 1 : 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Reject names that cannot appear inside a tree object.
|
|
175
|
+
* @param {String} name
|
|
176
|
+
*/
|
|
177
|
+
function assertTreeEntryName(name) {
|
|
178
|
+
if (typeof name !== 'string' || name.length === 0) {
|
|
179
|
+
throw new MalformedObjectError('tree entry name is empty');
|
|
180
|
+
}
|
|
181
|
+
if (name.includes('/')) {
|
|
182
|
+
throw new MalformedObjectError(`tree entry name must be a basename, got '${name}'`);
|
|
183
|
+
}
|
|
184
|
+
if (name.includes('\0')) {
|
|
185
|
+
throw new MalformedObjectError('tree entry name contains NUL');
|
|
186
|
+
}
|
|
187
|
+
if (name === '.' || name === '..') {
|
|
188
|
+
throw new MalformedObjectError(`tree entry name '${name}' is reserved`);
|
|
189
|
+
}
|
|
190
|
+
if (name.toLowerCase() === '.git') {
|
|
191
|
+
throw new MalformedObjectError("tree entry name '.git' is reserved");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* @param {Array<{mode: Number, name: String, oid: String}>} entries
|
|
197
|
+
* @returns {Buffer}
|
|
198
|
+
*/
|
|
199
|
+
function serializeTree(entries) {
|
|
200
|
+
const sorted = [...entries].sort(compareTreeEntries);
|
|
201
|
+
|
|
202
|
+
const parts = [];
|
|
203
|
+
let previous = null;
|
|
204
|
+
for (const entry of sorted) {
|
|
205
|
+
assertTreeEntryName(entry.name);
|
|
206
|
+
if (!WRITABLE_MODES.has(entry.mode)) {
|
|
207
|
+
throw new MalformedObjectError(`tree entry '${entry.name}' has mode ${entry.mode.toString(8)}, which Gent does not write`);
|
|
208
|
+
}
|
|
209
|
+
if (previous && previous.name === entry.name) {
|
|
210
|
+
throw new MalformedObjectError(`duplicate tree entry '${entry.name}'`);
|
|
211
|
+
}
|
|
212
|
+
previous = entry;
|
|
213
|
+
parts.push(Buffer.from(`${entry.mode.toString(8)} ${entry.name}\0`, 'utf8'));
|
|
214
|
+
parts.push(oidToRaw(entry.oid));
|
|
215
|
+
}
|
|
216
|
+
return Buffer.concat(parts);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* @param {Buffer} payload
|
|
221
|
+
* @returns {Array<{mode: Number, name: String, oid: String, type: String}>}
|
|
222
|
+
*/
|
|
223
|
+
function parseTree(payload) {
|
|
224
|
+
const entries = [];
|
|
225
|
+
let offset = 0;
|
|
226
|
+
|
|
227
|
+
while (offset < payload.length) {
|
|
228
|
+
const space = payload.indexOf(0x20, offset);
|
|
229
|
+
if (space < 0) throw new MalformedObjectError('tree entry has no mode separator');
|
|
230
|
+
|
|
231
|
+
const modeText = payload.toString('latin1', offset, space);
|
|
232
|
+
if (!/^[0-7]{5,6}$/.test(modeText)) {
|
|
233
|
+
throw new MalformedObjectError(`tree entry mode is not octal: ${JSON.stringify(modeText)}`);
|
|
234
|
+
}
|
|
235
|
+
if (modeText[0] === '0') {
|
|
236
|
+
throw new MalformedObjectError(`tree entry mode has a leading zero: ${modeText}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const nul = payload.indexOf(0, space + 1);
|
|
240
|
+
if (nul < 0) throw new MalformedObjectError('tree entry name has no NUL terminator');
|
|
241
|
+
|
|
242
|
+
const name = payload.toString('utf8', space + 1, nul);
|
|
243
|
+
const oid = rawToOid(payload, nul + 1);
|
|
244
|
+
const mode = parseInt(modeText, 8);
|
|
245
|
+
|
|
246
|
+
entries.push({ mode, name, oid, type: modeToType(mode) });
|
|
247
|
+
offset = nul + 1 + OID_RAW_LENGTH;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (offset !== payload.length) throw new MalformedObjectError('trailing bytes in tree object');
|
|
251
|
+
return entries;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @param {Number} mode
|
|
256
|
+
* @returns {String} 'tree' | 'blob' | 'commit' (gitlink)
|
|
257
|
+
*/
|
|
258
|
+
function modeToType(mode) {
|
|
259
|
+
if (mode === MODE.TREE) return 'tree';
|
|
260
|
+
if (mode === MODE.GITLINK) return 'commit';
|
|
261
|
+
return 'blob';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ─── Identities ──────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* @param {{name: String, email: String, timestamp: Number, timezone: String}} identity
|
|
268
|
+
* @returns {String}
|
|
269
|
+
*/
|
|
270
|
+
function formatIdentity(identity) {
|
|
271
|
+
const { name = '', email = '', timestamp, timezone } = identity;
|
|
272
|
+
if (!Number.isInteger(timestamp)) {
|
|
273
|
+
throw new MalformedObjectError(`identity timestamp must be integer epoch seconds, got ${timestamp}`);
|
|
274
|
+
}
|
|
275
|
+
if (!/^[+-][0-9]{4}$/.test(timezone)) {
|
|
276
|
+
throw new MalformedObjectError(`identity timezone must be ±HHMM, got ${JSON.stringify(timezone)}`);
|
|
277
|
+
}
|
|
278
|
+
if (name.includes('<') || name.includes('>') || name.includes('\n')) {
|
|
279
|
+
throw new MalformedObjectError(`identity name may not contain '<', '>' or a newline: ${JSON.stringify(name)}`);
|
|
280
|
+
}
|
|
281
|
+
if (email.includes('<') || email.includes('>') || email.includes('\n')) {
|
|
282
|
+
throw new MalformedObjectError(`identity email may not contain '<', '>' or a newline: ${JSON.stringify(email)}`);
|
|
283
|
+
}
|
|
284
|
+
return `${name} <${email}> ${timestamp} ${timezone}`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Tolerant parse — display only. Never used to re-derive an object id.
|
|
289
|
+
* @param {String} value
|
|
290
|
+
* @returns {{name, email, timestamp, timezone, raw}}
|
|
291
|
+
*/
|
|
292
|
+
function parseIdentity(value) {
|
|
293
|
+
const open = value.indexOf('<');
|
|
294
|
+
const close = value.indexOf('>', open + 1);
|
|
295
|
+
if (open < 0 || close < 0) {
|
|
296
|
+
return { name: value.trim(), email: '', timestamp: 0, timezone: '+0000', raw: value };
|
|
297
|
+
}
|
|
298
|
+
const name = value.slice(0, open).trimEnd();
|
|
299
|
+
const email = value.slice(open + 1, close);
|
|
300
|
+
const rest = value.slice(close + 1).trim().split(/\s+/);
|
|
301
|
+
const timestamp = Number.parseInt(rest[0], 10);
|
|
302
|
+
const timezone = /^[+-][0-9]{4}$/.test(rest[1] || '') ? rest[1] : '+0000';
|
|
303
|
+
return {
|
|
304
|
+
name,
|
|
305
|
+
email,
|
|
306
|
+
timestamp: Number.isFinite(timestamp) ? timestamp : 0,
|
|
307
|
+
timezone,
|
|
308
|
+
raw: value
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Local timezone offset in ±HHMM form for a Date.
|
|
314
|
+
* @param {Date} date
|
|
315
|
+
* @returns {String}
|
|
316
|
+
*/
|
|
317
|
+
function timezoneOffset(date) {
|
|
318
|
+
const minutes = -date.getTimezoneOffset();
|
|
319
|
+
const sign = minutes < 0 ? '-' : '+';
|
|
320
|
+
const abs = Math.abs(minutes);
|
|
321
|
+
return `${sign}${String(Math.floor(abs / 60)).padStart(2, '0')}${String(abs % 60).padStart(2, '0')}`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ─── Header block (shared by commit and tag) ─────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Split "header\n...\n\nmessage" preserving order, unknown headers and the
|
|
328
|
+
* one-leading-space continuation encoding used by multi-line signatures.
|
|
329
|
+
* @param {Buffer} payload
|
|
330
|
+
* @returns {{headers: Array<[String, String]>, message: Buffer}}
|
|
331
|
+
*/
|
|
332
|
+
function parseHeaderBlock(payload) {
|
|
333
|
+
const headers = [];
|
|
334
|
+
let offset = 0;
|
|
335
|
+
|
|
336
|
+
while (offset < payload.length) {
|
|
337
|
+
if (payload[offset] === 0x0a) { // blank line ends headers
|
|
338
|
+
offset += 1;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
let end = payload.indexOf(0x0a, offset);
|
|
342
|
+
if (end < 0) end = payload.length;
|
|
343
|
+
|
|
344
|
+
const line = payload.toString('utf8', offset, end);
|
|
345
|
+
const space = line.indexOf(' ');
|
|
346
|
+
const key = space < 0 ? line : line.slice(0, space);
|
|
347
|
+
let value = space < 0 ? '' : line.slice(space + 1);
|
|
348
|
+
offset = end + 1;
|
|
349
|
+
|
|
350
|
+
// Continuation lines begin with a single space.
|
|
351
|
+
while (offset < payload.length && payload[offset] === 0x20) {
|
|
352
|
+
let contEnd = payload.indexOf(0x0a, offset);
|
|
353
|
+
if (contEnd < 0) contEnd = payload.length;
|
|
354
|
+
value += '\n' + payload.toString('utf8', offset + 1, contEnd);
|
|
355
|
+
offset = contEnd + 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
headers.push([key, value]);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return { headers, message: payload.subarray(offset) };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* @param {Array<[String, String]>} headers
|
|
366
|
+
* @param {Buffer} message
|
|
367
|
+
* @returns {Buffer}
|
|
368
|
+
*/
|
|
369
|
+
function serializeHeaderBlock(headers, message) {
|
|
370
|
+
const parts = [];
|
|
371
|
+
for (const [key, value] of headers) {
|
|
372
|
+
if (key.includes(' ') || key.includes('\n')) {
|
|
373
|
+
throw new MalformedObjectError(`invalid object header key ${JSON.stringify(key)}`);
|
|
374
|
+
}
|
|
375
|
+
const encoded = String(value).split('\n').join('\n ');
|
|
376
|
+
parts.push(Buffer.from(`${key} ${encoded}\n`, 'utf8'));
|
|
377
|
+
}
|
|
378
|
+
parts.push(Buffer.from('\n', 'utf8'));
|
|
379
|
+
parts.push(Buffer.isBuffer(message) ? message : Buffer.from(String(message), 'utf8'));
|
|
380
|
+
return Buffer.concat(parts);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* @param {Array<[String, String]>} headers
|
|
385
|
+
* @param {String} key
|
|
386
|
+
* @returns {String|null}
|
|
387
|
+
*/
|
|
388
|
+
function headerValue(headers, key) {
|
|
389
|
+
const found = headers.find(([k]) => k === key);
|
|
390
|
+
return found ? found[1] : null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// ─── Commits ─────────────────────────────────────────────
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* @param {Object} commit
|
|
397
|
+
* @param {String} commit.tree
|
|
398
|
+
* @param {Array<String>} [commit.parents]
|
|
399
|
+
* @param {Object} commit.author
|
|
400
|
+
* @param {Object} commit.committer
|
|
401
|
+
* @param {Buffer|String} commit.message
|
|
402
|
+
* @param {Array<[String, String]>} [commit.extraHeaders] - emitted after committer
|
|
403
|
+
* @returns {Buffer}
|
|
404
|
+
*/
|
|
405
|
+
function serializeCommit(commit) {
|
|
406
|
+
const headers = [['tree', assertObjectId(commit.tree, 'commit tree')]];
|
|
407
|
+
for (const parent of commit.parents || []) {
|
|
408
|
+
headers.push(['parent', assertObjectId(parent, 'commit parent')]);
|
|
409
|
+
}
|
|
410
|
+
headers.push(['author', formatIdentity(commit.author)]);
|
|
411
|
+
headers.push(['committer', formatIdentity(commit.committer)]);
|
|
412
|
+
for (const [key, value] of commit.extraHeaders || []) {
|
|
413
|
+
headers.push([key, value]);
|
|
414
|
+
}
|
|
415
|
+
const message = Buffer.isBuffer(commit.message) ? commit.message : Buffer.from(commit.message || '', 'utf8');
|
|
416
|
+
return serializeHeaderBlock(headers, message);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* @param {Buffer} payload
|
|
421
|
+
* @returns {Object} parsed commit, carrying `raw` and `headers` for fidelity
|
|
422
|
+
*/
|
|
423
|
+
function parseCommit(payload) {
|
|
424
|
+
const { headers, message } = parseHeaderBlock(payload);
|
|
425
|
+
|
|
426
|
+
const tree = headerValue(headers, 'tree');
|
|
427
|
+
if (!tree) throw new MalformedObjectError('commit has no tree header');
|
|
428
|
+
assertObjectId(tree, 'commit tree');
|
|
429
|
+
|
|
430
|
+
const parents = headers.filter(([k]) => k === 'parent').map(([, v]) => assertObjectId(v, 'commit parent'));
|
|
431
|
+
const authorRaw = headerValue(headers, 'author');
|
|
432
|
+
const committerRaw = headerValue(headers, 'committer');
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
type: 'commit',
|
|
436
|
+
tree,
|
|
437
|
+
parents,
|
|
438
|
+
author: authorRaw ? parseIdentity(authorRaw) : null,
|
|
439
|
+
committer: committerRaw ? parseIdentity(committerRaw) : null,
|
|
440
|
+
encoding: headerValue(headers, 'encoding'),
|
|
441
|
+
extraHeaders: headers.filter(([k]) => !['tree', 'parent', 'author', 'committer'].includes(k)),
|
|
442
|
+
headers,
|
|
443
|
+
message,
|
|
444
|
+
raw: payload
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ─── Tags ────────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* @param {Object} tag
|
|
452
|
+
* @returns {Buffer}
|
|
453
|
+
*/
|
|
454
|
+
function serializeTag(tag) {
|
|
455
|
+
if (!OBJECT_TYPES.includes(tag.targetType)) {
|
|
456
|
+
throw new MalformedObjectError(`tag target type '${tag.targetType}' is not an object type`);
|
|
457
|
+
}
|
|
458
|
+
const headers = [
|
|
459
|
+
['object', assertObjectId(tag.object, 'tag object')],
|
|
460
|
+
['type', tag.targetType],
|
|
461
|
+
['tag', tag.tag]
|
|
462
|
+
];
|
|
463
|
+
if (tag.tagger) headers.push(['tagger', formatIdentity(tag.tagger)]);
|
|
464
|
+
for (const [key, value] of tag.extraHeaders || []) headers.push([key, value]);
|
|
465
|
+
|
|
466
|
+
const message = Buffer.isBuffer(tag.message) ? tag.message : Buffer.from(tag.message || '', 'utf8');
|
|
467
|
+
return serializeHeaderBlock(headers, message);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* @param {Buffer} payload
|
|
472
|
+
* @returns {Object}
|
|
473
|
+
*/
|
|
474
|
+
function parseTag(payload) {
|
|
475
|
+
const { headers, message } = parseHeaderBlock(payload);
|
|
476
|
+
|
|
477
|
+
const object = headerValue(headers, 'object');
|
|
478
|
+
const targetType = headerValue(headers, 'type');
|
|
479
|
+
const name = headerValue(headers, 'tag');
|
|
480
|
+
if (!object) throw new MalformedObjectError('tag has no object header');
|
|
481
|
+
assertObjectId(object, 'tag object');
|
|
482
|
+
if (!OBJECT_TYPES.includes(targetType)) {
|
|
483
|
+
throw new MalformedObjectError(`tag type header '${targetType}' is not an object type`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const taggerRaw = headerValue(headers, 'tagger');
|
|
487
|
+
return {
|
|
488
|
+
type: 'tag',
|
|
489
|
+
object,
|
|
490
|
+
targetType,
|
|
491
|
+
tag: name,
|
|
492
|
+
tagger: taggerRaw ? parseIdentity(taggerRaw) : null,
|
|
493
|
+
extraHeaders: headers.filter(([k]) => !['object', 'type', 'tag', 'tagger'].includes(k)),
|
|
494
|
+
headers,
|
|
495
|
+
message,
|
|
496
|
+
raw: payload
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
module.exports = {
|
|
501
|
+
OID_RAW_LENGTH,
|
|
502
|
+
OID_HEX_LENGTH,
|
|
503
|
+
OBJECT_TYPES,
|
|
504
|
+
NULL_OID,
|
|
505
|
+
MODE,
|
|
506
|
+
WRITABLE_MODES,
|
|
507
|
+
MAX_TAG_PEEL_DEPTH,
|
|
508
|
+
MalformedObjectError,
|
|
509
|
+
|
|
510
|
+
isObjectId,
|
|
511
|
+
assertObjectId,
|
|
512
|
+
oidToRaw,
|
|
513
|
+
rawToOid,
|
|
514
|
+
|
|
515
|
+
frameObject,
|
|
516
|
+
hashObject,
|
|
517
|
+
unframeObject,
|
|
518
|
+
|
|
519
|
+
compareTreeEntries,
|
|
520
|
+
assertTreeEntryName,
|
|
521
|
+
serializeTree,
|
|
522
|
+
parseTree,
|
|
523
|
+
modeToType,
|
|
524
|
+
|
|
525
|
+
formatIdentity,
|
|
526
|
+
parseIdentity,
|
|
527
|
+
timezoneOffset,
|
|
528
|
+
|
|
529
|
+
parseHeaderBlock,
|
|
530
|
+
serializeHeaderBlock,
|
|
531
|
+
headerValue,
|
|
532
|
+
|
|
533
|
+
serializeCommit,
|
|
534
|
+
parseCommit,
|
|
535
|
+
serializeTag,
|
|
536
|
+
parseTag
|
|
537
|
+
};
|