@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.
@@ -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