gent-cli 14.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 +122 -98
- package/src/commands/pull.js +3 -4
- package/src/commands/push.js +4 -5
- 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,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
|
+
};
|