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.
@@ -0,0 +1,595 @@
1
+ /**
2
+ * ============================================================================
3
+ * Refs - loose refs, packed-refs, symbolic HEAD and reflogs
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * The only place branch, tag and HEAD pointers are read or written. Every
8
+ * update is a compare-and-set under <ref>.lock, so an external Git writing
9
+ * the same ref concurrently cannot be silently overwritten.
10
+ *
11
+ * STORAGE:
12
+ * loose: <commondir>/refs/heads/main "<oid>\n"
13
+ * symbolic: <gitdir>/HEAD "ref: refs/heads/main\n"
14
+ * packed: <commondir>/packed-refs "<oid> <ref>" + "^<peeled>"
15
+ * reflog: <commondir>/logs/<ref> append-only
16
+ *
17
+ * PRECEDENCE:
18
+ * A loose ref shadows the packed value. Deleting must therefore remove the
19
+ * loose file *and* rewrite packed-refs, or the ref comes back from the dead.
20
+ *
21
+ * PER-WORKTREE REFS:
22
+ * HEAD, ORIG_HEAD, MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, BISECT_*,
23
+ * refs/bisect/*, refs/worktree/* and refs/rewritten/* belong to the calling
24
+ * worktree's gitdir. Everything else lives in the common directory.
25
+ *
26
+ * See docs/git-compat/format-contract.md sections 6 and 7.
27
+ * ============================================================================
28
+ */
29
+
30
+ const fs = require('fs').promises;
31
+ const path = require('path');
32
+
33
+ const { isObjectId, assertObjectId, NULL_OID } = require('./git-objects');
34
+ const { withLock, writeAtomic, readFileOrNull, Lock } = require('./lockfile');
35
+
36
+ const PACKED_REFS_HEADER = '# pack-refs with: peeled fully-peeled sorted ';
37
+ const MAX_SYMREF_DEPTH = 5;
38
+
39
+ /** Refs that are private to one worktree rather than shared. */
40
+ const PER_WORKTREE_EXACT = new Set([
41
+ 'HEAD', 'ORIG_HEAD', 'FETCH_HEAD', 'MERGE_HEAD', 'CHERRY_PICK_HEAD',
42
+ 'REVERT_HEAD', 'REBASE_HEAD', 'AUTO_MERGE', 'BISECT_EXPECTED_REV'
43
+ ]);
44
+ const PER_WORKTREE_PREFIXES = ['refs/bisect/', 'refs/worktree/', 'refs/rewritten/'];
45
+
46
+ class RefError extends Error {
47
+ constructor(message, code) {
48
+ super(message);
49
+ this.name = 'RefError';
50
+ this.code = code || 'GENT_BAD_REF';
51
+ }
52
+ }
53
+
54
+ /**
55
+ * git check-ref-format, applied to a full ref name such as refs/heads/main.
56
+ * @param {String} name
57
+ * @param {Object} [options]
58
+ * @param {Boolean} [options.allowOneLevel] - HEAD and friends
59
+ * @returns {Boolean}
60
+ */
61
+ function isValidRefName(name, options = {}) {
62
+ if (typeof name !== 'string' || name === '') return false;
63
+ if (name.endsWith('/') || name.endsWith('.') || name.endsWith('.lock')) return false;
64
+ if (name.startsWith('/')) return false;
65
+ if (name.includes('//') || name.includes('..') || name.includes('@{')) return false;
66
+ if (name === '@') return false;
67
+
68
+ for (const ch of name) {
69
+ const code = ch.codePointAt(0);
70
+ if (code < 0x20 || code === 0x7f) return false;
71
+ if (' ~^:?*[\\'.includes(ch)) return false;
72
+ }
73
+
74
+ const components = name.split('/');
75
+ if (!options.allowOneLevel && components.length < 2) return false;
76
+
77
+ for (const component of components) {
78
+ if (component === '') return false;
79
+ if (component.startsWith('.')) return false;
80
+ if (component.endsWith('.lock')) return false;
81
+ }
82
+ return true;
83
+ }
84
+
85
+ /**
86
+ * @param {String} name
87
+ * @param {Object} [options]
88
+ * @returns {String} the same name
89
+ */
90
+ function assertRefName(name, options) {
91
+ if (!isValidRefName(name, options)) {
92
+ throw new RefError(`'${name}' is not a valid ref name`);
93
+ }
94
+ return name;
95
+ }
96
+
97
+ /**
98
+ * @param {String} name
99
+ * @returns {Boolean}
100
+ */
101
+ function isPerWorktreeRef(name) {
102
+ return PER_WORKTREE_EXACT.has(name) || PER_WORKTREE_PREFIXES.some(prefix => name.startsWith(prefix));
103
+ }
104
+
105
+ class RefStore {
106
+ /**
107
+ * @param {Object} repo - { gitdir, commondir, identity? }
108
+ */
109
+ constructor(repo) {
110
+ this.gitdir = repo.gitdir;
111
+ this.commondir = repo.commondir || repo.gitdir;
112
+ this.repo = repo;
113
+ this._packed = null; // lazily loaded cache
114
+ }
115
+
116
+ /**
117
+ * Filesystem location of a ref, honouring per-worktree placement.
118
+ * @param {String} name
119
+ * @returns {String}
120
+ */
121
+ refPath(name) {
122
+ assertRefName(name, { allowOneLevel: true });
123
+ return path.join(isPerWorktreeRef(name) ? this.gitdir : this.commondir, ...name.split('/'));
124
+ }
125
+
126
+ /**
127
+ * @param {String} name
128
+ * @returns {String}
129
+ */
130
+ reflogPath(name) {
131
+ return path.join(isPerWorktreeRef(name) ? this.gitdir : this.commondir, 'logs', ...name.split('/'));
132
+ }
133
+
134
+ /** Drop the packed-refs cache after anything that could change it. */
135
+ invalidate() {
136
+ this._packed = null;
137
+ }
138
+
139
+ // ─── packed-refs ─────────────────────────────────────
140
+
141
+ /**
142
+ * @returns {Promise<Map<String, {oid: String, peeled: String|null}>>}
143
+ */
144
+ async packedRefs() {
145
+ if (this._packed) return this._packed;
146
+
147
+ const packed = new Map();
148
+ const raw = await readFileOrNull(path.join(this.commondir, 'packed-refs'));
149
+ if (!raw) {
150
+ this._packed = packed;
151
+ return packed;
152
+ }
153
+
154
+ let last = null;
155
+ for (const line of raw.toString('utf-8').split('\n')) {
156
+ if (!line || line.startsWith('#')) continue;
157
+
158
+ if (line.startsWith('^')) {
159
+ const peeled = line.slice(1).trim();
160
+ if (!last) throw new RefError('packed-refs has a peel line with no preceding ref');
161
+ packed.get(last).peeled = assertObjectId(peeled, 'peeled ref');
162
+ continue;
163
+ }
164
+
165
+ const space = line.indexOf(' ');
166
+ if (space < 0) throw new RefError(`malformed packed-refs line: ${JSON.stringify(line)}`);
167
+ const oid = line.slice(0, space);
168
+ const name = line.slice(space + 1).trim();
169
+ if (!isObjectId(oid) || !isValidRefName(name, { allowOneLevel: true })) {
170
+ throw new RefError(`malformed packed-refs line: ${JSON.stringify(line)}`);
171
+ }
172
+ packed.set(name, { oid, peeled: null });
173
+ last = name;
174
+ }
175
+
176
+ this._packed = packed;
177
+ return packed;
178
+ }
179
+
180
+ /**
181
+ * Rewrite packed-refs from a full map. Callers hold the packed-refs lock.
182
+ * @param {Lock} lock
183
+ * @param {Map<String, {oid: String, peeled: String|null}>} packed
184
+ */
185
+ async _writePackedRefs(lock, packed) {
186
+ const names = [...packed.keys()].sort();
187
+ const lines = [PACKED_REFS_HEADER];
188
+ for (const name of names) {
189
+ const entry = packed.get(name);
190
+ lines.push(`${entry.oid} ${name}`);
191
+ if (entry.peeled) lines.push(`^${entry.peeled}`);
192
+ }
193
+ await lock.write(lines.join('\n') + '\n');
194
+ this.invalidate();
195
+ }
196
+
197
+ // ─── reading ─────────────────────────────────────────
198
+
199
+ /**
200
+ * Raw content of a ref: an oid, or a symbolic target.
201
+ * @param {String} name
202
+ * @returns {Promise<{kind: 'oid'|'symbolic', value: String, loose: Boolean}|null>}
203
+ */
204
+ async readRef(name) {
205
+ const raw = await readFileOrNull(this.refPath(name));
206
+ if (raw !== null) {
207
+ const text = raw.toString('utf-8').trim();
208
+ if (text.startsWith('ref:')) {
209
+ const target = text.slice(4).trim();
210
+ assertRefName(target, { allowOneLevel: true });
211
+ return { kind: 'symbolic', value: target, loose: true };
212
+ }
213
+ if (!isObjectId(text)) {
214
+ throw new RefError(`ref '${name}' does not contain an object id: ${JSON.stringify(text.slice(0, 80))}`);
215
+ }
216
+ return { kind: 'oid', value: text, loose: true };
217
+ }
218
+
219
+ const packed = (await this.packedRefs()).get(name);
220
+ return packed ? { kind: 'oid', value: packed.oid, loose: false } : null;
221
+ }
222
+
223
+ /**
224
+ * Follow symbolic refs to an object id.
225
+ * @param {String} name
226
+ * @returns {Promise<{oid: String|null, ref: String, symbolic: Array<String>}>}
227
+ * oid is null for an unborn ref (HEAD on a branch with no commits)
228
+ */
229
+ async resolve(name) {
230
+ const chain = [];
231
+ let current = name;
232
+
233
+ for (let depth = 0; depth < MAX_SYMREF_DEPTH; depth++) {
234
+ const entry = await this.readRef(current);
235
+ if (!entry) return { oid: null, ref: current, symbolic: chain };
236
+ if (entry.kind === 'oid') return { oid: entry.value, ref: current, symbolic: chain };
237
+ chain.push(current);
238
+ current = entry.value;
239
+ }
240
+ throw new RefError(`symbolic ref '${name}' is nested more than ${MAX_SYMREF_DEPTH} levels deep`);
241
+ }
242
+
243
+ /**
244
+ * @param {String} name
245
+ * @returns {Promise<String|null>}
246
+ */
247
+ async resolveToOid(name) {
248
+ return (await this.resolve(name)).oid;
249
+ }
250
+
251
+ /**
252
+ * Every ref under a prefix, loose entries shadowing packed ones.
253
+ * @param {String} [prefix] - e.g. 'refs/heads/'
254
+ * @returns {Promise<Map<String, String>>} name -> oid
255
+ */
256
+ async list(prefix = 'refs/') {
257
+ const result = new Map();
258
+
259
+ for (const [name, entry] of await this.packedRefs()) {
260
+ if (name.startsWith(prefix)) result.set(name, entry.oid);
261
+ }
262
+
263
+ const roots = new Set([path.join(this.commondir, 'refs'), path.join(this.gitdir, 'refs')]);
264
+ for (const root of roots) {
265
+ await this._walkLoose(root, 'refs', prefix, result);
266
+ }
267
+ return result;
268
+ }
269
+
270
+ async _walkLoose(dir, refPrefix, wanted, out) {
271
+ let entries;
272
+ try {
273
+ entries = await fs.readdir(dir, { withFileTypes: true });
274
+ } catch (error) {
275
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return;
276
+ throw error;
277
+ }
278
+
279
+ for (const entry of entries) {
280
+ if (entry.name.endsWith('.lock')) continue;
281
+ const name = `${refPrefix}/${entry.name}`;
282
+
283
+ if (entry.isDirectory()) {
284
+ // Descend when the prefix could still match either way.
285
+ if (wanted.startsWith(name + '/') || name.startsWith(wanted) || wanted.startsWith(name)) {
286
+ await this._walkLoose(path.join(dir, entry.name), name, wanted, out);
287
+ }
288
+ continue;
289
+ }
290
+ if (!name.startsWith(wanted)) continue;
291
+
292
+ const ref = await this.readRef(name);
293
+ if (ref && ref.kind === 'oid') out.set(name, ref.value);
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Git's ref lookup order for a user-supplied shorthand.
299
+ * @param {String} shorthand
300
+ * @returns {Promise<{name: String, oid: String}|null>}
301
+ */
302
+ async expand(shorthand) {
303
+ const candidates = shorthand === 'HEAD'
304
+ ? ['HEAD']
305
+ : [
306
+ shorthand,
307
+ `refs/${shorthand}`,
308
+ `refs/tags/${shorthand}`,
309
+ `refs/heads/${shorthand}`,
310
+ `refs/remotes/${shorthand}`,
311
+ `refs/remotes/${shorthand}/HEAD`
312
+ ];
313
+
314
+ for (const candidate of candidates) {
315
+ if (!isValidRefName(candidate, { allowOneLevel: true })) continue;
316
+ const resolved = await this.resolve(candidate);
317
+ if (resolved.oid) return { name: resolved.ref, oid: resolved.oid };
318
+ }
319
+ return null;
320
+ }
321
+
322
+ // ─── HEAD ────────────────────────────────────────────
323
+
324
+ /**
325
+ * @returns {Promise<{detached: Boolean, unborn: Boolean, ref: String|null, oid: String|null, branch: String|null}>}
326
+ */
327
+ async head() {
328
+ const entry = await this.readRef('HEAD');
329
+ if (!entry) return { detached: false, unborn: true, ref: null, oid: null, branch: null };
330
+
331
+ if (entry.kind === 'oid') {
332
+ return { detached: true, unborn: false, ref: null, oid: entry.value, branch: null };
333
+ }
334
+
335
+ const target = entry.value;
336
+ const oid = await this.resolveToOid(target);
337
+ return {
338
+ detached: false,
339
+ unborn: oid === null,
340
+ ref: target,
341
+ oid,
342
+ branch: target.startsWith('refs/heads/') ? target.slice('refs/heads/'.length) : null
343
+ };
344
+ }
345
+
346
+ /**
347
+ * Point HEAD at a branch without touching the branch itself.
348
+ * @param {String} target
349
+ * @param {String} [reason]
350
+ */
351
+ async setHeadSymbolic(target, reason, expectedHead) {
352
+ assertRefName(target);
353
+ await this._setHead(`ref: ${target}\n`, reason || `checkout: moving to ${target}`, expectedHead);
354
+ }
355
+
356
+ async setHeadDetached(oid, reason, expectedHead) {
357
+ assertObjectId(oid);
358
+ await this._setHead(`${oid}\n`, reason || `checkout: moving to ${oid}`, expectedHead);
359
+ }
360
+
361
+ async _setHead(content, reason, expectedHead) {
362
+ const before = await withLock(path.join(this.gitdir, 'HEAD'), async lock => {
363
+ const current = await this.head();
364
+ if (expectedHead && (current.ref !== expectedHead.ref || current.oid !== expectedHead.oid)) {
365
+ throw new RefError('HEAD changed during the operation; refusing to overwrite it');
366
+ }
367
+ await lock.write(content);
368
+ return current.oid;
369
+ });
370
+ this.invalidate();
371
+ await this._appendReflog('HEAD', before, await this.resolveToOid('HEAD'), reason);
372
+ }
373
+
374
+ // ─── writing ─────────────────────────────────────────
375
+
376
+ /**
377
+ * Compare-and-set a ref.
378
+ *
379
+ * @param {String} name
380
+ * @param {String} newOid
381
+ * @param {Object} [options]
382
+ * @param {String|null|undefined} [options.expectedOldOid]
383
+ * `undefined` = no check, `null` = must not exist,
384
+ * an oid = must currently be exactly that.
385
+ * @param {String} [options.reason] - reflog message
386
+ * @returns {Promise<{oldOid: String|null, newOid: String}>}
387
+ */
388
+ async update(name, newOid, options = {}) {
389
+ assertRefName(name, { allowOneLevel: true });
390
+ assertObjectId(newOid, `new value for ${name}`);
391
+
392
+ const result = await withLock(this.refPath(name), async (lock) => {
393
+ const current = await this._currentUnderLock(name, lock);
394
+ this._checkExpected(name, current, options.expectedOldOid);
395
+ await lock.write(`${newOid}\n`);
396
+ return current;
397
+ });
398
+
399
+ this.invalidate();
400
+ await this._appendReflog(name, result, newOid, options.reason || 'update');
401
+ await this._mirrorHeadReflog(name, result, newOid, options.reason);
402
+ return { oldOid: result, newOid };
403
+ }
404
+
405
+ /**
406
+ * Delete a ref from both the loose file and packed-refs.
407
+ * @param {String} name
408
+ * @param {Object} [options]
409
+ * @param {String|undefined} [options.expectedOldOid]
410
+ * @returns {Promise<String|null>} the value that was removed
411
+ */
412
+ async delete(name, options = {}) {
413
+ assertRefName(name, { allowOneLevel: true });
414
+
415
+ const lock = await Lock.acquire(this.refPath(name));
416
+ let current;
417
+ try {
418
+ current = await this._currentUnderLock(name, lock);
419
+ this._checkExpected(name, current, options.expectedOldOid);
420
+ await fs.rm(this.refPath(name), { force: true });
421
+ } finally {
422
+ await lock.release();
423
+ }
424
+
425
+ const packed = await this.packedRefs();
426
+ if (packed.has(name)) {
427
+ await withLock(path.join(this.commondir, 'packed-refs'), async (packedLock) => {
428
+ const fresh = new Map(await this.packedRefs());
429
+ fresh.delete(name);
430
+ await this._writePackedRefs(packedLock, fresh);
431
+ });
432
+ }
433
+
434
+ this.invalidate();
435
+ if (current) await this._appendReflog(name, current, NULL_OID, options.reason || 'delete');
436
+ return current;
437
+ }
438
+
439
+ /**
440
+ * Apply several ref updates, stopping before the first that fails its
441
+ * precondition. Not a filesystem transaction: earlier updates that already
442
+ * committed stay committed and are reported, so a caller can undo them.
443
+ *
444
+ * @param {Array<{name, newOid?, delete?, expectedOldOid?}>} updates
445
+ * @param {String} [reason]
446
+ * @returns {Promise<Array<{name, oldOid, newOid}>>} applied, in order
447
+ */
448
+ async updateMany(updates, reason) {
449
+ // Check every precondition first so the common case fails atomically.
450
+ for (const update of updates) {
451
+ const current = await this.readRef(update.name);
452
+ const currentOid = current && current.kind === 'oid' ? current.value : null;
453
+ this._checkExpected(update.name, currentOid, update.expectedOldOid);
454
+ }
455
+
456
+ const applied = [];
457
+ for (const update of updates) {
458
+ if (update.delete) {
459
+ const oldOid = await this.delete(update.name, { expectedOldOid: update.expectedOldOid, reason });
460
+ applied.push({ name: update.name, oldOid, newOid: null });
461
+ } else {
462
+ const result = await this.update(update.name, update.newOid, { expectedOldOid: update.expectedOldOid, reason });
463
+ applied.push({ name: update.name, ...result });
464
+ }
465
+ }
466
+ return applied;
467
+ }
468
+
469
+ /**
470
+ * Read the ref while its lock is held — the loose file may have been
471
+ * created between our earlier read and the lock.
472
+ * @param {String} name
473
+ * @param {Lock} lock
474
+ * @returns {Promise<String|null>}
475
+ */
476
+ async _currentUnderLock(name, lock) {
477
+ const raw = await lock.readTarget();
478
+ if (raw !== null) {
479
+ const text = raw.toString('utf-8').trim();
480
+ if (text.startsWith('ref:')) {
481
+ throw new RefError(`'${name}' is a symbolic ref; use setHeadSymbolic to change it`);
482
+ }
483
+ return assertObjectId(text, `current value of ${name}`);
484
+ }
485
+ this.invalidate();
486
+ const packed = (await this.packedRefs()).get(name);
487
+ return packed ? packed.oid : null;
488
+ }
489
+
490
+ /**
491
+ * @param {String} name
492
+ * @param {String|null} current
493
+ * @param {String|null|undefined} expected
494
+ */
495
+ _checkExpected(name, current, expected) {
496
+ if (expected === undefined) return;
497
+
498
+ if (expected === null || expected === NULL_OID) {
499
+ if (current !== null) {
500
+ throw new RefError(
501
+ `'${name}' already exists (${current.slice(0, 12)}) but was expected not to`,
502
+ 'GENT_REF_RACE'
503
+ );
504
+ }
505
+ return;
506
+ }
507
+
508
+ assertObjectId(expected, `expected old value of ${name}`);
509
+ if (current !== expected) {
510
+ throw new RefError(
511
+ `'${name}' is ${current ? current.slice(0, 12) : 'missing'}, not the expected ${expected.slice(0, 12)} — ` +
512
+ `it changed underneath this operation`,
513
+ 'GENT_REF_RACE'
514
+ );
515
+ }
516
+ }
517
+
518
+ // ─── reflog ──────────────────────────────────────────
519
+
520
+ /**
521
+ * @param {String} name
522
+ * @param {String|null} oldOid
523
+ * @param {String|null} newOid
524
+ * @param {String} message
525
+ */
526
+ async _appendReflog(name, oldOid, newOid, message) {
527
+ const identity = this.repo.reflogIdentity ? await this.repo.reflogIdentity() : null;
528
+ if (!identity) return; // no identity configured yet: skip rather than invent one
529
+
530
+ const line =
531
+ `${oldOid || NULL_OID} ${newOid || NULL_OID} ` +
532
+ `${identity.name} <${identity.email}> ${identity.timestamp} ${identity.timezone}\t` +
533
+ `${String(message).replace(/[\n\r]+/g, ' ')}\n`;
534
+
535
+ const target = this.reflogPath(name);
536
+ await fs.mkdir(path.dirname(target), { recursive: true });
537
+ await fs.appendFile(target, line, 'utf-8');
538
+ }
539
+
540
+ /**
541
+ * Git records branch movements in HEAD's reflog too, when HEAD points at
542
+ * the branch being moved.
543
+ */
544
+ async _mirrorHeadReflog(name, oldOid, newOid, reason) {
545
+ if (name === 'HEAD') return;
546
+ const entry = await this.readRef('HEAD');
547
+ if (entry && entry.kind === 'symbolic' && entry.value === name) {
548
+ await this._appendReflog('HEAD', oldOid, newOid, reason || 'update');
549
+ }
550
+ }
551
+
552
+ /**
553
+ * @param {String} name
554
+ * @returns {Promise<Array<{oldOid, newOid, name, email, timestamp, timezone, message}>>}
555
+ * newest last, matching the file order
556
+ */
557
+ async readReflog(name) {
558
+ const raw = await readFileOrNull(this.reflogPath(name));
559
+ if (!raw) return [];
560
+
561
+ const entries = [];
562
+ for (const line of raw.toString('utf-8').split('\n')) {
563
+ if (!line.trim()) continue;
564
+ const tab = line.indexOf('\t');
565
+ const head = tab < 0 ? line : line.slice(0, tab);
566
+ const message = tab < 0 ? '' : line.slice(tab + 1);
567
+
568
+ const open = head.indexOf('<');
569
+ const close = head.indexOf('>', open + 1);
570
+ if (open < 0 || close < 0) continue;
571
+
572
+ const [oldOid, newOid] = head.slice(0, open).trim().split(/\s+/);
573
+ const rest = head.slice(close + 1).trim().split(/\s+/);
574
+ entries.push({
575
+ oldOid,
576
+ newOid,
577
+ name: head.slice(head.indexOf(' ', head.indexOf(' ') + 1) + 1, open).trim(),
578
+ email: head.slice(open + 1, close),
579
+ timestamp: Number.parseInt(rest[0], 10) || 0,
580
+ timezone: rest[1] || '+0000',
581
+ message
582
+ });
583
+ }
584
+ return entries;
585
+ }
586
+ }
587
+
588
+ module.exports = {
589
+ RefStore,
590
+ RefError,
591
+ isValidRefName,
592
+ assertRefName,
593
+ isPerWorktreeRef,
594
+ PACKED_REFS_HEADER
595
+ };