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.
@@ -0,0 +1,606 @@
1
+ /**
2
+ * ============================================================================
3
+ * Git Config - parser, accessor and format-preserving editor
4
+ * ============================================================================
5
+ *
6
+ * PURPOSE:
7
+ * Read and write Git's configuration syntax so Gent and Git can share one
8
+ * config file. Editing rewrites only the affected line: comments, ordering,
9
+ * indentation and every unrelated setting survive.
10
+ *
11
+ * SYNTAX SUPPORTED:
12
+ * [section] / [section "subsection"] / [section.subsection]
13
+ * key = value, bare key (boolean true), repeated keys (multi-valued),
14
+ * double-quoted values with \n \t \b \\ \" escapes, # and ; comments,
15
+ * trailing-backslash line continuation, include.path and includeIf.
16
+ *
17
+ * CASE RULES (Git's, not ours):
18
+ * Section and key names are case-insensitive; the subsection in
19
+ * [a "B"] is case-sensitive, the one in [a.B] is lowercased.
20
+ *
21
+ * See docs/git-compat/format-contract.md section 6 for what Gent stores here.
22
+ * ============================================================================
23
+ */
24
+
25
+ const fs = require('fs').promises;
26
+ const path = require('path');
27
+ const os = require('os');
28
+
29
+ const MAX_INCLUDE_DEPTH = 10;
30
+
31
+ class ConfigError extends Error {
32
+ constructor(message, file, line) {
33
+ super(`${file}:${line}: ${message}`);
34
+ this.name = 'ConfigError';
35
+ this.code = 'GENT_BAD_CONFIG';
36
+ this.file = file;
37
+ this.line = line;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Normalize "Section.Sub Section.Key" into its canonical lookup form.
43
+ * @param {String} name
44
+ * @returns {{section: String, subsection: String|null, key: String, full: String}}
45
+ */
46
+ function splitName(name) {
47
+ const first = name.indexOf('.');
48
+ const last = name.lastIndexOf('.');
49
+ if (first < 0) throw new Error(`config name '${name}' needs at least a section and a key`);
50
+
51
+ const section = name.slice(0, first).toLowerCase();
52
+ const key = name.slice(last + 1).toLowerCase();
53
+ const subsection = last > first ? name.slice(first + 1, last) : null;
54
+ return { section, subsection, key, full: canonicalName(section, subsection, key) };
55
+ }
56
+
57
+ /**
58
+ * @param {String} section
59
+ * @param {String|null} subsection
60
+ * @param {String} key
61
+ * @returns {String}
62
+ */
63
+ function canonicalName(section, subsection, key) {
64
+ return subsection === null
65
+ ? `${section}.${key}`
66
+ : `${section}.${subsection}.${key}`;
67
+ }
68
+
69
+ /**
70
+ * A single config file: the raw lines plus the entries pointing into them.
71
+ */
72
+ class ConfigFile {
73
+ /**
74
+ * @param {String} text
75
+ * @param {String} filePath
76
+ */
77
+ constructor(text, filePath) {
78
+ this.filePath = filePath;
79
+ this.lines = text.split('\n');
80
+ this.entries = []; // {section, subsection, key, value, lineStart, lineEnd}
81
+ this._parse();
82
+ }
83
+
84
+ /**
85
+ * @param {String} filePath
86
+ * @returns {Promise<ConfigFile|null>} null when the file does not exist
87
+ */
88
+ static async load(filePath) {
89
+ try {
90
+ return new ConfigFile(await fs.readFile(filePath, 'utf-8'), filePath);
91
+ } catch (error) {
92
+ if (error.code === 'ENOENT' || error.code === 'EACCES' || error.code === 'EISDIR') return null;
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ _parse() {
98
+ let section = null;
99
+ let subsection = null;
100
+
101
+ for (let i = 0; i < this.lines.length; i++) {
102
+ const raw = this.lines[i];
103
+ const trimmed = raw.trim();
104
+
105
+ if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith(';')) continue;
106
+
107
+ if (trimmed.startsWith('[')) {
108
+ const header = this._parseSectionHeader(trimmed, i);
109
+ section = header.section;
110
+ subsection = header.subsection;
111
+ continue;
112
+ }
113
+
114
+ if (section === null) {
115
+ throw new ConfigError(`key outside of any section: ${trimmed}`, this.filePath, i + 1);
116
+ }
117
+
118
+ // Gather continuation lines before parsing the value.
119
+ let lineEnd = i;
120
+ let joined = raw;
121
+ while (this._endsWithContinuation(joined) && lineEnd + 1 < this.lines.length) {
122
+ lineEnd += 1;
123
+ joined = joined.slice(0, joined.lastIndexOf('\\')) + this.lines[lineEnd];
124
+ }
125
+
126
+ const parsed = this._parseKeyValue(joined, i);
127
+ this.entries.push({
128
+ section,
129
+ subsection,
130
+ key: parsed.key,
131
+ value: parsed.value,
132
+ lineStart: i,
133
+ lineEnd
134
+ });
135
+ i = lineEnd;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * A trailing backslash escapes the newline unless it is itself escaped.
141
+ * @param {String} line
142
+ * @returns {Boolean}
143
+ */
144
+ _endsWithContinuation(line) {
145
+ let backslashes = 0;
146
+ for (let i = line.length - 1; i >= 0 && line[i] === '\\'; i--) backslashes++;
147
+ return backslashes % 2 === 1;
148
+ }
149
+
150
+ /**
151
+ * @param {String} line
152
+ * @param {Number} index
153
+ * @returns {{section: String, subsection: String|null}}
154
+ */
155
+ _parseSectionHeader(line, index) {
156
+ const close = line.lastIndexOf(']');
157
+ if (close < 0) throw new ConfigError('section header is not closed', this.filePath, index + 1);
158
+
159
+ const body = line.slice(1, close).trim();
160
+ const quote = body.indexOf('"');
161
+
162
+ if (quote >= 0) {
163
+ const name = body.slice(0, quote).trim();
164
+ if (!/^[A-Za-z0-9.-]+$/.test(name)) {
165
+ throw new ConfigError(`invalid section name '${name}'`, this.filePath, index + 1);
166
+ }
167
+ const endQuote = body.lastIndexOf('"');
168
+ if (endQuote <= quote) throw new ConfigError('subsection is not closed', this.filePath, index + 1);
169
+
170
+ let subsection = '';
171
+ for (let i = quote + 1; i < endQuote; i++) {
172
+ if (body[i] === '\\' && i + 1 < endQuote) {
173
+ i += 1;
174
+ subsection += body[i]; // \" and \\ only; others are literal
175
+ } else {
176
+ subsection += body[i];
177
+ }
178
+ }
179
+ return { section: name.toLowerCase(), subsection };
180
+ }
181
+
182
+ if (!/^[A-Za-z0-9.-]+$/.test(body)) {
183
+ throw new ConfigError(`invalid section name '${body}'`, this.filePath, index + 1);
184
+ }
185
+ const dot = body.indexOf('.');
186
+ return dot < 0
187
+ ? { section: body.toLowerCase(), subsection: null }
188
+ : { section: body.slice(0, dot).toLowerCase(), subsection: body.slice(dot + 1).toLowerCase() };
189
+ }
190
+
191
+ /**
192
+ * @param {String} line
193
+ * @param {Number} index
194
+ * @returns {{key: String, value: String}}
195
+ */
196
+ _parseKeyValue(line, index) {
197
+ const eq = this._findAssignment(line);
198
+ const keyText = (eq < 0 ? line : line.slice(0, eq)).trim();
199
+
200
+ if (!/^[A-Za-z][A-Za-z0-9-]*$/.test(keyText)) {
201
+ throw new ConfigError(`invalid key name '${keyText}'`, this.filePath, index + 1);
202
+ }
203
+ if (eq < 0) return { key: keyText.toLowerCase(), value: 'true' };
204
+
205
+ return { key: keyText.toLowerCase(), value: this._parseValue(line.slice(eq + 1), index) };
206
+ }
207
+
208
+ /**
209
+ * Index of the assignment '=' — the first one outside quotes.
210
+ * @param {String} line
211
+ * @returns {Number}
212
+ */
213
+ _findAssignment(line) {
214
+ let inQuotes = false;
215
+ for (let i = 0; i < line.length; i++) {
216
+ const ch = line[i];
217
+ if (ch === '\\') { i += 1; continue; }
218
+ if (ch === '"') { inQuotes = !inQuotes; continue; }
219
+ if (inQuotes) continue;
220
+ if (ch === '=') return i;
221
+ if (ch === '#' || ch === ';') return -1;
222
+ }
223
+ return -1;
224
+ }
225
+
226
+ /**
227
+ * @param {String} text - everything after '='
228
+ * @param {Number} index
229
+ * @returns {String}
230
+ */
231
+ _parseValue(text, index) {
232
+ let value = '';
233
+ let inQuotes = false;
234
+ let pendingSpace = '';
235
+ let started = false;
236
+
237
+ const ESCAPES = { n: '\n', t: '\t', b: '\b', '\\': '\\', '"': '"' };
238
+
239
+ for (let i = 0; i < text.length; i++) {
240
+ const ch = text[i];
241
+
242
+ if (ch === '\\') {
243
+ const next = text[i + 1];
244
+ if (next === undefined) throw new ConfigError('value ends with a lone backslash', this.filePath, index + 1);
245
+ i += 1;
246
+ if (!(next in ESCAPES)) throw new ConfigError(`invalid escape '\\${next}'`, this.filePath, index + 1);
247
+ value += pendingSpace + ESCAPES[next];
248
+ pendingSpace = '';
249
+ started = true;
250
+ continue;
251
+ }
252
+
253
+ if (ch === '"') { inQuotes = !inQuotes; started = true; continue; }
254
+
255
+ if (!inQuotes && (ch === '#' || ch === ';')) break;
256
+
257
+ if (!inQuotes && (ch === ' ' || ch === '\t')) {
258
+ if (started) pendingSpace += ch; // held back until a non-space follows
259
+ continue;
260
+ }
261
+
262
+ value += pendingSpace + ch;
263
+ pendingSpace = '';
264
+ started = true;
265
+ }
266
+
267
+ if (inQuotes) throw new ConfigError('unterminated quoted value', this.filePath, index + 1);
268
+ return value;
269
+ }
270
+
271
+ /**
272
+ * @param {String} name
273
+ * @returns {Array<String>}
274
+ */
275
+ getAll(name) {
276
+ const { section, subsection, key } = splitName(name);
277
+ return this.entries
278
+ .filter(e => e.section === section && e.key === key && (subsection === null ? e.subsection === null : e.subsection === subsection))
279
+ .map(e => e.value);
280
+ }
281
+
282
+ /**
283
+ * Every canonical name present, for `gent config --list`.
284
+ * @returns {Array<[String, String]>}
285
+ */
286
+ list() {
287
+ return this.entries.map(e => [canonicalName(e.section, e.subsection, e.key), e.value]);
288
+ }
289
+
290
+ /**
291
+ * Replace the last occurrence, or append into the right section.
292
+ * @param {String} name
293
+ * @param {String} value
294
+ */
295
+ set(name, value) {
296
+ const { section, subsection, key } = splitName(name);
297
+ const matches = this._match(section, subsection, key);
298
+
299
+ if (matches.length) {
300
+ const target = matches[matches.length - 1];
301
+ const indent = (this.lines[target.lineStart].match(/^[ \t]*/) || [''])[0];
302
+ this.lines.splice(target.lineStart, target.lineEnd - target.lineStart + 1, `${indent}${key} = ${quoteValue(value)}`);
303
+ } else {
304
+ this._appendInSection(section, subsection, key, value);
305
+ }
306
+ this._reparse();
307
+ }
308
+
309
+ /**
310
+ * Add a value without removing existing ones.
311
+ * @param {String} name
312
+ * @param {String} value
313
+ */
314
+ add(name, value) {
315
+ const { section, subsection, key } = splitName(name);
316
+ this._appendInSection(section, subsection, key, value);
317
+ this._reparse();
318
+ }
319
+
320
+ /**
321
+ * @param {String} name
322
+ * @returns {Number} how many entries were removed
323
+ */
324
+ unset(name) {
325
+ const { section, subsection, key } = splitName(name);
326
+ const matches = this._match(section, subsection, key);
327
+
328
+ for (const entry of [...matches].reverse()) {
329
+ this.lines.splice(entry.lineStart, entry.lineEnd - entry.lineStart + 1);
330
+ }
331
+ this._reparse();
332
+ return matches.length;
333
+ }
334
+
335
+ _match(section, subsection, key) {
336
+ return this.entries.filter(e =>
337
+ e.section === section && e.key === key &&
338
+ (subsection === null ? e.subsection === null : e.subsection === subsection));
339
+ }
340
+
341
+ _appendInSection(section, subsection, key, value) {
342
+ const line = `\t${key} = ${quoteValue(value)}`;
343
+
344
+ // Last non-blank line belonging to a matching section header.
345
+ let insertAt = -1;
346
+ let current = null;
347
+ for (let i = 0; i < this.lines.length; i++) {
348
+ const trimmed = this.lines[i].trim();
349
+ if (trimmed.startsWith('[')) {
350
+ current = this._parseSectionHeader(trimmed, i);
351
+ if (current.section === section &&
352
+ (subsection === null ? current.subsection === null : current.subsection === subsection)) {
353
+ insertAt = i;
354
+ }
355
+ continue;
356
+ }
357
+ if (trimmed && current && current.section === section &&
358
+ (subsection === null ? current.subsection === null : current.subsection === subsection)) {
359
+ insertAt = i;
360
+ }
361
+ }
362
+
363
+ if (insertAt >= 0) {
364
+ this.lines.splice(insertAt + 1, 0, line);
365
+ return;
366
+ }
367
+
368
+ const header = subsection === null ? `[${section}]` : `[${section} "${escapeSubsection(subsection)}"]`;
369
+ while (this.lines.length && this.lines[this.lines.length - 1].trim() === '') this.lines.pop();
370
+ this.lines.push(header, line, '');
371
+ }
372
+
373
+ _reparse() {
374
+ this.entries = [];
375
+ this._parse();
376
+ }
377
+
378
+ /**
379
+ * @returns {String}
380
+ */
381
+ toString() {
382
+ const text = this.lines.join('\n');
383
+ return text === '' || text.endsWith('\n') ? text : text + '\n';
384
+ }
385
+
386
+ /**
387
+ * Atomic write through a .lock file, as Git does.
388
+ * @returns {Promise<void>}
389
+ */
390
+ async save() {
391
+ const { withLock } = require('./lockfile');
392
+ await withLock(this.filePath, async (lock) => {
393
+ await lock.write(Buffer.from(this.toString(), 'utf-8'));
394
+ });
395
+ }
396
+ }
397
+
398
+ /**
399
+ * @param {String} value
400
+ * @returns {String} the value as it should appear after '='
401
+ */
402
+ function quoteValue(value) {
403
+ const text = String(value);
404
+ const needsQuotes = text === '' ||
405
+ /^[ \t]/.test(text) || /[ \t]$/.test(text) ||
406
+ /["#;\\\n\t\x08]/.test(text);
407
+
408
+ if (!needsQuotes) return text;
409
+ return '"' + text
410
+ .replace(/\\/g, '\\\\')
411
+ .replace(/"/g, '\\"')
412
+ .replace(/\n/g, '\\n')
413
+ .replace(/\t/g, '\\t')
414
+ .replace(/\x08/g, '\\b') + '"';
415
+ }
416
+
417
+ /**
418
+ * @param {String} subsection
419
+ * @returns {String}
420
+ */
421
+ function escapeSubsection(subsection) {
422
+ return subsection.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
423
+ }
424
+
425
+ /**
426
+ * A stack of config files: later files win, and getAll concatenates in order.
427
+ */
428
+ class ConfigSet {
429
+ /**
430
+ * @param {Array<ConfigFile>} files - lowest precedence first
431
+ */
432
+ constructor(files) {
433
+ this.files = files.filter(Boolean);
434
+ }
435
+
436
+ /**
437
+ * @param {String} name
438
+ * @returns {Array<String>}
439
+ */
440
+ getAll(name) {
441
+ return this.files.flatMap(file => file.getAll(name));
442
+ }
443
+
444
+ /**
445
+ * @param {String} name
446
+ * @param {String} [fallback]
447
+ * @returns {String|undefined}
448
+ */
449
+ get(name, fallback) {
450
+ const all = this.getAll(name);
451
+ return all.length ? all[all.length - 1] : fallback;
452
+ }
453
+
454
+ /**
455
+ * @param {String} name
456
+ * @param {Boolean} [fallback]
457
+ * @returns {Boolean}
458
+ */
459
+ getBoolean(name, fallback = false) {
460
+ const value = this.get(name);
461
+ if (value === undefined) return fallback;
462
+ const lower = value.toLowerCase();
463
+ if (['true', 'yes', 'on', '1'].includes(lower)) return true;
464
+ if (['false', 'no', 'off', '0', ''].includes(lower)) return false;
465
+ throw new Error(`config ${name}='${value}' is not a boolean`);
466
+ }
467
+
468
+ /**
469
+ * @param {String} name
470
+ * @param {Number} [fallback]
471
+ * @returns {Number}
472
+ */
473
+ getInt(name, fallback = 0) {
474
+ const value = this.get(name);
475
+ if (value === undefined) return fallback;
476
+ const match = /^([+-]?[0-9]+)([kKmMgG]?)$/.exec(value.trim());
477
+ if (!match) throw new Error(`config ${name}='${value}' is not an integer`);
478
+ const scale = { '': 1, k: 1024, m: 1024 ** 2, g: 1024 ** 3 }[match[2].toLowerCase()];
479
+ return Number.parseInt(match[1], 10) * scale;
480
+ }
481
+
482
+ /**
483
+ * Distinct subsection names, e.g. every configured remote.
484
+ * @param {String} section
485
+ * @returns {Array<String>}
486
+ */
487
+ subsections(section) {
488
+ const wanted = section.toLowerCase();
489
+ const names = new Set();
490
+ for (const file of this.files) {
491
+ for (const entry of file.entries) {
492
+ if (entry.section === wanted && entry.subsection !== null) names.add(entry.subsection);
493
+ }
494
+ }
495
+ return [...names];
496
+ }
497
+
498
+ /**
499
+ * @returns {Array<[String, String]>}
500
+ */
501
+ list() {
502
+ return this.files.flatMap(file => file.list());
503
+ }
504
+ }
505
+
506
+ /**
507
+ * Resolve include.path / includeIf.*.path relative to the including file.
508
+ * @param {ConfigFile} file
509
+ * @param {Object} context - { gitdir, worktree, branch }
510
+ * @param {Number} depth
511
+ * @returns {Promise<Array<ConfigFile>>} the file, then its includes, in order
512
+ */
513
+ async function expandIncludes(file, context, depth = 0) {
514
+ if (!file) return [];
515
+ if (depth >= MAX_INCLUDE_DEPTH) return [file];
516
+
517
+ const result = [file];
518
+ for (const entry of file.entries) {
519
+ let include = null;
520
+ if (entry.section === 'include' && entry.key === 'path') {
521
+ include = entry.value;
522
+ } else if (entry.section === 'includeif' && entry.key === 'path' && entry.subsection !== null) {
523
+ if (matchesIncludeCondition(entry.subsection, context, file.filePath)) include = entry.value;
524
+ }
525
+ if (!include) continue;
526
+
527
+ const resolved = expandTilde(include);
528
+ const absolute = path.isAbsolute(resolved) ? resolved : path.resolve(path.dirname(file.filePath), resolved);
529
+ const included = await ConfigFile.load(absolute);
530
+ if (included) result.push(...await expandIncludes(included, context, depth + 1));
531
+ }
532
+ return result;
533
+ }
534
+
535
+ /**
536
+ * @param {String} condition - e.g. gitdir:~/work/, onbranch:main
537
+ * @param {Object} context
538
+ * @param {String} fromPath
539
+ * @returns {Boolean}
540
+ */
541
+ function matchesIncludeCondition(condition, context, fromPath) {
542
+ const colon = condition.indexOf(':');
543
+ if (colon < 0) return false;
544
+
545
+ const kind = condition.slice(0, colon).toLowerCase();
546
+ let pattern = condition.slice(colon + 1);
547
+
548
+ if (kind === 'onbranch') {
549
+ return Boolean(context.branch) && globMatch(pattern.replace(/\/$/, ''), context.branch);
550
+ }
551
+ if (kind !== 'gitdir' && kind !== 'gitdir/i') return false;
552
+ if (!context.gitdir) return false;
553
+
554
+ pattern = expandTilde(pattern);
555
+ if (pattern.startsWith('./')) pattern = path.resolve(path.dirname(fromPath), pattern);
556
+ if (pattern.endsWith('/')) pattern += '**';
557
+ if (!pattern.startsWith('/') && !pattern.startsWith('**')) pattern = '**/' + pattern;
558
+
559
+ const subject = kind === 'gitdir/i' ? context.gitdir.toLowerCase() : context.gitdir;
560
+ const target = kind === 'gitdir/i' ? pattern.toLowerCase() : pattern;
561
+ return globMatch(target, subject);
562
+ }
563
+
564
+ /**
565
+ * Minimal fnmatch with '**' spanning separators, used only for include
566
+ * conditions. The worktree matcher in ignore.js is the full implementation.
567
+ * @param {String} pattern
568
+ * @param {String} subject
569
+ * @returns {Boolean}
570
+ */
571
+ function globMatch(pattern, subject) {
572
+ let source = '';
573
+ for (let i = 0; i < pattern.length; i++) {
574
+ const ch = pattern[i];
575
+ if (ch === '*' && pattern[i + 1] === '*') {
576
+ source += '.*';
577
+ i += 1;
578
+ continue;
579
+ }
580
+ if (ch === '*') { source += '[^/]*'; continue; }
581
+ if (ch === '?') { source += '[^/]'; continue; }
582
+ source += /[.+^${}()|[\]\\]/.test(ch) ? '\\' + ch : ch;
583
+ }
584
+ return new RegExp(`^${source}$`).test(subject);
585
+ }
586
+
587
+ /**
588
+ * @param {String} value
589
+ * @returns {String}
590
+ */
591
+ function expandTilde(value) {
592
+ if (value === '~') return os.homedir();
593
+ if (value.startsWith('~/')) return path.join(os.homedir(), value.slice(2));
594
+ return value;
595
+ }
596
+
597
+ module.exports = {
598
+ ConfigFile,
599
+ ConfigSet,
600
+ ConfigError,
601
+ expandIncludes,
602
+ splitName,
603
+ canonicalName,
604
+ quoteValue,
605
+ expandTilde
606
+ };