gent-cli 15.0.0 → 20.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 +242 -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-ops.js +282 -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,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* Git Index - binary staging area (DIRC)
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* PURPOSE:
|
|
7
|
+
* Read and write the index Git reads and writes, so `gent add` and
|
|
8
|
+
* `git status` agree without either re-scanning the other's state.
|
|
9
|
+
*
|
|
10
|
+
* VERSIONS:
|
|
11
|
+
* Read 2, 3 and 4. Write 2 whenever every entry is representable in it; an
|
|
12
|
+
* entry needing a v3-only flag (skip-worktree, intent-to-add) refuses with
|
|
13
|
+
* an actionable error rather than silently dropping the flag.
|
|
14
|
+
*
|
|
15
|
+
* ENTRY LAYOUT (SHA-256):
|
|
16
|
+
* ctime(8) mtime(8) dev(4) ino(4) mode(4) uid(4) gid(4) size(4)
|
|
17
|
+
* oid(32) flags(2) [extended flags(2)] path NUL-padded to a multiple of 8.
|
|
18
|
+
* v4 drops the padding and prefix-compresses the path against its
|
|
19
|
+
* predecessor.
|
|
20
|
+
*
|
|
21
|
+
* EXTENSIONS:
|
|
22
|
+
* An uppercase first signature byte means optional (TREE, REUC, UNTR, ...);
|
|
23
|
+
* lowercase means required (link, sdir). An unknown *required* extension
|
|
24
|
+
* refuses the operation before anything is written.
|
|
25
|
+
*
|
|
26
|
+
* RACY TIMESTAMPS:
|
|
27
|
+
* An entry whose mtime is not strictly older than the index file's own
|
|
28
|
+
* mtime cannot be trusted as clean on stat alone; isRacy() marks those so
|
|
29
|
+
* status falls back to comparing content.
|
|
30
|
+
*
|
|
31
|
+
* See docs/git-compat/format-contract.md section 8.
|
|
32
|
+
* ============================================================================
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require('fs').promises;
|
|
36
|
+
const crypto = require('crypto');
|
|
37
|
+
const path = require('path');
|
|
38
|
+
|
|
39
|
+
const { OID_RAW_LENGTH, assertObjectId, MODE } = require('./git-objects');
|
|
40
|
+
const { withLock, readFileOrNull, writeAtomic } = require('./lockfile');
|
|
41
|
+
const { UnsupportedFeatureError, feature } = require('./feature-support');
|
|
42
|
+
|
|
43
|
+
const SIGNATURE = 0x44495243; // 'DIRC'
|
|
44
|
+
const SUPPORTED_READ_VERSIONS = new Set([2, 3, 4]);
|
|
45
|
+
const WRITE_VERSION = 2;
|
|
46
|
+
/** stat block (40) + oid (32) + flags (2) */
|
|
47
|
+
const FIXED_ENTRY_PREFIX = 40 + OID_RAW_LENGTH + 2;
|
|
48
|
+
|
|
49
|
+
const FLAG_ASSUME_VALID = 0x8000;
|
|
50
|
+
const FLAG_EXTENDED = 0x4000;
|
|
51
|
+
const FLAG_STAGE_MASK = 0x3000;
|
|
52
|
+
const FLAG_NAME_MASK = 0x0fff;
|
|
53
|
+
const EXT_FLAG_SKIP_WORKTREE = 0x4000;
|
|
54
|
+
const EXT_FLAG_INTENT_TO_ADD = 0x2000;
|
|
55
|
+
|
|
56
|
+
/** Extensions Gent understands well enough to keep meaningful. */
|
|
57
|
+
const UNDERSTOOD_EXTENSIONS = new Set(['TREE', 'REUC']);
|
|
58
|
+
/** Lowercase-signature extensions with a known meaning, for better errors. */
|
|
59
|
+
const KNOWN_REQUIRED_EXTENSIONS = {
|
|
60
|
+
link: 'index.split',
|
|
61
|
+
sdir: 'index.sparse'
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
class IndexError extends Error {
|
|
65
|
+
constructor(message) {
|
|
66
|
+
super(message);
|
|
67
|
+
this.name = 'IndexError';
|
|
68
|
+
this.code = 'GENT_BAD_INDEX';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* One index entry. `stage` 0 is the normal staged state; 1/2/3 are the base,
|
|
74
|
+
* ours and theirs sides of an unresolved conflict.
|
|
75
|
+
*/
|
|
76
|
+
class IndexEntry {
|
|
77
|
+
constructor(fields) {
|
|
78
|
+
this.ctimeSeconds = fields.ctimeSeconds || 0;
|
|
79
|
+
this.ctimeNanoseconds = fields.ctimeNanoseconds || 0;
|
|
80
|
+
this.mtimeSeconds = fields.mtimeSeconds || 0;
|
|
81
|
+
this.mtimeNanoseconds = fields.mtimeNanoseconds || 0;
|
|
82
|
+
this.dev = fields.dev || 0;
|
|
83
|
+
this.ino = fields.ino || 0;
|
|
84
|
+
this.mode = fields.mode;
|
|
85
|
+
this.uid = fields.uid || 0;
|
|
86
|
+
this.gid = fields.gid || 0;
|
|
87
|
+
this.size = fields.size || 0;
|
|
88
|
+
this.oid = assertObjectId(fields.oid, `index entry ${fields.path}`);
|
|
89
|
+
this.path = fields.path;
|
|
90
|
+
this.stage = fields.stage || 0;
|
|
91
|
+
this.assumeValid = Boolean(fields.assumeValid);
|
|
92
|
+
this.skipWorktree = Boolean(fields.skipWorktree);
|
|
93
|
+
this.intentToAdd = Boolean(fields.intentToAdd);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Build an entry from a worktree lstat. Pass a `{ bigint: true }` stat for
|
|
98
|
+
* true nanosecond fidelity; a plain stat degrades to millisecond
|
|
99
|
+
* precision, which only costs an occasional extra content comparison.
|
|
100
|
+
* @param {fs.Stats|fs.BigIntStats} stat
|
|
101
|
+
* @param {String} relativePath
|
|
102
|
+
* @param {String} oid
|
|
103
|
+
* @param {Number} mode - one of MODE.*
|
|
104
|
+
* @returns {IndexEntry}
|
|
105
|
+
*/
|
|
106
|
+
static fromStat(stat, relativePath, oid, mode) {
|
|
107
|
+
return new IndexEntry({
|
|
108
|
+
ctimeSeconds: Math.floor(Number(stat.ctimeMs) / 1000),
|
|
109
|
+
ctimeNanoseconds: nanosecondsOf(stat.ctimeNs, stat.ctimeMs),
|
|
110
|
+
mtimeSeconds: Math.floor(Number(stat.mtimeMs) / 1000),
|
|
111
|
+
mtimeNanoseconds: nanosecondsOf(stat.mtimeNs, stat.mtimeMs),
|
|
112
|
+
dev: truncate32(stat.dev),
|
|
113
|
+
ino: truncate32(stat.ino),
|
|
114
|
+
mode,
|
|
115
|
+
uid: truncate32(stat.uid),
|
|
116
|
+
gid: truncate32(stat.gid),
|
|
117
|
+
size: truncate32(stat.size),
|
|
118
|
+
oid,
|
|
119
|
+
path: relativePath
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Cheap "unchanged" test. Deliberately conservative: any difference, or a
|
|
125
|
+
* racy timestamp, sends the caller to a content comparison.
|
|
126
|
+
* @param {fs.Stats} stat
|
|
127
|
+
* @returns {Boolean}
|
|
128
|
+
*/
|
|
129
|
+
matchesStat(stat) {
|
|
130
|
+
if (this.assumeValid) return true;
|
|
131
|
+
return this.mtimeSeconds === Math.floor(Number(stat.mtimeMs) / 1000) &&
|
|
132
|
+
this.size === truncate32(stat.size) &&
|
|
133
|
+
this.ino === truncate32(stat.ino) &&
|
|
134
|
+
this.dev === truncate32(stat.dev) &&
|
|
135
|
+
this.mode === modeFromStat(stat, this.mode);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {Number} indexMtimeSeconds
|
|
140
|
+
* @returns {Boolean} true when stat data cannot prove cleanliness
|
|
141
|
+
*/
|
|
142
|
+
isRacy(indexMtimeSeconds) {
|
|
143
|
+
return this.mtimeSeconds >= indexMtimeSeconds;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* @param {Number|BigInt} value
|
|
149
|
+
* @returns {Number}
|
|
150
|
+
*/
|
|
151
|
+
function truncate32(value) {
|
|
152
|
+
return Number(BigInt.asUintN(32, BigInt(Math.trunc(Number(value || 0)))));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Sub-second part of a timestamp, preferring the bigint nanosecond field.
|
|
157
|
+
* @param {BigInt|undefined} nanoseconds
|
|
158
|
+
* @param {Number|BigInt} milliseconds
|
|
159
|
+
* @returns {Number}
|
|
160
|
+
*/
|
|
161
|
+
function nanosecondsOf(nanoseconds, milliseconds) {
|
|
162
|
+
if (typeof nanoseconds === 'bigint') return Number(nanoseconds % BigInt(1e9));
|
|
163
|
+
return Math.round((Number(milliseconds || 0) % 1000) * 1e6);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Git's view of a worktree file's mode.
|
|
168
|
+
* @param {fs.Stats} stat
|
|
169
|
+
* @param {Number} [fallback] - used when the platform has no executable bit
|
|
170
|
+
* @returns {Number}
|
|
171
|
+
*/
|
|
172
|
+
function modeFromStat(stat, fallback) {
|
|
173
|
+
if (stat.isSymbolicLink()) return MODE.SYMLINK;
|
|
174
|
+
if (stat.isDirectory()) return MODE.GITLINK;
|
|
175
|
+
if (fallback === MODE.EXECUTABLE || fallback === MODE.REGULAR) {
|
|
176
|
+
return (stat.mode & 0o111) ? MODE.EXECUTABLE : MODE.REGULAR;
|
|
177
|
+
}
|
|
178
|
+
return (stat.mode & 0o111) ? MODE.EXECUTABLE : MODE.REGULAR;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Index order: path bytes ascending, then stage ascending.
|
|
183
|
+
* @param {IndexEntry} a
|
|
184
|
+
* @param {IndexEntry} b
|
|
185
|
+
* @returns {Number}
|
|
186
|
+
*/
|
|
187
|
+
function compareEntries(a, b) {
|
|
188
|
+
const cmp = Buffer.compare(Buffer.from(a.path, 'utf8'), Buffer.from(b.path, 'utf8'));
|
|
189
|
+
if (cmp !== 0) return cmp;
|
|
190
|
+
return a.stage - b.stage;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
class GitIndex {
|
|
194
|
+
constructor() {
|
|
195
|
+
this.version = WRITE_VERSION;
|
|
196
|
+
/** @type {Array<IndexEntry>} kept sorted */
|
|
197
|
+
this.entries = [];
|
|
198
|
+
/** @type {Map<String, Buffer>} understood extensions, verbatim */
|
|
199
|
+
this.extensions = new Map();
|
|
200
|
+
/** @type {Number} mtime of the file we read, for racy detection */
|
|
201
|
+
this.readMtimeSeconds = 0;
|
|
202
|
+
this.dirty = false;
|
|
203
|
+
this.sourceBytes = null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @param {String} indexPath
|
|
208
|
+
* @returns {Promise<GitIndex>} an empty index when the file does not exist
|
|
209
|
+
*/
|
|
210
|
+
static async read(indexPath) {
|
|
211
|
+
const raw = await readFileOrNull(indexPath);
|
|
212
|
+
const index = raw ? GitIndex.parse(raw) : new GitIndex();
|
|
213
|
+
index.sourceBytes = raw;
|
|
214
|
+
if (raw) {
|
|
215
|
+
const stat = await fs.stat(indexPath).catch(() => null);
|
|
216
|
+
index.readMtimeSeconds = stat ? Math.floor(stat.mtimeMs / 1000) : 0;
|
|
217
|
+
}
|
|
218
|
+
return index;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* @param {Buffer} buffer
|
|
223
|
+
* @returns {GitIndex}
|
|
224
|
+
*/
|
|
225
|
+
static parse(buffer) {
|
|
226
|
+
if (buffer.length < 12 + OID_RAW_LENGTH) throw new IndexError('index is too short to be valid');
|
|
227
|
+
if (buffer.readUInt32BE(0) !== SIGNATURE) throw new IndexError('index does not start with DIRC');
|
|
228
|
+
|
|
229
|
+
const version = buffer.readUInt32BE(4);
|
|
230
|
+
if (!SUPPORTED_READ_VERSIONS.has(version)) {
|
|
231
|
+
throw new IndexError(`index version ${version} is not supported (Gent reads 2, 3 and 4)`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const body = buffer.subarray(0, buffer.length - OID_RAW_LENGTH);
|
|
235
|
+
const stored = buffer.subarray(buffer.length - OID_RAW_LENGTH).toString('hex');
|
|
236
|
+
const actual = crypto.createHash('sha256').update(body).digest('hex');
|
|
237
|
+
if (stored !== actual) {
|
|
238
|
+
throw new IndexError(`index checksum mismatch: file says ${stored.slice(0, 12)}, contents hash to ${actual.slice(0, 12)}`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const index = new GitIndex();
|
|
242
|
+
index.version = version;
|
|
243
|
+
|
|
244
|
+
const count = buffer.readUInt32BE(8);
|
|
245
|
+
let offset = 12;
|
|
246
|
+
let previousPath = '';
|
|
247
|
+
|
|
248
|
+
for (let i = 0; i < count; i++) {
|
|
249
|
+
const parsed = parseEntry(buffer, offset, version, previousPath);
|
|
250
|
+
index.entries.push(parsed.entry);
|
|
251
|
+
previousPath = parsed.entry.path;
|
|
252
|
+
offset = parsed.offset;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
while (offset + 8 <= body.length) {
|
|
256
|
+
const signature = body.toString('latin1', offset, offset + 4);
|
|
257
|
+
const size = body.readUInt32BE(offset + 4);
|
|
258
|
+
const start = offset + 8;
|
|
259
|
+
const end = start + size;
|
|
260
|
+
if (end > body.length) throw new IndexError(`index extension '${signature}' claims ${size} bytes but the file ends first`);
|
|
261
|
+
|
|
262
|
+
const optional = /^[A-Z]/.test(signature);
|
|
263
|
+
if (!optional) {
|
|
264
|
+
const featureId = KNOWN_REQUIRED_EXTENSIONS[signature];
|
|
265
|
+
throw new UnsupportedFeatureError(
|
|
266
|
+
[featureId
|
|
267
|
+
? feature(featureId)
|
|
268
|
+
: {
|
|
269
|
+
id: `index.${signature}`,
|
|
270
|
+
status: 'unsupported',
|
|
271
|
+
title: `Required index extension '${signature}'`,
|
|
272
|
+
detail: 'The index cannot be interpreted without it.',
|
|
273
|
+
remedy: 'Rewrite the index with Git, or remove the feature that produced this extension.'
|
|
274
|
+
}],
|
|
275
|
+
'reading the index'
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (UNDERSTOOD_EXTENSIONS.has(signature)) {
|
|
279
|
+
index.extensions.set(signature, body.subarray(start, end));
|
|
280
|
+
}
|
|
281
|
+
offset = end;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
index.entries.sort(compareEntries);
|
|
285
|
+
return index;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @param {String} filePath
|
|
290
|
+
* @param {Number} [stage]
|
|
291
|
+
* @returns {IndexEntry|undefined}
|
|
292
|
+
*/
|
|
293
|
+
get(filePath, stage = 0) {
|
|
294
|
+
return this.entries.find(e => e.path === filePath && e.stage === stage);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Every entry for a path, any stage.
|
|
299
|
+
* @param {String} filePath
|
|
300
|
+
* @returns {Array<IndexEntry>}
|
|
301
|
+
*/
|
|
302
|
+
getAll(filePath) {
|
|
303
|
+
return this.entries.filter(e => e.path === filePath);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Stage-0 entries only — the normal "what will be committed" view.
|
|
308
|
+
* @returns {Array<IndexEntry>}
|
|
309
|
+
*/
|
|
310
|
+
staged() {
|
|
311
|
+
return this.entries.filter(e => e.stage === 0);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* @returns {Map<String, {base?: IndexEntry, ours?: IndexEntry, theirs?: IndexEntry}>}
|
|
316
|
+
*/
|
|
317
|
+
conflicts() {
|
|
318
|
+
const byPath = new Map();
|
|
319
|
+
for (const entry of this.entries) {
|
|
320
|
+
if (entry.stage === 0) continue;
|
|
321
|
+
if (!byPath.has(entry.path)) byPath.set(entry.path, {});
|
|
322
|
+
byPath.get(entry.path)[['', 'base', 'ours', 'theirs'][entry.stage]] = entry;
|
|
323
|
+
}
|
|
324
|
+
return byPath;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @returns {Boolean}
|
|
329
|
+
*/
|
|
330
|
+
hasConflicts() {
|
|
331
|
+
return this.entries.some(e => e.stage !== 0);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Insert or replace a stage-0 entry, clearing any conflict on that path.
|
|
336
|
+
* @param {IndexEntry} entry
|
|
337
|
+
*/
|
|
338
|
+
add(entry) {
|
|
339
|
+
this.remove(entry.path);
|
|
340
|
+
this.entries.push(entry);
|
|
341
|
+
this.entries.sort(compareEntries);
|
|
342
|
+
this._invalidateDerived(entry.path);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Insert an entry at a specific stage without touching the others.
|
|
347
|
+
* @param {IndexEntry} entry
|
|
348
|
+
*/
|
|
349
|
+
addStage(entry) {
|
|
350
|
+
this.entries = this.entries.filter(e => !(e.path === entry.path && e.stage === entry.stage));
|
|
351
|
+
this.entries.push(entry);
|
|
352
|
+
this.entries.sort(compareEntries);
|
|
353
|
+
this._invalidateDerived(entry.path);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Remove every stage of a path.
|
|
358
|
+
* @param {String} filePath
|
|
359
|
+
* @returns {Boolean} whether anything was removed
|
|
360
|
+
*/
|
|
361
|
+
remove(filePath) {
|
|
362
|
+
const before = this.entries.length;
|
|
363
|
+
this.entries = this.entries.filter(e => e.path !== filePath);
|
|
364
|
+
if (this.entries.length !== before) this._invalidateDerived(filePath);
|
|
365
|
+
return this.entries.length !== before;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Replace the conflict stages of a path with a resolved stage-0 entry.
|
|
370
|
+
* @param {IndexEntry} entry
|
|
371
|
+
*/
|
|
372
|
+
resolve(entry) {
|
|
373
|
+
this.add(entry);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Cached trees and resolve-undo data stop being true the moment an entry
|
|
378
|
+
* moves. Dropping them entirely is correct and cheap; a stale cache-tree
|
|
379
|
+
* would make Git write a wrong commit.
|
|
380
|
+
*/
|
|
381
|
+
_invalidateDerived() {
|
|
382
|
+
this.extensions.delete('TREE');
|
|
383
|
+
this.extensions.delete('REUC');
|
|
384
|
+
this.dirty = true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* @returns {Buffer}
|
|
389
|
+
*/
|
|
390
|
+
serialize() {
|
|
391
|
+
const unrepresentable = this.entries.filter(e => e.skipWorktree || e.intentToAdd);
|
|
392
|
+
if (unrepresentable.length) {
|
|
393
|
+
throw new IndexError(
|
|
394
|
+
`cannot write this index as version ${WRITE_VERSION}: ` +
|
|
395
|
+
`${unrepresentable.map(e => e.path).join(', ')} carry skip-worktree or intent-to-add flags that only version 3 can express.\n` +
|
|
396
|
+
`Clear those flags with Git, or commit through Git for these paths.`
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const header = Buffer.alloc(12);
|
|
401
|
+
header.writeUInt32BE(SIGNATURE, 0);
|
|
402
|
+
header.writeUInt32BE(WRITE_VERSION, 4);
|
|
403
|
+
header.writeUInt32BE(this.entries.length, 8);
|
|
404
|
+
|
|
405
|
+
const parts = [header];
|
|
406
|
+
for (const entry of [...this.entries].sort(compareEntries)) {
|
|
407
|
+
parts.push(serializeEntry(entry));
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Optional extensions Gent does not maintain are dropped rather than
|
|
411
|
+
// written back stale; TREE/REUC are only kept when still valid.
|
|
412
|
+
for (const [signature, data] of this.extensions) {
|
|
413
|
+
const head = Buffer.alloc(8);
|
|
414
|
+
head.write(signature, 0, 'latin1');
|
|
415
|
+
head.writeUInt32BE(data.length, 4);
|
|
416
|
+
parts.push(head, data);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const body = Buffer.concat(parts);
|
|
420
|
+
return Buffer.concat([body, crypto.createHash('sha256').update(body).digest()]);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Write under index.lock. Returns the mtime so callers can reason about
|
|
425
|
+
* racy entries immediately afterwards.
|
|
426
|
+
* @param {String} indexPath
|
|
427
|
+
* @returns {Promise<Number>} mtime in seconds
|
|
428
|
+
*/
|
|
429
|
+
async write(indexPath) {
|
|
430
|
+
const bytes = this.serialize();
|
|
431
|
+
await withLock(indexPath, async (lock) => {
|
|
432
|
+
const current = await lock.readTarget();
|
|
433
|
+
if (!(current === null && this.sourceBytes === null) &&
|
|
434
|
+
!(current && this.sourceBytes && current.equals(this.sourceBytes))) {
|
|
435
|
+
throw new IndexError("index changed since it was read; retry the operation");
|
|
436
|
+
}
|
|
437
|
+
const recoveryPath = path.join(path.dirname(indexPath), 'gent', 'checkout-plan.json');
|
|
438
|
+
const recovery = await readFileOrNull(recoveryPath);
|
|
439
|
+
if (recovery) {
|
|
440
|
+
const record = JSON.parse(recovery.toString());
|
|
441
|
+
record.nextIndex = bytes.toString('base64');
|
|
442
|
+
await writeAtomic(recoveryPath, JSON.stringify(record));
|
|
443
|
+
}
|
|
444
|
+
await lock.write(bytes);
|
|
445
|
+
});
|
|
446
|
+
this.sourceBytes = bytes;
|
|
447
|
+
this.dirty = false;
|
|
448
|
+
const stat = await fs.stat(indexPath);
|
|
449
|
+
this.readMtimeSeconds = Math.floor(stat.mtimeMs / 1000);
|
|
450
|
+
return this.readMtimeSeconds;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* @param {Buffer} buffer
|
|
456
|
+
* @param {Number} offset
|
|
457
|
+
* @param {Number} version
|
|
458
|
+
* @param {String} previousPath
|
|
459
|
+
* @returns {{entry: IndexEntry, offset: Number}}
|
|
460
|
+
*/
|
|
461
|
+
function parseEntry(buffer, offset, version, previousPath) {
|
|
462
|
+
const start = offset;
|
|
463
|
+
if (offset + FIXED_ENTRY_PREFIX > buffer.length) throw new IndexError('index entry is truncated');
|
|
464
|
+
|
|
465
|
+
const fields = {
|
|
466
|
+
ctimeSeconds: buffer.readUInt32BE(offset),
|
|
467
|
+
ctimeNanoseconds: buffer.readUInt32BE(offset + 4),
|
|
468
|
+
mtimeSeconds: buffer.readUInt32BE(offset + 8),
|
|
469
|
+
mtimeNanoseconds: buffer.readUInt32BE(offset + 12),
|
|
470
|
+
dev: buffer.readUInt32BE(offset + 16),
|
|
471
|
+
ino: buffer.readUInt32BE(offset + 20),
|
|
472
|
+
mode: buffer.readUInt32BE(offset + 24),
|
|
473
|
+
uid: buffer.readUInt32BE(offset + 28),
|
|
474
|
+
gid: buffer.readUInt32BE(offset + 32),
|
|
475
|
+
size: buffer.readUInt32BE(offset + 36)
|
|
476
|
+
};
|
|
477
|
+
offset += 40;
|
|
478
|
+
|
|
479
|
+
fields.oid = buffer.toString('hex', offset, offset + OID_RAW_LENGTH);
|
|
480
|
+
offset += OID_RAW_LENGTH;
|
|
481
|
+
|
|
482
|
+
const flags = buffer.readUInt16BE(offset);
|
|
483
|
+
offset += 2;
|
|
484
|
+
|
|
485
|
+
fields.assumeValid = Boolean(flags & FLAG_ASSUME_VALID);
|
|
486
|
+
fields.stage = (flags & FLAG_STAGE_MASK) >> 12;
|
|
487
|
+
const extended = Boolean(flags & FLAG_EXTENDED);
|
|
488
|
+
|
|
489
|
+
if (extended) {
|
|
490
|
+
if (version < 3) throw new IndexError('an extended-flag entry appeared in a version 2 index');
|
|
491
|
+
const extraFlags = buffer.readUInt16BE(offset);
|
|
492
|
+
offset += 2;
|
|
493
|
+
fields.skipWorktree = Boolean(extraFlags & EXT_FLAG_SKIP_WORKTREE);
|
|
494
|
+
fields.intentToAdd = Boolean(extraFlags & EXT_FLAG_INTENT_TO_ADD);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
let nameLength = flags & FLAG_NAME_MASK;
|
|
498
|
+
|
|
499
|
+
if (version >= 4) {
|
|
500
|
+
const stripped = readVarint(buffer, offset);
|
|
501
|
+
offset = stripped.offset;
|
|
502
|
+
if (stripped.value > previousPath.length) {
|
|
503
|
+
throw new IndexError('version 4 path prefix removes more bytes than the previous path has');
|
|
504
|
+
}
|
|
505
|
+
const prefix = previousPath.slice(0, previousPath.length - stripped.value);
|
|
506
|
+
|
|
507
|
+
const nul = buffer.indexOf(0, offset);
|
|
508
|
+
if (nul < 0) throw new IndexError('index entry path is not NUL-terminated');
|
|
509
|
+
fields.path = prefix + buffer.toString('utf8', offset, nul);
|
|
510
|
+
offset = nul + 1;
|
|
511
|
+
} else {
|
|
512
|
+
if (nameLength === FLAG_NAME_MASK) {
|
|
513
|
+
const nul = buffer.indexOf(0, offset);
|
|
514
|
+
if (nul < 0) throw new IndexError('index entry path is not NUL-terminated');
|
|
515
|
+
nameLength = nul - offset;
|
|
516
|
+
}
|
|
517
|
+
fields.path = buffer.toString('utf8', offset, offset + nameLength);
|
|
518
|
+
const entrySize = (FIXED_ENTRY_PREFIX + (extended ? 2 : 0) + nameLength + 8) & ~7;
|
|
519
|
+
offset = start + entrySize;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (fields.path.includes('\0')) throw new IndexError('index entry path contains NUL');
|
|
523
|
+
return { entry: new IndexEntry(fields), offset };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* @param {IndexEntry} entry
|
|
528
|
+
* @returns {Buffer}
|
|
529
|
+
*/
|
|
530
|
+
function serializeEntry(entry) {
|
|
531
|
+
const name = Buffer.from(entry.path, 'utf8');
|
|
532
|
+
const size = (FIXED_ENTRY_PREFIX + name.length + 8) & ~7;
|
|
533
|
+
const buffer = Buffer.alloc(size);
|
|
534
|
+
|
|
535
|
+
buffer.writeUInt32BE(entry.ctimeSeconds >>> 0, 0);
|
|
536
|
+
buffer.writeUInt32BE(entry.ctimeNanoseconds >>> 0, 4);
|
|
537
|
+
buffer.writeUInt32BE(entry.mtimeSeconds >>> 0, 8);
|
|
538
|
+
buffer.writeUInt32BE(entry.mtimeNanoseconds >>> 0, 12);
|
|
539
|
+
buffer.writeUInt32BE(entry.dev >>> 0, 16);
|
|
540
|
+
buffer.writeUInt32BE(entry.ino >>> 0, 20);
|
|
541
|
+
buffer.writeUInt32BE(entry.mode >>> 0, 24);
|
|
542
|
+
buffer.writeUInt32BE(entry.uid >>> 0, 28);
|
|
543
|
+
buffer.writeUInt32BE(entry.gid >>> 0, 32);
|
|
544
|
+
buffer.writeUInt32BE(entry.size >>> 0, 36);
|
|
545
|
+
Buffer.from(entry.oid, 'hex').copy(buffer, 40);
|
|
546
|
+
|
|
547
|
+
let flags = Math.min(name.length, FLAG_NAME_MASK);
|
|
548
|
+
flags |= (entry.stage & 0x3) << 12;
|
|
549
|
+
if (entry.assumeValid) flags |= FLAG_ASSUME_VALID;
|
|
550
|
+
buffer.writeUInt16BE(flags, 40 + OID_RAW_LENGTH);
|
|
551
|
+
|
|
552
|
+
name.copy(buffer, FIXED_ENTRY_PREFIX);
|
|
553
|
+
return buffer;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Git's offset varint (most-significant group first, with an implicit +1).
|
|
558
|
+
* @param {Buffer} buffer
|
|
559
|
+
* @param {Number} offset
|
|
560
|
+
* @returns {{value: Number, offset: Number}}
|
|
561
|
+
*/
|
|
562
|
+
function readVarint(buffer, offset) {
|
|
563
|
+
let byte = buffer[offset++];
|
|
564
|
+
let value = byte & 0x7f;
|
|
565
|
+
while (byte & 0x80) {
|
|
566
|
+
byte = buffer[offset++];
|
|
567
|
+
if (byte === undefined) throw new IndexError('truncated varint in index');
|
|
568
|
+
value = ((value + 1) << 7) | (byte & 0x7f);
|
|
569
|
+
}
|
|
570
|
+
return { value, offset };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
module.exports = {
|
|
574
|
+
GitIndex,
|
|
575
|
+
IndexEntry,
|
|
576
|
+
IndexError,
|
|
577
|
+
compareEntries,
|
|
578
|
+
modeFromStat,
|
|
579
|
+
WRITE_VERSION,
|
|
580
|
+
FIXED_ENTRY_PREFIX
|
|
581
|
+
};
|