@kirchdev/gitignore-sync 0.1.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/LICENSE +21 -0
- package/README.md +179 -0
- package/action.yml +63 -0
- package/dist/bin/gitignore-sync.mjs +1539 -0
- package/dist/bin/gitignore-sync.mjs.map +1 -0
- package/package.json +85 -0
|
@@ -0,0 +1,1539 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { defineCommand, runMain } from "citty";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import consola from "consola";
|
|
5
|
+
import { colors } from "consola/utils";
|
|
6
|
+
import { basename, dirname, isAbsolute, join, resolve } from "pathe";
|
|
7
|
+
//#region src/gitignore/markers.ts
|
|
8
|
+
/**
|
|
9
|
+
* `# region` / `# endregion` rather than `# start` / `# end` for one concrete
|
|
10
|
+
* reader: VSCode folds `#region`, so a 40-line managed block collapses to a
|
|
11
|
+
* single line — and the nested form folds at both levels.
|
|
12
|
+
*/
|
|
13
|
+
var REGION_NAME = "gitignore-sync";
|
|
14
|
+
/** `# region gitignore-sync` — the outer marker, matched leniently on spacing. */
|
|
15
|
+
var OUTER_START = new RegExp(`^#\\s*region\\s+${REGION_NAME}\\s*$`);
|
|
16
|
+
/** `# region node@v1` — a section marker. */
|
|
17
|
+
var SECTION_START = /^#\s*region\s+([A-Za-z0-9][\w.-]*)@v(\d+)\s*$/;
|
|
18
|
+
/** Any region opener, whichever level it belongs to. */
|
|
19
|
+
var ANY_START = /^#\s*region\b/;
|
|
20
|
+
var ANY_END = /^#\s*endregion\s*$/;
|
|
21
|
+
var isOuterStart = (line) => OUTER_START.test(line);
|
|
22
|
+
var isAnyStart = (line) => ANY_START.test(line);
|
|
23
|
+
var isEnd = (line) => ANY_END.test(line);
|
|
24
|
+
function matchSectionStart(line) {
|
|
25
|
+
const m = SECTION_START.exec(line);
|
|
26
|
+
if (!m?.[1] || !m[2]) return void 0;
|
|
27
|
+
return {
|
|
28
|
+
stack: m[1],
|
|
29
|
+
version: Number(m[2])
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
var outerStartLine = () => `# region ${REGION_NAME}`;
|
|
33
|
+
var sectionStartLine = (stack, version) => `# region ${stack}@v${version}`;
|
|
34
|
+
var endLine = () => "# endregion";
|
|
35
|
+
/** `# stacks: core, node` — the declaration the whole tool reads. */
|
|
36
|
+
var STACKS = /^#\s*stacks:\s*(.*)$/;
|
|
37
|
+
function matchStacks(line) {
|
|
38
|
+
const m = STACKS.exec(line);
|
|
39
|
+
if (!m) return void 0;
|
|
40
|
+
return m[1] ? m[1].split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
41
|
+
}
|
|
42
|
+
var stacksLine = (stacks) => `# stacks: ${stacks.join(", ")}`;
|
|
43
|
+
/** Cosmetic rule under the header; regenerated on every render. */
|
|
44
|
+
var DIVIDER = `# ${"─".repeat(41)}`;
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/gitignore/types.ts
|
|
47
|
+
var GitignoreParseError = class extends Error {
|
|
48
|
+
line;
|
|
49
|
+
constructor(message, line) {
|
|
50
|
+
super(`${message} (line ${line})`);
|
|
51
|
+
this.name = "GitignoreParseError";
|
|
52
|
+
this.line = line;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
//#endregion
|
|
56
|
+
//#region src/gitignore/parse.ts
|
|
57
|
+
var isBlank$1 = (line) => line.trim() === "";
|
|
58
|
+
/**
|
|
59
|
+
* Split text into lines, dropping the single trailing empty string a
|
|
60
|
+
* newline-terminated file produces. Touches no filesystem: string in, Document
|
|
61
|
+
* out.
|
|
62
|
+
*/
|
|
63
|
+
function toLines(text) {
|
|
64
|
+
const lines = text.split("\n");
|
|
65
|
+
if (lines.at(-1) === "") lines.pop();
|
|
66
|
+
return lines.map((l) => l.endsWith("\r") ? l.slice(0, -1) : l);
|
|
67
|
+
}
|
|
68
|
+
function trimBlankEdges(lines) {
|
|
69
|
+
let start = 0;
|
|
70
|
+
let end = lines.length;
|
|
71
|
+
while (start < end && isBlank$1(lines[start] ?? "")) start++;
|
|
72
|
+
while (end > start && isBlank$1(lines[end - 1] ?? "")) end--;
|
|
73
|
+
return lines.slice(start, end);
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Recognise the header, the sections and the free zone.
|
|
77
|
+
*
|
|
78
|
+
* A file with no managed region parses as pure free zone — that is what `init`
|
|
79
|
+
* sees on its first run.
|
|
80
|
+
*/
|
|
81
|
+
function parse(text) {
|
|
82
|
+
const lines = toLines(text);
|
|
83
|
+
const outerStart = lines.findIndex(isOuterStart);
|
|
84
|
+
if (outerStart === -1) return {
|
|
85
|
+
header: [],
|
|
86
|
+
sections: [],
|
|
87
|
+
freeZone: trimBlankEdges(lines),
|
|
88
|
+
hasRegion: false
|
|
89
|
+
};
|
|
90
|
+
const header = [];
|
|
91
|
+
const sections = [];
|
|
92
|
+
/** Non-marker lines found at region level after the first section: strays. */
|
|
93
|
+
const strays = [];
|
|
94
|
+
let depth = 1;
|
|
95
|
+
let index = outerStart + 1;
|
|
96
|
+
let outerEnd = -1;
|
|
97
|
+
let open;
|
|
98
|
+
/** A line the region holds but no marker of ours claims. */
|
|
99
|
+
const keep = (line) => {
|
|
100
|
+
if (open) open.lines.push(line);
|
|
101
|
+
else if (sections.length === 0) header.push(line);
|
|
102
|
+
else if (!isBlank$1(line)) strays.push(line);
|
|
103
|
+
};
|
|
104
|
+
for (; index < lines.length; index++) {
|
|
105
|
+
const line = lines[index] ?? "";
|
|
106
|
+
if (isEnd(line)) {
|
|
107
|
+
const next = depth - 1;
|
|
108
|
+
if (next === 0) {
|
|
109
|
+
outerEnd = index;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
depth = next;
|
|
113
|
+
if (next === 1 && open) {
|
|
114
|
+
sections.push(open);
|
|
115
|
+
open = void 0;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
keep(line);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (isAnyStart(line)) {
|
|
122
|
+
const section = matchSectionStart(line);
|
|
123
|
+
depth++;
|
|
124
|
+
if (depth === 2 && section && !open) {
|
|
125
|
+
open = {
|
|
126
|
+
stack: section.stack,
|
|
127
|
+
version: section.version,
|
|
128
|
+
lines: []
|
|
129
|
+
};
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
keep(line);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
keep(line);
|
|
136
|
+
}
|
|
137
|
+
if (outerEnd === -1) throw new GitignoreParseError(`unterminated '# region gitignore-sync' — no matching '# endregion'`, outerStart + 1);
|
|
138
|
+
const before = lines.slice(0, outerStart);
|
|
139
|
+
const after = lines.slice(outerEnd + 1);
|
|
140
|
+
for (const section of sections) section.lines = trimBlankEdges(section.lines);
|
|
141
|
+
return {
|
|
142
|
+
header: trimBlankEdges(header),
|
|
143
|
+
sections,
|
|
144
|
+
freeZone: [
|
|
145
|
+
...trimBlankEdges(before),
|
|
146
|
+
...trimBlankEdges(after),
|
|
147
|
+
...strays
|
|
148
|
+
],
|
|
149
|
+
hasRegion: true
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/** The stacks the header declares — the input side of the document. */
|
|
153
|
+
function readStacks(doc) {
|
|
154
|
+
for (const line of doc.header) {
|
|
155
|
+
const stacks = matchStacks(line);
|
|
156
|
+
if (stacks) return stacks;
|
|
157
|
+
}
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A copy of the document with a new `# stacks:` declaration. The header is the
|
|
162
|
+
* only thing `add` and `remove` touch — the sections follow from it on the next
|
|
163
|
+
* reconcile, which is the whole point of the header being input.
|
|
164
|
+
*/
|
|
165
|
+
function withStacks(doc, stacks) {
|
|
166
|
+
const line = stacksLine(stacks);
|
|
167
|
+
let replaced = false;
|
|
168
|
+
const header = doc.header.map((l) => {
|
|
169
|
+
if (replaced || matchStacks(l) === void 0) return l;
|
|
170
|
+
replaced = true;
|
|
171
|
+
return line;
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
...doc,
|
|
175
|
+
header: replaced ? header : [line, ...header]
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
//#endregion
|
|
179
|
+
//#region src/templates/index.ts
|
|
180
|
+
/**
|
|
181
|
+
* Curated blocks, versioned with the binary. Data, not code.
|
|
182
|
+
*
|
|
183
|
+
* Every version a stack has ever shipped is kept, ascending. Reconciling reads
|
|
184
|
+
* the version the section marker names, so upgrading `node@v1` to `node@v2`
|
|
185
|
+
* knows which of the block's lines it put there itself and which the user
|
|
186
|
+
* added — without that history an upgrade would "rescue" its own dropped lines
|
|
187
|
+
* into the free zone.
|
|
188
|
+
*
|
|
189
|
+
* **No line may appear in two stacks.** A repo declaring both would otherwise
|
|
190
|
+
* render it twice, and the equivalence report would nag about a collision the
|
|
191
|
+
* tool created itself. `tests/templates.test.ts` enforces this.
|
|
192
|
+
*/
|
|
193
|
+
var registry = {
|
|
194
|
+
core: [{
|
|
195
|
+
stack: "core",
|
|
196
|
+
version: 1,
|
|
197
|
+
lines: [".DS_Store", ".claude/settings.local.json"]
|
|
198
|
+
}],
|
|
199
|
+
git: [{
|
|
200
|
+
stack: "git",
|
|
201
|
+
version: 1,
|
|
202
|
+
lines: [
|
|
203
|
+
"*.orig",
|
|
204
|
+
"*.rej",
|
|
205
|
+
"*.BACKUP.*",
|
|
206
|
+
"*.BASE.*",
|
|
207
|
+
"*.LOCAL.*",
|
|
208
|
+
"*.REMOTE.*",
|
|
209
|
+
"*_BACKUP_*.txt",
|
|
210
|
+
"*_BASE_*.txt",
|
|
211
|
+
"*_LOCAL_*.txt",
|
|
212
|
+
"*_REMOTE_*.txt"
|
|
213
|
+
]
|
|
214
|
+
}],
|
|
215
|
+
node: [{
|
|
216
|
+
stack: "node",
|
|
217
|
+
version: 1,
|
|
218
|
+
lines: [
|
|
219
|
+
"node_modules",
|
|
220
|
+
"dist",
|
|
221
|
+
"coverage",
|
|
222
|
+
"logs",
|
|
223
|
+
"*.log",
|
|
224
|
+
"*.tsbuildinfo",
|
|
225
|
+
".eslintcache",
|
|
226
|
+
".npm",
|
|
227
|
+
"*.tgz"
|
|
228
|
+
]
|
|
229
|
+
}],
|
|
230
|
+
go: [{
|
|
231
|
+
stack: "go",
|
|
232
|
+
version: 1,
|
|
233
|
+
lines: [
|
|
234
|
+
"*.exe",
|
|
235
|
+
"*.test",
|
|
236
|
+
"*.out",
|
|
237
|
+
"*.prof"
|
|
238
|
+
]
|
|
239
|
+
}],
|
|
240
|
+
tofu: [{
|
|
241
|
+
stack: "tofu",
|
|
242
|
+
version: 1,
|
|
243
|
+
lines: [
|
|
244
|
+
".terraform/",
|
|
245
|
+
"*.tfstate",
|
|
246
|
+
"*.tfstate.*",
|
|
247
|
+
"*.tfvars",
|
|
248
|
+
"!*.tfvars.example",
|
|
249
|
+
"override.tf",
|
|
250
|
+
"override.tf.json",
|
|
251
|
+
".terraformrc",
|
|
252
|
+
"terraform.rc",
|
|
253
|
+
"crash.log",
|
|
254
|
+
"crash.*.log"
|
|
255
|
+
]
|
|
256
|
+
}],
|
|
257
|
+
php: [{
|
|
258
|
+
stack: "php",
|
|
259
|
+
version: 1,
|
|
260
|
+
lines: [
|
|
261
|
+
"/vendor",
|
|
262
|
+
"/.phpunit.cache",
|
|
263
|
+
"/.phpunit.result.cache"
|
|
264
|
+
]
|
|
265
|
+
}],
|
|
266
|
+
laravel: [{
|
|
267
|
+
stack: "laravel",
|
|
268
|
+
version: 1,
|
|
269
|
+
lines: [
|
|
270
|
+
"/public/build",
|
|
271
|
+
"/public/hot",
|
|
272
|
+
"/public/storage",
|
|
273
|
+
"/storage/*.key",
|
|
274
|
+
"/storage/pail",
|
|
275
|
+
"/bootstrap/ssr",
|
|
276
|
+
"_ide_helper.php",
|
|
277
|
+
"_ide_helper_models.php",
|
|
278
|
+
".phpstorm.meta.php"
|
|
279
|
+
]
|
|
280
|
+
}],
|
|
281
|
+
turborepo: [{
|
|
282
|
+
stack: "turborepo",
|
|
283
|
+
version: 1,
|
|
284
|
+
lines: [".turbo"]
|
|
285
|
+
}],
|
|
286
|
+
rust: [{
|
|
287
|
+
stack: "rust",
|
|
288
|
+
version: 1,
|
|
289
|
+
lines: ["/target", "**/*.rs.bk"]
|
|
290
|
+
}],
|
|
291
|
+
playwright: [{
|
|
292
|
+
stack: "playwright",
|
|
293
|
+
version: 1,
|
|
294
|
+
lines: [
|
|
295
|
+
"test-results/",
|
|
296
|
+
"playwright-report/",
|
|
297
|
+
"blob-report/",
|
|
298
|
+
".last-run.json"
|
|
299
|
+
]
|
|
300
|
+
}],
|
|
301
|
+
storybook: [{
|
|
302
|
+
stack: "storybook",
|
|
303
|
+
version: 1,
|
|
304
|
+
lines: ["storybook-static"]
|
|
305
|
+
}],
|
|
306
|
+
nuxt: [{
|
|
307
|
+
stack: "nuxt",
|
|
308
|
+
version: 1,
|
|
309
|
+
lines: [
|
|
310
|
+
".nuxt",
|
|
311
|
+
".output",
|
|
312
|
+
".nitro",
|
|
313
|
+
".data"
|
|
314
|
+
]
|
|
315
|
+
}],
|
|
316
|
+
tauri: [{
|
|
317
|
+
stack: "tauri",
|
|
318
|
+
version: 1,
|
|
319
|
+
lines: ["src-tauri/target", "src-tauri/gen/schemas"]
|
|
320
|
+
}],
|
|
321
|
+
dotenv: [{
|
|
322
|
+
stack: "dotenv",
|
|
323
|
+
version: 1,
|
|
324
|
+
lines: [
|
|
325
|
+
".env",
|
|
326
|
+
".env.*",
|
|
327
|
+
"!.env.example",
|
|
328
|
+
"!.env.*.example"
|
|
329
|
+
]
|
|
330
|
+
}],
|
|
331
|
+
vscode: [{
|
|
332
|
+
stack: "vscode",
|
|
333
|
+
version: 1,
|
|
334
|
+
lines: [
|
|
335
|
+
".vscode/*",
|
|
336
|
+
"!.vscode/extensions.json",
|
|
337
|
+
"!.vscode/settings.json",
|
|
338
|
+
"!.vscode/mcp.json"
|
|
339
|
+
]
|
|
340
|
+
}],
|
|
341
|
+
intellij: [{
|
|
342
|
+
stack: "intellij",
|
|
343
|
+
version: 1,
|
|
344
|
+
lines: [".idea/*"]
|
|
345
|
+
}],
|
|
346
|
+
macos: [{
|
|
347
|
+
stack: "macos",
|
|
348
|
+
version: 1,
|
|
349
|
+
lines: [
|
|
350
|
+
".AppleDouble",
|
|
351
|
+
".LSOverride",
|
|
352
|
+
"._*",
|
|
353
|
+
".Spotlight-V100",
|
|
354
|
+
".Trashes",
|
|
355
|
+
".DocumentRevisions-V100",
|
|
356
|
+
".fseventsd",
|
|
357
|
+
".TemporaryItems",
|
|
358
|
+
".VolumeIcon.icns",
|
|
359
|
+
".com.apple.timemachine.donotpresent",
|
|
360
|
+
".AppleDB",
|
|
361
|
+
".AppleDesktop",
|
|
362
|
+
"Network Trash Folder",
|
|
363
|
+
"Temporary Items",
|
|
364
|
+
".apdisk",
|
|
365
|
+
"*.icloud"
|
|
366
|
+
]
|
|
367
|
+
}],
|
|
368
|
+
windows: [{
|
|
369
|
+
stack: "windows",
|
|
370
|
+
version: 1,
|
|
371
|
+
lines: [
|
|
372
|
+
"Thumbs.db",
|
|
373
|
+
"Thumbs.db:encryptable",
|
|
374
|
+
"ehthumbs.db",
|
|
375
|
+
"ehthumbs_vista.db",
|
|
376
|
+
"[Dd]esktop.ini",
|
|
377
|
+
"$RECYCLE.BIN/",
|
|
378
|
+
"*.stackdump",
|
|
379
|
+
"*.cab",
|
|
380
|
+
"*.msi",
|
|
381
|
+
"*.msix",
|
|
382
|
+
"*.msm",
|
|
383
|
+
"*.msp",
|
|
384
|
+
"*.lnk"
|
|
385
|
+
]
|
|
386
|
+
}],
|
|
387
|
+
vim: [{
|
|
388
|
+
stack: "vim",
|
|
389
|
+
version: 1,
|
|
390
|
+
lines: [
|
|
391
|
+
"*.swp",
|
|
392
|
+
"*.swo",
|
|
393
|
+
"*.swn",
|
|
394
|
+
"Session.vim",
|
|
395
|
+
".netrwhist"
|
|
396
|
+
]
|
|
397
|
+
}],
|
|
398
|
+
linux: [{
|
|
399
|
+
stack: "linux",
|
|
400
|
+
version: 1,
|
|
401
|
+
lines: [
|
|
402
|
+
"*~",
|
|
403
|
+
".fuse_hidden*",
|
|
404
|
+
".directory",
|
|
405
|
+
".Trash-*",
|
|
406
|
+
".nfs*"
|
|
407
|
+
]
|
|
408
|
+
}]
|
|
409
|
+
};
|
|
410
|
+
/**
|
|
411
|
+
* Registry insertion order, which is the order `init` writes into the header:
|
|
412
|
+
* `core` first, then repository stacks, then the person-level ones. Stable, so
|
|
413
|
+
* the `# stacks:` line does not churn between runs.
|
|
414
|
+
*/
|
|
415
|
+
var stackOrder = () => Object.keys(registry);
|
|
416
|
+
var knownStacks = () => Object.keys(registry).sort();
|
|
417
|
+
var isKnownStack = (stack) => stack in registry;
|
|
418
|
+
/** The version a fresh render of this stack produces. */
|
|
419
|
+
function currentTemplate(stack) {
|
|
420
|
+
return registry[stack]?.at(-1);
|
|
421
|
+
}
|
|
422
|
+
/** A specific past version, for working out what a section put there itself. */
|
|
423
|
+
function templateAt(stack, version) {
|
|
424
|
+
return registry[stack]?.find((t) => t.version === version);
|
|
425
|
+
}
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/gitignore/reconcile.ts
|
|
428
|
+
var isBlank = (line) => line.trim() === "";
|
|
429
|
+
var isComment = (line) => line.trimStart().startsWith("#");
|
|
430
|
+
/** Free-zone lines grouped the way a human reads them: blank lines separate. */
|
|
431
|
+
function splitBlocks(lines) {
|
|
432
|
+
const blocks = [];
|
|
433
|
+
let current = [];
|
|
434
|
+
for (const line of lines) {
|
|
435
|
+
if (isBlank(line)) {
|
|
436
|
+
if (current.length > 0) blocks.push(current);
|
|
437
|
+
current = [];
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
current.push(line);
|
|
441
|
+
}
|
|
442
|
+
if (current.length > 0) blocks.push(current);
|
|
443
|
+
return blocks;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* A human-eye normalisation, used for **reporting only**. `/.idea` anchors to
|
|
447
|
+
* the repo root and `.idea/` matches directories only, so collapsing them would
|
|
448
|
+
* change what git ignores — hence never a merge, only a note.
|
|
449
|
+
*/
|
|
450
|
+
function equivalenceKey(line) {
|
|
451
|
+
let key = line.trim();
|
|
452
|
+
const negated = key.startsWith("!");
|
|
453
|
+
if (negated) key = key.slice(1);
|
|
454
|
+
key = key.replace(/^\/+/, "").replace(/\/\*{1,2}$/, "").replace(/\/+$/, "");
|
|
455
|
+
return negated ? `!${key}` : key;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Bring a document in line with the templates its header declares.
|
|
459
|
+
*
|
|
460
|
+
* Pure: a Document in, a Document out. The rule that makes running this twice
|
|
461
|
+
* safe lives here — a line inside a managed section that no template version
|
|
462
|
+
* put there is moved to the free zone, never deleted.
|
|
463
|
+
*/
|
|
464
|
+
function reconcile(doc) {
|
|
465
|
+
const declared = readStacks(doc);
|
|
466
|
+
const unknownStacks = declared.filter((s) => !isKnownStack(s));
|
|
467
|
+
const rescued = [];
|
|
468
|
+
const staleSections = [];
|
|
469
|
+
for (const section of doc.sections) {
|
|
470
|
+
const shipped = templateAt(section.stack, section.version) ?? currentTemplate(section.stack);
|
|
471
|
+
if (shipped) {
|
|
472
|
+
const known = new Set(shipped.lines);
|
|
473
|
+
for (const line of section.lines) {
|
|
474
|
+
if (isBlank(line)) continue;
|
|
475
|
+
if (!known.has(line)) rescued.push(line);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const current = currentTemplate(section.stack);
|
|
479
|
+
if (current && current.version !== section.version) staleSections.push({
|
|
480
|
+
stack: section.stack,
|
|
481
|
+
from: section.version,
|
|
482
|
+
to: current.version
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
const sections = [];
|
|
486
|
+
for (const stack of declared) {
|
|
487
|
+
const template = currentTemplate(stack);
|
|
488
|
+
if (template) {
|
|
489
|
+
sections.push({
|
|
490
|
+
stack: template.stack,
|
|
491
|
+
version: template.version,
|
|
492
|
+
lines: [...template.lines]
|
|
493
|
+
});
|
|
494
|
+
continue;
|
|
495
|
+
}
|
|
496
|
+
const existing = doc.sections.find((s) => s.stack === stack);
|
|
497
|
+
if (existing) sections.push(existing);
|
|
498
|
+
}
|
|
499
|
+
const managed = new Set(sections.flatMap((s) => s.lines));
|
|
500
|
+
const duplicates = [];
|
|
501
|
+
const covered = [];
|
|
502
|
+
const orphanedComments = [];
|
|
503
|
+
const seen = /* @__PURE__ */ new Set();
|
|
504
|
+
const freeZone = [];
|
|
505
|
+
for (const block of splitBlocks([...doc.freeZone, ...rescued])) {
|
|
506
|
+
const kept = [];
|
|
507
|
+
let hadPattern = false;
|
|
508
|
+
let keptPattern = false;
|
|
509
|
+
for (const line of block) {
|
|
510
|
+
if (isComment(line)) {
|
|
511
|
+
kept.push(line);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
hadPattern = true;
|
|
515
|
+
if (managed.has(line)) {
|
|
516
|
+
covered.push(line);
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (seen.has(line)) {
|
|
520
|
+
duplicates.push(line);
|
|
521
|
+
continue;
|
|
522
|
+
}
|
|
523
|
+
seen.add(line);
|
|
524
|
+
keptPattern = true;
|
|
525
|
+
kept.push(line);
|
|
526
|
+
}
|
|
527
|
+
if (hadPattern && !keptPattern) {
|
|
528
|
+
orphanedComments.push(...kept);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (kept.length > 0) {
|
|
532
|
+
if (freeZone.length > 0) freeZone.push("");
|
|
533
|
+
freeZone.push(...kept);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const smothered = [];
|
|
537
|
+
const negations = [...managed].filter((line) => line.startsWith("!"));
|
|
538
|
+
for (const line of freeZone) {
|
|
539
|
+
if (isBlank(line) || isComment(line) || line.startsWith("!")) continue;
|
|
540
|
+
const dir = line.trim().replace(/^\/+/, "").replace(/\/+$/, "");
|
|
541
|
+
if (dir === "" || dir.includes("*")) continue;
|
|
542
|
+
const exceptions = negations.filter((n) => n.slice(1).replace(/^\/+/, "").startsWith(`${dir}/`));
|
|
543
|
+
if (exceptions.length > 0) smothered.push({
|
|
544
|
+
line,
|
|
545
|
+
exceptions
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
549
|
+
for (const line of [...managed, ...freeZone]) {
|
|
550
|
+
if (isBlank(line) || isComment(line)) continue;
|
|
551
|
+
const key = equivalenceKey(line);
|
|
552
|
+
const spellings = byKey.get(key);
|
|
553
|
+
if (spellings) {
|
|
554
|
+
if (!spellings.includes(line)) spellings.push(line);
|
|
555
|
+
} else byKey.set(key, [line]);
|
|
556
|
+
}
|
|
557
|
+
const smotheredLines = new Set(smothered.map((s) => s.line));
|
|
558
|
+
const equivalences = [...byKey].map(([key, spellings]) => ({
|
|
559
|
+
key,
|
|
560
|
+
spellings: spellings.filter((s) => !smotheredLines.has(s))
|
|
561
|
+
})).filter(({ spellings }) => spellings.length > 1);
|
|
562
|
+
return {
|
|
563
|
+
document: {
|
|
564
|
+
header: doc.header,
|
|
565
|
+
sections,
|
|
566
|
+
freeZone,
|
|
567
|
+
hasRegion: doc.hasRegion
|
|
568
|
+
},
|
|
569
|
+
rescued,
|
|
570
|
+
duplicates,
|
|
571
|
+
covered,
|
|
572
|
+
orphanedComments,
|
|
573
|
+
smothered,
|
|
574
|
+
equivalences,
|
|
575
|
+
unknownStacks,
|
|
576
|
+
staleSections
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
//#endregion
|
|
580
|
+
//#region src/io.ts
|
|
581
|
+
var gitignorePath = (dir) => join(isAbsolute(dir) ? dir : resolve(dir), ".gitignore");
|
|
582
|
+
/** A missing `.gitignore` reads as empty — that is `init`'s first-run case. */
|
|
583
|
+
function readGitignore(dir) {
|
|
584
|
+
try {
|
|
585
|
+
return readFileSync(gitignorePath(dir), "utf8");
|
|
586
|
+
} catch (error) {
|
|
587
|
+
if (error.code === "ENOENT") return "";
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
function writeGitignore(dir, text) {
|
|
592
|
+
writeFileSync(gitignorePath(dir), text, "utf8");
|
|
593
|
+
}
|
|
594
|
+
//#endregion
|
|
595
|
+
//#region src/commands/audit.ts
|
|
596
|
+
var isPattern = (line) => {
|
|
597
|
+
const t = line.trim();
|
|
598
|
+
return t !== "" && !t.startsWith("#");
|
|
599
|
+
};
|
|
600
|
+
/**
|
|
601
|
+
* Measure one file against every stack this binary ships.
|
|
602
|
+
*
|
|
603
|
+
* The point is not to rewrite anything — it is to answer "what would still be
|
|
604
|
+
* left over if this repo declared everything?", because that remainder is what
|
|
605
|
+
* decides whether a stack is missing or the lines are genuinely project rules.
|
|
606
|
+
*/
|
|
607
|
+
function auditFile(dir) {
|
|
608
|
+
const file = gitignorePath(dir);
|
|
609
|
+
const text = readGitignore(dir);
|
|
610
|
+
const before = text.split("\n").filter(isPattern).length;
|
|
611
|
+
const doc = parse(text);
|
|
612
|
+
const all = stackOrder();
|
|
613
|
+
const leftovers = reconcile({
|
|
614
|
+
...doc,
|
|
615
|
+
header: [stacksLine(all), DIVIDER],
|
|
616
|
+
hasRegion: true
|
|
617
|
+
}).document.freeZone.filter(isPattern).map((l) => l.trim());
|
|
618
|
+
return {
|
|
619
|
+
name: basename(dir),
|
|
620
|
+
file,
|
|
621
|
+
before,
|
|
622
|
+
after: leftovers.length,
|
|
623
|
+
leftovers
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
var auditCommand = defineCommand({
|
|
627
|
+
meta: {
|
|
628
|
+
name: "audit",
|
|
629
|
+
description: "Measure how much of one or more repos the shipped stacks already cover"
|
|
630
|
+
},
|
|
631
|
+
args: {
|
|
632
|
+
dirs: {
|
|
633
|
+
type: "positional",
|
|
634
|
+
description: "Repository directories (default: the current one)",
|
|
635
|
+
required: false,
|
|
636
|
+
default: "."
|
|
637
|
+
},
|
|
638
|
+
"min-files": {
|
|
639
|
+
type: "string",
|
|
640
|
+
description: "Only list leftovers carried by at least this many files",
|
|
641
|
+
default: "1"
|
|
642
|
+
},
|
|
643
|
+
json: {
|
|
644
|
+
type: "boolean",
|
|
645
|
+
description: "Emit a machine-readable JSON report",
|
|
646
|
+
default: false
|
|
647
|
+
}
|
|
648
|
+
},
|
|
649
|
+
run({ args }) {
|
|
650
|
+
const raw = args._ && args._.length > 0 ? args._ : [args.dirs];
|
|
651
|
+
const dirs = [...new Set(raw.filter((d) => typeof d === "string" && d !== ""))].filter((dir) => {
|
|
652
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
|
653
|
+
consola.warn(`Not a directory, skipped: ${dir}`);
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
if (!existsSync(gitignorePath(dir))) {
|
|
657
|
+
consola.warn(`No .gitignore, skipped: ${dir}`);
|
|
658
|
+
return false;
|
|
659
|
+
}
|
|
660
|
+
return true;
|
|
661
|
+
});
|
|
662
|
+
if (dirs.length === 0) {
|
|
663
|
+
consola.error("Nothing to audit.");
|
|
664
|
+
process.exitCode = 1;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const files = dirs.map(auditFile).sort((a, b) => b.before - a.before);
|
|
668
|
+
const before = files.reduce((n, f) => n + f.before, 0);
|
|
669
|
+
const after = files.reduce((n, f) => n + f.after, 0);
|
|
670
|
+
const carriers = /* @__PURE__ */ new Map();
|
|
671
|
+
for (const row of files) for (const line of row.leftovers) {
|
|
672
|
+
const seen = carriers.get(line);
|
|
673
|
+
if (seen) seen.push(row.name);
|
|
674
|
+
else carriers.set(line, [row.name]);
|
|
675
|
+
}
|
|
676
|
+
const minFiles = Math.max(1, Number(args["min-files"]) || 1);
|
|
677
|
+
const leftovers = [...carriers].map(([pattern, names]) => ({
|
|
678
|
+
pattern,
|
|
679
|
+
files: names
|
|
680
|
+
})).filter((l) => l.files.length >= minFiles).sort((a, b) => b.files.length - a.files.length || a.pattern.localeCompare(b.pattern));
|
|
681
|
+
const report = {
|
|
682
|
+
files,
|
|
683
|
+
totals: {
|
|
684
|
+
before,
|
|
685
|
+
after,
|
|
686
|
+
covered: before - after,
|
|
687
|
+
percent: before === 0 ? 100 : Math.round((before - after) / before * 1e3) / 10
|
|
688
|
+
},
|
|
689
|
+
leftovers
|
|
690
|
+
};
|
|
691
|
+
if (args.json) {
|
|
692
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
const width = Math.max(...files.map((f) => f.name.length), 4);
|
|
696
|
+
consola.log(` ${colors.dim("repo".padEnd(width))} ${colors.dim("before")} ${colors.dim("left")}`);
|
|
697
|
+
for (const row of files) consola.log(` ${row.name.padEnd(width)} ${String(row.before).padStart(6)} ${String(row.after).padStart(4)}`);
|
|
698
|
+
consola.log(`\n ${colors.bold(`${report.totals.before} patterns → ${report.totals.after} left`)} ${colors.dim(`(${report.totals.percent}% covered by the shipped stacks)`)}`);
|
|
699
|
+
if (leftovers.length > 0) {
|
|
700
|
+
consola.log(`\n ${colors.dim(`leftovers carried by ${minFiles}+ file(s), most common first`)}`);
|
|
701
|
+
for (const { pattern, files: names } of leftovers) consola.log(` ${String(names.length).padStart(3)}x ${pattern} ${colors.dim(names.slice(0, 4).join(", ") + (names.length > 4 ? ", …" : ""))}`);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
//#endregion
|
|
706
|
+
//#region src/gitignore/render.ts
|
|
707
|
+
/**
|
|
708
|
+
* Document back to text. The managed region comes first, the free zone below
|
|
709
|
+
* it, exactly one blank line between every block — so a second render of a
|
|
710
|
+
* rendered document is a no-op, which is what makes `check` a usable CI gate.
|
|
711
|
+
*/
|
|
712
|
+
function render(doc) {
|
|
713
|
+
const out = [];
|
|
714
|
+
if (doc.hasRegion || doc.header.length > 0 || doc.sections.length > 0) {
|
|
715
|
+
out.push(outerStartLine(), ...doc.header);
|
|
716
|
+
for (const section of doc.sections) out.push("", sectionStartLine(section.stack, section.version), ...section.lines, endLine());
|
|
717
|
+
out.push("", endLine());
|
|
718
|
+
}
|
|
719
|
+
if (doc.freeZone.length > 0) {
|
|
720
|
+
if (out.length > 0) out.push("");
|
|
721
|
+
out.push(...doc.freeZone);
|
|
722
|
+
}
|
|
723
|
+
return out.length > 0 ? `${out.join("\n")}\n` : "";
|
|
724
|
+
}
|
|
725
|
+
//#endregion
|
|
726
|
+
//#region src/commands/report.ts
|
|
727
|
+
/** Everything a run wants to say about a reconciliation, in one place. */
|
|
728
|
+
function report(result) {
|
|
729
|
+
for (const stack of result.unknownStacks) consola.warn(`Header declares unknown stack ${colors.cyan(stack)} — no template ships for it, its block is left untouched.`);
|
|
730
|
+
for (const stale of result.staleSections) consola.info(`${colors.cyan(stale.stack)} block is at v${stale.from}, this binary ships v${stale.to}.`);
|
|
731
|
+
if (result.rescued.length > 0) {
|
|
732
|
+
consola.info(`Rescued ${result.rescued.length} hand-written line(s) from managed blocks into the free zone:`);
|
|
733
|
+
for (const line of result.rescued) consola.log(` ${colors.green(line)}`);
|
|
734
|
+
}
|
|
735
|
+
if (result.covered.length > 0) {
|
|
736
|
+
consola.info(`Removed ${result.covered.length} free-zone line(s) a managed block already covers:`);
|
|
737
|
+
for (const line of result.covered) consola.log(` ${colors.dim(line)}`);
|
|
738
|
+
}
|
|
739
|
+
if (result.orphanedComments.length > 0) {
|
|
740
|
+
consola.info(`Removed ${result.orphanedComments.length} heading(s) left pointing at nothing:`);
|
|
741
|
+
for (const line of result.orphanedComments) consola.log(` ${colors.dim(line)}`);
|
|
742
|
+
}
|
|
743
|
+
if (result.duplicates.length > 0) {
|
|
744
|
+
consola.info(`Removed ${result.duplicates.length} exact duplicate(s):`);
|
|
745
|
+
for (const line of result.duplicates) consola.log(` ${colors.dim(line)}`);
|
|
746
|
+
}
|
|
747
|
+
for (const s of result.smothered) consola.warn(`${colors.yellow(s.line)} ignores the whole directory, which disables ${s.exceptions.map((e) => colors.cyan(e)).join(", ")}. Remove it — the managed block already covers it.`);
|
|
748
|
+
for (const eq of result.equivalences) consola.warn(`${eq.spellings.map((s) => colors.yellow(s)).join(" / ")} mean different things to git — reported, not merged.`);
|
|
749
|
+
}
|
|
750
|
+
//#endregion
|
|
751
|
+
//#region src/commands/check.ts
|
|
752
|
+
var checkCommand = defineCommand({
|
|
753
|
+
meta: {
|
|
754
|
+
name: "check",
|
|
755
|
+
description: "Dry run for CI: report drift and duplicates, exit non-zero on deviation"
|
|
756
|
+
},
|
|
757
|
+
args: { dir: {
|
|
758
|
+
type: "positional",
|
|
759
|
+
description: "Repository directory (default: the current one)",
|
|
760
|
+
required: false,
|
|
761
|
+
default: "."
|
|
762
|
+
} },
|
|
763
|
+
run({ args }) {
|
|
764
|
+
const dir = args.dir;
|
|
765
|
+
const text = readGitignore(dir);
|
|
766
|
+
const doc = parse(text);
|
|
767
|
+
if (!doc.hasRegion) {
|
|
768
|
+
consola.error(`${gitignorePath(dir)} has no managed region — run ${colors.cyan("gitignore-sync init")} first.`);
|
|
769
|
+
process.exitCode = 1;
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const result = reconcile(doc);
|
|
773
|
+
report(result);
|
|
774
|
+
if (render(result.document) !== text) {
|
|
775
|
+
consola.error(`${gitignorePath(dir)} is out of sync — run ${colors.cyan("gitignore-sync sync")}.`);
|
|
776
|
+
process.exitCode = 1;
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
consola.success(`${gitignorePath(dir)} is in sync.`);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
//#endregion
|
|
783
|
+
//#region src/detect.ts
|
|
784
|
+
/**
|
|
785
|
+
* How deep a pattern fingerprint looks. One level below the root, because a
|
|
786
|
+
* Terraform repo keeps its stacks in `tofu/` or `terraform/` rather than
|
|
787
|
+
* scattered across the root — and because going deeper starts finding other
|
|
788
|
+
* people's examples.
|
|
789
|
+
*/
|
|
790
|
+
var PATTERN_DEPTH = 2;
|
|
791
|
+
/**
|
|
792
|
+
* Never descended into. `examples/` is here on purpose: a `.tf` file shown as
|
|
793
|
+
* documentation is not a workspace that produces state, and treating it as one
|
|
794
|
+
* makes every provider repo look like an infrastructure repo.
|
|
795
|
+
*/
|
|
796
|
+
var SKIP = /* @__PURE__ */ new Set([
|
|
797
|
+
".git",
|
|
798
|
+
"node_modules",
|
|
799
|
+
"vendor",
|
|
800
|
+
"dist",
|
|
801
|
+
"coverage",
|
|
802
|
+
"examples",
|
|
803
|
+
"testdata",
|
|
804
|
+
"fixtures"
|
|
805
|
+
]);
|
|
806
|
+
var fingerprints = {
|
|
807
|
+
core: {
|
|
808
|
+
source: "repo",
|
|
809
|
+
always: true
|
|
810
|
+
},
|
|
811
|
+
git: {
|
|
812
|
+
source: "repo",
|
|
813
|
+
files: [".git"]
|
|
814
|
+
},
|
|
815
|
+
node: {
|
|
816
|
+
source: "repo",
|
|
817
|
+
files: ["package.json"]
|
|
818
|
+
},
|
|
819
|
+
turborepo: {
|
|
820
|
+
source: "repo",
|
|
821
|
+
files: ["turbo.json"]
|
|
822
|
+
},
|
|
823
|
+
rust: {
|
|
824
|
+
source: "repo",
|
|
825
|
+
files: ["Cargo.toml"]
|
|
826
|
+
},
|
|
827
|
+
playwright: {
|
|
828
|
+
source: "repo",
|
|
829
|
+
patterns: [/^playwright\.config\.[cm]?[jt]s$/]
|
|
830
|
+
},
|
|
831
|
+
storybook: {
|
|
832
|
+
source: "repo",
|
|
833
|
+
files: [".storybook"]
|
|
834
|
+
},
|
|
835
|
+
php: {
|
|
836
|
+
source: "repo",
|
|
837
|
+
files: ["composer.json"]
|
|
838
|
+
},
|
|
839
|
+
laravel: {
|
|
840
|
+
source: "repo",
|
|
841
|
+
files: ["artisan"]
|
|
842
|
+
},
|
|
843
|
+
go: {
|
|
844
|
+
source: "repo",
|
|
845
|
+
files: ["go.mod"]
|
|
846
|
+
},
|
|
847
|
+
tofu: {
|
|
848
|
+
source: "repo",
|
|
849
|
+
patterns: [
|
|
850
|
+
/\.tf$/,
|
|
851
|
+
/\.tofu$/,
|
|
852
|
+
/^\.terraform\.lock\.hcl$/
|
|
853
|
+
]
|
|
854
|
+
},
|
|
855
|
+
nuxt: {
|
|
856
|
+
source: "repo",
|
|
857
|
+
patterns: [/^nuxt\.config\.[cm]?[jt]s$/]
|
|
858
|
+
},
|
|
859
|
+
tauri: {
|
|
860
|
+
source: "repo",
|
|
861
|
+
files: ["src-tauri"]
|
|
862
|
+
},
|
|
863
|
+
dotenv: {
|
|
864
|
+
source: "repo",
|
|
865
|
+
files: [".env.example", ".env"]
|
|
866
|
+
},
|
|
867
|
+
vscode: {
|
|
868
|
+
source: "machine",
|
|
869
|
+
files: [".vscode"]
|
|
870
|
+
},
|
|
871
|
+
intellij: {
|
|
872
|
+
source: "machine",
|
|
873
|
+
files: [".idea"]
|
|
874
|
+
},
|
|
875
|
+
vim: {
|
|
876
|
+
source: "machine",
|
|
877
|
+
editor: /\b(?:vi|vim|nvim)\b/
|
|
878
|
+
},
|
|
879
|
+
macos: {
|
|
880
|
+
source: "machine",
|
|
881
|
+
platform: "darwin"
|
|
882
|
+
},
|
|
883
|
+
windows: {
|
|
884
|
+
source: "machine",
|
|
885
|
+
platform: "win32"
|
|
886
|
+
},
|
|
887
|
+
linux: {
|
|
888
|
+
source: "machine",
|
|
889
|
+
platform: "linux"
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
function matchesPatterns(dir, patterns, depth) {
|
|
893
|
+
let entries;
|
|
894
|
+
try {
|
|
895
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
896
|
+
} catch {
|
|
897
|
+
return false;
|
|
898
|
+
}
|
|
899
|
+
for (const entry of entries) {
|
|
900
|
+
if (entry.isDirectory()) continue;
|
|
901
|
+
if (patterns.some((pattern) => pattern.test(entry.name))) return true;
|
|
902
|
+
}
|
|
903
|
+
if (depth <= 1) return false;
|
|
904
|
+
for (const entry of entries) {
|
|
905
|
+
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
|
906
|
+
if (SKIP.has(entry.name)) continue;
|
|
907
|
+
if (matchesPatterns(join(dir, entry.name), patterns, depth - 1)) return true;
|
|
908
|
+
}
|
|
909
|
+
return false;
|
|
910
|
+
}
|
|
911
|
+
function matches(dir, print) {
|
|
912
|
+
if (print.always) return true;
|
|
913
|
+
if (print.files?.some((file) => existsSync(join(dir, file)))) return true;
|
|
914
|
+
if (print.platform && process.platform === print.platform) return true;
|
|
915
|
+
if (print.editor) {
|
|
916
|
+
const editor = process.env.VISUAL ?? process.env.EDITOR ?? "";
|
|
917
|
+
if (print.editor.test(editor)) return true;
|
|
918
|
+
}
|
|
919
|
+
if (print.patterns) return matchesPatterns(dir, print.patterns, PATTERN_DEPTH);
|
|
920
|
+
return false;
|
|
921
|
+
}
|
|
922
|
+
/**
|
|
923
|
+
* The only filesystem contact in the tool. `parse`, `render` and `reconcile`
|
|
924
|
+
* stay string-in/string-out so the hard part is testable with fixture pairs.
|
|
925
|
+
*
|
|
926
|
+
* Returns registry order, so the header reads the same on every run.
|
|
927
|
+
*/
|
|
928
|
+
function detect(dir) {
|
|
929
|
+
const found = [];
|
|
930
|
+
for (const stack of stackOrder()) {
|
|
931
|
+
const print = fingerprints[stack];
|
|
932
|
+
if (print && matches(dir, print)) found.push({
|
|
933
|
+
stack,
|
|
934
|
+
source: print.source
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
return found;
|
|
938
|
+
}
|
|
939
|
+
/** Just the names, for callers that do not care where a signal came from. */
|
|
940
|
+
var stacksOf = (found) => found.map((d) => d.stack);
|
|
941
|
+
/**
|
|
942
|
+
* What may be written without asking. Only committed evidence: `init --yes`
|
|
943
|
+
* and the no-TTY path run in CI, where the platform is the runner's and the
|
|
944
|
+
* editor directory is nobody's.
|
|
945
|
+
*/
|
|
946
|
+
var committedOnly = (found) => found.filter((d) => d.source === "repo");
|
|
947
|
+
//#endregion
|
|
948
|
+
//#region src/commands/prompt.ts
|
|
949
|
+
/**
|
|
950
|
+
* The one multiselect both `init` and `edit` show, so the two read the same
|
|
951
|
+
* however you got there. Returns `null` when the prompt was cancelled — the
|
|
952
|
+
* caller writes nothing in that case.
|
|
953
|
+
*/
|
|
954
|
+
async function promptStacks(options) {
|
|
955
|
+
const offered = options.choices ?? stackOrder();
|
|
956
|
+
const source = new Map(options.found.map((d) => [d.stack, d.source]));
|
|
957
|
+
const choices = offered.map((stack) => {
|
|
958
|
+
const template = currentTemplate(stack);
|
|
959
|
+
const marks = [];
|
|
960
|
+
if (source.has(stack)) marks.push("detected");
|
|
961
|
+
if (options.declared.has(stack)) marks.push("declared");
|
|
962
|
+
return {
|
|
963
|
+
value: stack,
|
|
964
|
+
label: stack,
|
|
965
|
+
hint: `${template?.lines.length ?? 0} lines${marks.length > 0 ? ` · ${marks.join(", ")}` : ""}`
|
|
966
|
+
};
|
|
967
|
+
});
|
|
968
|
+
const answer = await consola.prompt(options.message, {
|
|
969
|
+
type: "multiselect",
|
|
970
|
+
options: choices,
|
|
971
|
+
initial: offered.filter((s) => options.initial.has(s)),
|
|
972
|
+
required: false,
|
|
973
|
+
cancel: "null"
|
|
974
|
+
});
|
|
975
|
+
if (answer === null || answer === void 0) return null;
|
|
976
|
+
const chosen = new Set(answer.map(String));
|
|
977
|
+
const picked = offered.filter((stack) => chosen.has(stack));
|
|
978
|
+
for (const stack of options.declared) if (!picked.includes(stack) && !stackOrder().includes(stack)) picked.push(stack);
|
|
979
|
+
return picked;
|
|
980
|
+
}
|
|
981
|
+
/** A prompt with no terminal to read from would hang or take its defaults. */
|
|
982
|
+
var canPrompt = () => Boolean(process.stdin.isTTY);
|
|
983
|
+
//#endregion
|
|
984
|
+
//#region src/commands/edit.ts
|
|
985
|
+
var editCommand = defineCommand({
|
|
986
|
+
meta: {
|
|
987
|
+
name: "edit",
|
|
988
|
+
description: "Tick stacks on and off in a prompt, then re-render"
|
|
989
|
+
},
|
|
990
|
+
args: {
|
|
991
|
+
dir: {
|
|
992
|
+
type: "positional",
|
|
993
|
+
description: "Repository directory (default: the current one)",
|
|
994
|
+
required: false,
|
|
995
|
+
default: "."
|
|
996
|
+
},
|
|
997
|
+
"dry-run": {
|
|
998
|
+
type: "boolean",
|
|
999
|
+
description: "Print the result instead of writing it",
|
|
1000
|
+
default: false
|
|
1001
|
+
}
|
|
1002
|
+
},
|
|
1003
|
+
async run({ args }) {
|
|
1004
|
+
const dir = args.dir;
|
|
1005
|
+
const text = readGitignore(dir);
|
|
1006
|
+
const doc = parse(text);
|
|
1007
|
+
if (!doc.hasRegion) {
|
|
1008
|
+
consola.error(`${gitignorePath(dir)} has no managed region — run ${colors.cyan("gitignore-sync init")} first.`);
|
|
1009
|
+
process.exitCode = 1;
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
if (!canPrompt()) {
|
|
1013
|
+
consola.error(`${colors.cyan("edit")} needs an interactive terminal. Use ${colors.cyan("add")} / ${colors.cyan("remove")} in a script or CI.`);
|
|
1014
|
+
process.exitCode = 1;
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
const declared = new Set(readStacks(doc));
|
|
1018
|
+
const next = await promptStacks({
|
|
1019
|
+
message: "Which stacks does this repo want?",
|
|
1020
|
+
initial: declared,
|
|
1021
|
+
found: detect(dir),
|
|
1022
|
+
declared
|
|
1023
|
+
});
|
|
1024
|
+
if (next === null) {
|
|
1025
|
+
consola.info("Cancelled — nothing written.");
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
const result = reconcile(withStacks(doc, next));
|
|
1029
|
+
const output = render(result.document);
|
|
1030
|
+
consola.log(` ${colors.dim(stacksLine(next))}`);
|
|
1031
|
+
report(result);
|
|
1032
|
+
if (args["dry-run"]) {
|
|
1033
|
+
consola.log(output);
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
if (output === text) {
|
|
1037
|
+
consola.success(`${gitignorePath(dir)} is already in sync.`);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
writeGitignore(dir, output);
|
|
1041
|
+
consola.success(`Wrote ${gitignorePath(dir)}`);
|
|
1042
|
+
}
|
|
1043
|
+
});
|
|
1044
|
+
//#endregion
|
|
1045
|
+
//#region src/commands/info.ts
|
|
1046
|
+
/** The package.json `name` this build is identified by. */
|
|
1047
|
+
var PACKAGE_NAME = "@kirchdev/gitignore-sync";
|
|
1048
|
+
/** What a person types, and what the report is headed with. */
|
|
1049
|
+
var BIN_NAME = "gitignore-sync";
|
|
1050
|
+
/** Resolve the real executed file behind any shim or symlink. */
|
|
1051
|
+
function resolveBinary(entry) {
|
|
1052
|
+
if (!entry) return {
|
|
1053
|
+
invoked: null,
|
|
1054
|
+
resolved: null
|
|
1055
|
+
};
|
|
1056
|
+
try {
|
|
1057
|
+
return {
|
|
1058
|
+
invoked: entry,
|
|
1059
|
+
resolved: realpathSync(entry)
|
|
1060
|
+
};
|
|
1061
|
+
} catch {
|
|
1062
|
+
return {
|
|
1063
|
+
invoked: entry,
|
|
1064
|
+
resolved: entry
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
/** Walk up from `start` to the nearest package.json, returning its dir + name. */
|
|
1069
|
+
function findPackageRoot(start) {
|
|
1070
|
+
let dir = resolve(start);
|
|
1071
|
+
for (;;) {
|
|
1072
|
+
const pkgPath = join(dir, "package.json");
|
|
1073
|
+
if (existsSync(pkgPath)) try {
|
|
1074
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
1075
|
+
return {
|
|
1076
|
+
dir,
|
|
1077
|
+
name: pkg.name
|
|
1078
|
+
};
|
|
1079
|
+
} catch {
|
|
1080
|
+
return {
|
|
1081
|
+
dir,
|
|
1082
|
+
name: void 0
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
const parent = dirname(dir);
|
|
1086
|
+
if (parent === dir) return null;
|
|
1087
|
+
dir = parent;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Tell a linked dev build from an installed release: the resolved binary lives
|
|
1092
|
+
* inside a git work tree whose package.json carries this package's name. When
|
|
1093
|
+
* neither can be established, report `unknown` rather than guessing.
|
|
1094
|
+
*/
|
|
1095
|
+
function detectBuild(resolved) {
|
|
1096
|
+
if (!resolved) return {
|
|
1097
|
+
kind: "unknown",
|
|
1098
|
+
packageRoot: null,
|
|
1099
|
+
reason: "binary path could not be resolved"
|
|
1100
|
+
};
|
|
1101
|
+
const pkg = findPackageRoot(dirname(resolved));
|
|
1102
|
+
if (!pkg) return {
|
|
1103
|
+
kind: "unknown",
|
|
1104
|
+
packageRoot: null,
|
|
1105
|
+
reason: "no package.json found above the binary"
|
|
1106
|
+
};
|
|
1107
|
+
if (pkg.name !== PACKAGE_NAME) return {
|
|
1108
|
+
kind: "unknown",
|
|
1109
|
+
packageRoot: pkg.dir,
|
|
1110
|
+
reason: `nearest package.json is "${pkg.name ?? "unnamed"}", not ${PACKAGE_NAME}`
|
|
1111
|
+
};
|
|
1112
|
+
const inGitTree = existsSync(join(pkg.dir, ".git"));
|
|
1113
|
+
return {
|
|
1114
|
+
kind: inGitTree ? "linked" : "release",
|
|
1115
|
+
packageRoot: pkg.dir,
|
|
1116
|
+
reason: inGitTree ? `runs from a git work tree named ${PACKAGE_NAME}` : `installed ${PACKAGE_NAME} package (no git work tree beside it)`
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
function inspectRepository(dir) {
|
|
1120
|
+
const file = gitignorePath(dir);
|
|
1121
|
+
const exists = existsSync(file);
|
|
1122
|
+
if (!exists) return {
|
|
1123
|
+
file,
|
|
1124
|
+
exists,
|
|
1125
|
+
hasRegion: false,
|
|
1126
|
+
stacks: [],
|
|
1127
|
+
status: "no file"
|
|
1128
|
+
};
|
|
1129
|
+
const text = readGitignore(dir);
|
|
1130
|
+
try {
|
|
1131
|
+
const doc = parse(text);
|
|
1132
|
+
if (!doc.hasRegion) return {
|
|
1133
|
+
file,
|
|
1134
|
+
exists,
|
|
1135
|
+
hasRegion: false,
|
|
1136
|
+
stacks: [],
|
|
1137
|
+
status: "no region"
|
|
1138
|
+
};
|
|
1139
|
+
const result = reconcile(doc);
|
|
1140
|
+
return {
|
|
1141
|
+
file,
|
|
1142
|
+
exists,
|
|
1143
|
+
hasRegion: true,
|
|
1144
|
+
stacks: readStacks(doc),
|
|
1145
|
+
status: render(result.document) === text ? "in sync" : "drifted"
|
|
1146
|
+
};
|
|
1147
|
+
} catch (error) {
|
|
1148
|
+
return {
|
|
1149
|
+
file,
|
|
1150
|
+
exists,
|
|
1151
|
+
hasRegion: false,
|
|
1152
|
+
stacks: [],
|
|
1153
|
+
status: error.message
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
var BUILD_LABELS = {
|
|
1158
|
+
linked: "linked / dev build",
|
|
1159
|
+
release: "installed release",
|
|
1160
|
+
unknown: "unknown"
|
|
1161
|
+
};
|
|
1162
|
+
var row = (label, value) => ` ${colors.dim(label.padEnd(9))} ${value}\n`;
|
|
1163
|
+
function renderPretty(info) {
|
|
1164
|
+
let out = `${colors.bold(BIN_NAME)} ${colors.cyan(`v${info.version}`)} ${colors.dim(`(${BUILD_LABELS[info.build.kind]})`)}\n`;
|
|
1165
|
+
out += row("build", colors.dim(info.build.reason));
|
|
1166
|
+
if (info.build.packageRoot) out += row("package", info.build.packageRoot);
|
|
1167
|
+
out += row("binary", info.binary.resolved ?? colors.dim("unknown"));
|
|
1168
|
+
if (info.binary.invoked && info.binary.invoked !== info.binary.resolved) out += row("", colors.dim(`via ${info.binary.invoked}`));
|
|
1169
|
+
out += row("node", info.node);
|
|
1170
|
+
out += `\n${colors.bold("templates")}\n`;
|
|
1171
|
+
out += row("ships", `${info.templates.stacks} stacks, ${info.templates.patterns} patterns`);
|
|
1172
|
+
out += `\n${colors.bold("repository")}\n`;
|
|
1173
|
+
out += row("file", info.repository.file);
|
|
1174
|
+
const status = info.repository.status === "in sync" ? colors.green(info.repository.status) : info.repository.status === "drifted" ? colors.yellow(info.repository.status) : colors.dim(info.repository.status);
|
|
1175
|
+
out += row("status", status);
|
|
1176
|
+
if (info.repository.stacks.length > 0) out += row("stacks", info.repository.stacks.map((s) => colors.cyan(s)).join(", "));
|
|
1177
|
+
return out;
|
|
1178
|
+
}
|
|
1179
|
+
var infoCommand = defineCommand({
|
|
1180
|
+
meta: {
|
|
1181
|
+
name: "info",
|
|
1182
|
+
description: "Describe this installation: version, how it was installed, and the repo it sees"
|
|
1183
|
+
},
|
|
1184
|
+
args: {
|
|
1185
|
+
dir: {
|
|
1186
|
+
type: "positional",
|
|
1187
|
+
description: "Repository directory (default: the current one)",
|
|
1188
|
+
required: false,
|
|
1189
|
+
default: "."
|
|
1190
|
+
},
|
|
1191
|
+
json: {
|
|
1192
|
+
type: "boolean",
|
|
1193
|
+
description: "Emit a machine-readable JSON report",
|
|
1194
|
+
default: false
|
|
1195
|
+
}
|
|
1196
|
+
},
|
|
1197
|
+
run({ args }) {
|
|
1198
|
+
const binary = resolveBinary(process.argv[1]);
|
|
1199
|
+
const info = {
|
|
1200
|
+
version: "0.1.0",
|
|
1201
|
+
build: detectBuild(binary.resolved),
|
|
1202
|
+
binary,
|
|
1203
|
+
node: process.version,
|
|
1204
|
+
templates: {
|
|
1205
|
+
stacks: stackOrder().length,
|
|
1206
|
+
patterns: stackOrder().reduce((n, s) => n + (currentTemplate(s)?.lines.length ?? 0), 0)
|
|
1207
|
+
},
|
|
1208
|
+
repository: inspectRepository(args.dir)
|
|
1209
|
+
};
|
|
1210
|
+
if (args.json) {
|
|
1211
|
+
process.stdout.write(`${JSON.stringify(info, null, 2)}\n`);
|
|
1212
|
+
return;
|
|
1213
|
+
}
|
|
1214
|
+
process.stdout.write(renderPretty(info));
|
|
1215
|
+
}
|
|
1216
|
+
});
|
|
1217
|
+
//#endregion
|
|
1218
|
+
//#region src/commands/stacks.ts
|
|
1219
|
+
var args = {
|
|
1220
|
+
stacks: {
|
|
1221
|
+
type: "positional",
|
|
1222
|
+
description: "One or more stack names",
|
|
1223
|
+
required: true
|
|
1224
|
+
},
|
|
1225
|
+
dir: {
|
|
1226
|
+
type: "string",
|
|
1227
|
+
description: "Repository directory (default: the current one)",
|
|
1228
|
+
default: "."
|
|
1229
|
+
},
|
|
1230
|
+
"dry-run": {
|
|
1231
|
+
type: "boolean",
|
|
1232
|
+
description: "Print the result instead of writing it",
|
|
1233
|
+
default: false
|
|
1234
|
+
}
|
|
1235
|
+
};
|
|
1236
|
+
/**
|
|
1237
|
+
* `add` and `remove` edit one line: the `# stacks:` declaration. Everything
|
|
1238
|
+
* below it is re-rendered from that line, so the two verbs stay a convenience
|
|
1239
|
+
* over an edit you could also make by hand — never a second source of truth.
|
|
1240
|
+
*/
|
|
1241
|
+
function edit(mode) {
|
|
1242
|
+
return (context) => {
|
|
1243
|
+
const a = context.args;
|
|
1244
|
+
const dir = a.dir ?? ".";
|
|
1245
|
+
const raw = a._ && a._.length > 0 ? a._ : [a.stacks];
|
|
1246
|
+
const names = [...new Set(raw.filter((n) => typeof n === "string" && n !== ""))];
|
|
1247
|
+
if (names.length === 0) {
|
|
1248
|
+
consola.error("Name at least one stack.");
|
|
1249
|
+
consola.info(`Available: ${knownStacks().join(", ")}`);
|
|
1250
|
+
process.exitCode = 1;
|
|
1251
|
+
return;
|
|
1252
|
+
}
|
|
1253
|
+
const doc = parse(readGitignore(dir));
|
|
1254
|
+
if (!doc.hasRegion) {
|
|
1255
|
+
consola.error(`${gitignorePath(dir)} has no managed region — run ${colors.cyan("gitignore-sync init")} first.`);
|
|
1256
|
+
process.exitCode = 1;
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
if (mode === "add") {
|
|
1260
|
+
const unknown = names.filter((n) => !isKnownStack(n));
|
|
1261
|
+
if (unknown.length > 0) {
|
|
1262
|
+
consola.error(`Unknown stack: ${unknown.map((u) => colors.yellow(u)).join(", ")}`);
|
|
1263
|
+
consola.info(`Available: ${knownStacks().join(", ")}`);
|
|
1264
|
+
process.exitCode = 1;
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
const before = readStacks(doc);
|
|
1269
|
+
const set = new Set(before);
|
|
1270
|
+
const changed = [];
|
|
1271
|
+
for (const name of names) {
|
|
1272
|
+
if (mode === "add") {
|
|
1273
|
+
if (set.has(name)) continue;
|
|
1274
|
+
set.add(name);
|
|
1275
|
+
} else {
|
|
1276
|
+
if (!set.has(name)) continue;
|
|
1277
|
+
set.delete(name);
|
|
1278
|
+
}
|
|
1279
|
+
changed.push(name);
|
|
1280
|
+
}
|
|
1281
|
+
if (changed.length === 0) {
|
|
1282
|
+
consola.info(mode === "add" ? "Already declared — nothing to add." : "Not declared — nothing to remove.");
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
1285
|
+
const order = stackOrder();
|
|
1286
|
+
const next = [...set].sort((x, y) => {
|
|
1287
|
+
const ix = order.indexOf(x);
|
|
1288
|
+
const iy = order.indexOf(y);
|
|
1289
|
+
if (ix === -1 && iy === -1) return x.localeCompare(y);
|
|
1290
|
+
if (ix === -1) return 1;
|
|
1291
|
+
if (iy === -1) return -1;
|
|
1292
|
+
return ix - iy;
|
|
1293
|
+
});
|
|
1294
|
+
const result = reconcile(withStacks(doc, next));
|
|
1295
|
+
const output = render(result.document);
|
|
1296
|
+
consola.info(`${mode === "add" ? "Added" : "Removed"} ${changed.map((c) => colors.cyan(c)).join(", ")}`);
|
|
1297
|
+
consola.log(` ${colors.dim(`# stacks: ${next.join(", ")}`)}`);
|
|
1298
|
+
report(result);
|
|
1299
|
+
if (a["dry-run"]) {
|
|
1300
|
+
consola.log(output);
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1303
|
+
writeGitignore(dir, output);
|
|
1304
|
+
consola.success(`Wrote ${gitignorePath(dir)}`);
|
|
1305
|
+
};
|
|
1306
|
+
}
|
|
1307
|
+
var addCommand = defineCommand({
|
|
1308
|
+
meta: {
|
|
1309
|
+
name: "add",
|
|
1310
|
+
description: "Add stacks to the header and re-render"
|
|
1311
|
+
},
|
|
1312
|
+
args,
|
|
1313
|
+
run: edit("add")
|
|
1314
|
+
});
|
|
1315
|
+
var removeCommand = defineCommand({
|
|
1316
|
+
meta: {
|
|
1317
|
+
name: "remove",
|
|
1318
|
+
alias: "rm",
|
|
1319
|
+
description: "Remove stacks from the header and re-render"
|
|
1320
|
+
},
|
|
1321
|
+
args,
|
|
1322
|
+
run: edit("remove")
|
|
1323
|
+
});
|
|
1324
|
+
//#endregion
|
|
1325
|
+
//#region src/commands/init.ts
|
|
1326
|
+
var initCommand = defineCommand({
|
|
1327
|
+
meta: {
|
|
1328
|
+
name: "init",
|
|
1329
|
+
description: "Fingerprint the repo, confirm the stacks in a prompt and write the managed region"
|
|
1330
|
+
},
|
|
1331
|
+
args: {
|
|
1332
|
+
dir: {
|
|
1333
|
+
type: "positional",
|
|
1334
|
+
description: "Repository directory (default: the current one)",
|
|
1335
|
+
required: false,
|
|
1336
|
+
default: "."
|
|
1337
|
+
},
|
|
1338
|
+
stacks: {
|
|
1339
|
+
type: "string",
|
|
1340
|
+
description: "Comma-separated stacks; skips the prompt"
|
|
1341
|
+
},
|
|
1342
|
+
yes: {
|
|
1343
|
+
type: "boolean",
|
|
1344
|
+
description: "Take what detection proposes without asking",
|
|
1345
|
+
alias: "y",
|
|
1346
|
+
default: false
|
|
1347
|
+
},
|
|
1348
|
+
"dry-run": {
|
|
1349
|
+
type: "boolean",
|
|
1350
|
+
description: "Print the result instead of writing it",
|
|
1351
|
+
default: false
|
|
1352
|
+
},
|
|
1353
|
+
force: {
|
|
1354
|
+
type: "boolean",
|
|
1355
|
+
description: "Replace an existing header instead of refusing",
|
|
1356
|
+
default: false
|
|
1357
|
+
}
|
|
1358
|
+
},
|
|
1359
|
+
async run({ args }) {
|
|
1360
|
+
const dir = args.dir;
|
|
1361
|
+
const existing = parse(readGitignore(dir));
|
|
1362
|
+
if (existing.hasRegion && !args.force) {
|
|
1363
|
+
consola.error(`${gitignorePath(dir)} already carries a managed region. Use ${colors.cyan("gitignore-sync edit")} to change it, ${colors.cyan("sync")} to re-render, or pass --force to start over.`);
|
|
1364
|
+
process.exitCode = 1;
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
const found = detect(dir);
|
|
1368
|
+
const detected = new Set(stacksOf(found));
|
|
1369
|
+
let stacks;
|
|
1370
|
+
if (args.stacks) stacks = args.stacks.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1371
|
+
else if (args.yes || !canPrompt()) {
|
|
1372
|
+
stacks = stacksOf(committedOnly(found));
|
|
1373
|
+
consola.info(`Detected ${stacks.map((s) => colors.cyan(s)).join(", ")}`);
|
|
1374
|
+
const skipped = stacksOf(found).filter((s) => !stacks.includes(s));
|
|
1375
|
+
if (skipped.length > 0) consola.log(` ${colors.dim(`Skipped ${skipped.join(", ")} — editor and platform need a human to confirm them. Add with \`gitignore-sync add\`.`)}`);
|
|
1376
|
+
} else {
|
|
1377
|
+
consola.info(`Found ${detected.size} stack${detected.size === 1 ? "" : "s"}: ${[...detected].map((s) => colors.cyan(s)).join(", ")}`);
|
|
1378
|
+
const takeProposal = await consola.prompt("Use them?", {
|
|
1379
|
+
type: "confirm",
|
|
1380
|
+
initial: true,
|
|
1381
|
+
cancel: "null"
|
|
1382
|
+
});
|
|
1383
|
+
if (takeProposal === null || takeProposal === void 0) {
|
|
1384
|
+
consola.info("Cancelled — nothing written.");
|
|
1385
|
+
return;
|
|
1386
|
+
}
|
|
1387
|
+
if (takeProposal) {
|
|
1388
|
+
const rest = stackOrder().filter((s) => !detected.has(s));
|
|
1389
|
+
const extra = await promptStacks({
|
|
1390
|
+
message: "Anything else?",
|
|
1391
|
+
initial: /* @__PURE__ */ new Set(),
|
|
1392
|
+
found,
|
|
1393
|
+
declared: /* @__PURE__ */ new Set(),
|
|
1394
|
+
choices: rest
|
|
1395
|
+
});
|
|
1396
|
+
if (extra === null) {
|
|
1397
|
+
consola.info("Cancelled — nothing written.");
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
stacks = stackOrder().filter((s) => detected.has(s) || extra.includes(s));
|
|
1401
|
+
} else {
|
|
1402
|
+
const picked = await promptStacks({
|
|
1403
|
+
message: "Pick the stacks yourself:",
|
|
1404
|
+
initial: /* @__PURE__ */ new Set(),
|
|
1405
|
+
found,
|
|
1406
|
+
declared: /* @__PURE__ */ new Set()
|
|
1407
|
+
});
|
|
1408
|
+
if (picked === null) {
|
|
1409
|
+
consola.info("Cancelled — nothing written.");
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
stacks = picked;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
const result = reconcile({
|
|
1416
|
+
...existing,
|
|
1417
|
+
header: [stacksLine(stacks), DIVIDER],
|
|
1418
|
+
hasRegion: true
|
|
1419
|
+
});
|
|
1420
|
+
const output = render(result.document);
|
|
1421
|
+
consola.log(` ${colors.dim(stacksLine(stacks))}`);
|
|
1422
|
+
report(result);
|
|
1423
|
+
if (args["dry-run"]) {
|
|
1424
|
+
consola.log(output);
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
writeGitignore(dir, output);
|
|
1428
|
+
consola.success(`Wrote ${gitignorePath(dir)}`);
|
|
1429
|
+
}
|
|
1430
|
+
});
|
|
1431
|
+
//#endregion
|
|
1432
|
+
//#region src/commands/list.ts
|
|
1433
|
+
var listCommand = defineCommand({
|
|
1434
|
+
meta: {
|
|
1435
|
+
name: "list",
|
|
1436
|
+
alias: "ls",
|
|
1437
|
+
description: "List the stacks this binary ships and which the repo declares"
|
|
1438
|
+
},
|
|
1439
|
+
args: { dir: {
|
|
1440
|
+
type: "positional",
|
|
1441
|
+
description: "Repository directory (default: the current one)",
|
|
1442
|
+
required: false,
|
|
1443
|
+
default: "."
|
|
1444
|
+
} },
|
|
1445
|
+
run({ args }) {
|
|
1446
|
+
const declared = new Set(readStacks(parse(readGitignore(args.dir))));
|
|
1447
|
+
for (const stack of knownStacks()) {
|
|
1448
|
+
const template = currentTemplate(stack);
|
|
1449
|
+
const mark = declared.has(stack) ? colors.green("●") : colors.dim("○");
|
|
1450
|
+
consola.log(` ${mark} ${colors.cyan(stack)}@v${template?.version ?? "?"} ${colors.dim(`${template?.lines.length ?? 0} ${template?.lines.length === 1 ? "line" : "lines"}`)}`);
|
|
1451
|
+
}
|
|
1452
|
+
for (const stack of declared) if (!currentTemplate(stack)) consola.log(` ${colors.yellow("?")} ${colors.cyan(stack)} ${colors.dim("declared, no template")}`);
|
|
1453
|
+
}
|
|
1454
|
+
});
|
|
1455
|
+
//#endregion
|
|
1456
|
+
//#region src/commands/sync.ts
|
|
1457
|
+
var syncCommand = defineCommand({
|
|
1458
|
+
meta: {
|
|
1459
|
+
name: "sync",
|
|
1460
|
+
description: "Read the header, re-render the managed blocks from it. Detects nothing."
|
|
1461
|
+
},
|
|
1462
|
+
args: {
|
|
1463
|
+
dir: {
|
|
1464
|
+
type: "positional",
|
|
1465
|
+
description: "Repository directory (default: the current one)",
|
|
1466
|
+
required: false,
|
|
1467
|
+
default: "."
|
|
1468
|
+
},
|
|
1469
|
+
detect: {
|
|
1470
|
+
type: "boolean",
|
|
1471
|
+
description: "Additionally propose header changes from the tree — never applies them",
|
|
1472
|
+
default: false
|
|
1473
|
+
},
|
|
1474
|
+
"dry-run": {
|
|
1475
|
+
type: "boolean",
|
|
1476
|
+
description: "Print the result instead of writing it",
|
|
1477
|
+
default: false
|
|
1478
|
+
}
|
|
1479
|
+
},
|
|
1480
|
+
run({ args }) {
|
|
1481
|
+
const dir = args.dir;
|
|
1482
|
+
const text = readGitignore(dir);
|
|
1483
|
+
const doc = parse(text);
|
|
1484
|
+
if (!doc.hasRegion) {
|
|
1485
|
+
consola.error(`${gitignorePath(dir)} has no managed region — run ${colors.cyan("gitignore-sync init")} first.`);
|
|
1486
|
+
process.exitCode = 1;
|
|
1487
|
+
return;
|
|
1488
|
+
}
|
|
1489
|
+
const result = reconcile(doc);
|
|
1490
|
+
const output = render(result.document);
|
|
1491
|
+
report(result);
|
|
1492
|
+
if (args.detect) {
|
|
1493
|
+
const declared = new Set(readStacks(doc));
|
|
1494
|
+
const missing = stacksOf(detect(dir)).filter((s) => !declared.has(s));
|
|
1495
|
+
if (missing.length === 0) consola.info("Detection proposes no header change.");
|
|
1496
|
+
else consola.info(`Detection suggests adding ${missing.map((s) => colors.cyan(s)).join(", ")} to the ${colors.cyan("# stacks:")} line. Not applied.`);
|
|
1497
|
+
}
|
|
1498
|
+
if (args["dry-run"]) {
|
|
1499
|
+
consola.log(output);
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
if (output === text) {
|
|
1503
|
+
consola.success(`${gitignorePath(dir)} is already in sync.`);
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
writeGitignore(dir, output);
|
|
1507
|
+
consola.success(`Wrote ${gitignorePath(dir)}`);
|
|
1508
|
+
}
|
|
1509
|
+
});
|
|
1510
|
+
//#endregion
|
|
1511
|
+
//#region src/cli.ts
|
|
1512
|
+
var rootCommand = defineCommand({
|
|
1513
|
+
meta: {
|
|
1514
|
+
name: "gitignore-sync",
|
|
1515
|
+
version: "0.1.0",
|
|
1516
|
+
description: "Keep a repo's .gitignore maintained: curated blocks in a managed region, re-rendered on demand"
|
|
1517
|
+
},
|
|
1518
|
+
subCommands: {
|
|
1519
|
+
init: initCommand,
|
|
1520
|
+
edit: editCommand,
|
|
1521
|
+
add: addCommand,
|
|
1522
|
+
remove: removeCommand,
|
|
1523
|
+
sync: syncCommand,
|
|
1524
|
+
check: checkCommand,
|
|
1525
|
+
list: listCommand,
|
|
1526
|
+
audit: auditCommand,
|
|
1527
|
+
info: infoCommand
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
//#endregion
|
|
1531
|
+
//#region src/bin/gitignore-sync.ts
|
|
1532
|
+
process.stdout.on("error", (error) => {
|
|
1533
|
+
if (error.code === "EPIPE") process.exit(0);
|
|
1534
|
+
throw error;
|
|
1535
|
+
});
|
|
1536
|
+
runMain(rootCommand);
|
|
1537
|
+
//#endregion
|
|
1538
|
+
|
|
1539
|
+
//# sourceMappingURL=gitignore-sync.mjs.map
|