@coderook/cli 0.29.4 → 0.30.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.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
5
- "version": "0.29.4",
5
+ "version": "0.30.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -18,6 +18,8 @@ exports.cancelMerge = cancelMerge;
18
18
  exports.aiAccess = aiAccess;
19
19
  exports.setAiAccess = setAiAccess;
20
20
  exports.updateProject = updateProject;
21
+ exports.compareVersions = compareVersions;
22
+ exports.fileTextAt = fileTextAt;
21
23
  exports.versions = versions;
22
24
  exports.markRelease = markRelease;
23
25
  exports.issues = issues;
@@ -313,7 +315,50 @@ async function updateProject(repositoryId, change) {
313
315
  body: change,
314
316
  });
315
317
  }
316
- /** Every saved version of a project, newest first. */
318
+ /**
319
+ * What changed between two saved versions.
320
+ *
321
+ * The service answers this from the stored trees, so an untouched folder deep
322
+ * in the project costs nothing: identical subtree ids short-circuit before
323
+ * either side is listed. That is why this asks the service rather than
324
+ * fetching both file lists and comparing them here — on a project of twenty
325
+ * thousand files the difference is one request against two very large ones.
326
+ */
327
+ async function compareVersions(repositoryId, from, to) {
328
+ const body = await call(`/v1/repositories/${repositoryId}/compare` +
329
+ `?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`);
330
+ return body.changes ?? [];
331
+ }
332
+ /**
333
+ * One file's contents at one version, as text.
334
+ *
335
+ * Returns null for anything that is not text — a diff of a PNG is noise, and
336
+ * deciding that here keeps every caller from having to guess. The byte cap is
337
+ * the same reasoning: past it a file is data, and a line-by-line comparison of
338
+ * data is neither readable nor quick.
339
+ */
340
+ async function fileTextAt(repositoryId, versionId, filePath, mostBytes = 2 * 1024 * 1024) {
341
+ const token = await (0, config_js_1.loadToken)();
342
+ if (!token)
343
+ throw new Error("Not signed in. Run: cbx sign-in");
344
+ const route = `${(0, config_js_1.apiOrigin)()}/v1/repositories/${repositoryId}` +
345
+ `/versions/${versionId}/file?path=${encodeURIComponent(filePath)}`;
346
+ const response = await fetch(route, {
347
+ headers: {
348
+ authorization: `Bearer ${token}`,
349
+ "user-agent": "CodeRook-CLI/0.1",
350
+ ...(0, identify_js_1.clientHeaders)(),
351
+ range: `bytes=0-${mostBytes - 1}`,
352
+ },
353
+ });
354
+ if (!response.ok)
355
+ return null;
356
+ const bytes = new Uint8Array(await response.arrayBuffer());
357
+ /* A NUL byte is the cheapest reliable sign that this is not text. */
358
+ if (bytes.includes(0))
359
+ return null;
360
+ return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
361
+ }
317
362
  async function versions(repositoryId) {
318
363
  const body = await call(`/v1/repositories/${encodeURIComponent(repositoryId)}/versions`);
319
364
  return (body.versions ?? []).map((row) => ({
@@ -35,11 +35,13 @@ const skill_command_js_1 = require("./skill_command.js");
35
35
  const licence_commands_js_1 = require("./licence_commands.js");
36
36
  const service_commands_js_1 = require("./service_commands.js");
37
37
  const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
38
+ const rules_js_1 = require("../../desktop-app/src/main/rules.js");
38
39
  const upload_js_1 = require("../../desktop-app/src/main/upload.js");
39
40
  const download_js_1 = require("../../desktop-app/src/main/download.js");
40
41
  const faults_js_1 = require("../../desktop-app/src/main/faults.js");
41
42
  const version_commands_js_1 = require("./version_commands.js");
42
43
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
44
+ const diff_command_js_1 = require("./diff_command.js");
43
45
  const profile_js_1 = require("../../desktop-app/src/main/profile.js");
44
46
  const detect_js_1 = require("../../desktop-app/src/main/detect.js");
45
47
  const cbx_js_1 = require("../../desktop-app/src/main/cbx.js");
@@ -348,7 +350,22 @@ async function commandStatus(parsed) {
348
350
  console.log(dim("Offline — comparing with the last Version cached for this folder."));
349
351
  }
350
352
  const rules = await (0, worktree_js_1.readRules)(folder);
351
- const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
353
+ /*
354
+ Scanned the way `cbx diff` scans, and reported the way `cbx submit` acts.
355
+
356
+ Status used to ask only for additions and updates, which is exactly what a
357
+ plain save sends — so it was not wrong. But a deleted file then appeared
358
+ nowhere: `cbx diff` showed it as removed, `cbx status` said nothing, and
359
+ the two commands disagreed about the same folder. Somebody who had deleted
360
+ a file read that as CodeRook not having noticed it.
361
+
362
+ So deletions are found and named here, kept apart from what a save will
363
+ send, with the flag that would send them spelled out. The safe default
364
+ does not change: a save adds and updates.
365
+ */
366
+ const found = await (0, worktree_js_1.changedFiles)(folder, rules, baseline, "synchronize");
367
+ const gone = found.filter((file) => file.deleted);
368
+ const files = found.filter((file) => !file.deleted);
352
369
  console.log(bold(node_path_1.default.basename(folder)) + dim(` ${folder}`));
353
370
  console.log(link
354
371
  ? `Account holds ${accent(`v${link.sequence}`)} · ${Object.keys(link.manifest).length} files ${dim(`(${link.slug})`)}`
@@ -369,18 +386,85 @@ async function commandStatus(parsed) {
369
386
  for (const [path] of stale.slice(0, 10))
370
387
  console.log(dim(` behind ${path}`));
371
388
  }
389
+ /** The deletions, and what a plain save will do about them. */
390
+ /*
391
+ What this folder is, and what its own rules never heard about.
392
+
393
+ A real .gitignore always wins — that is not negotiable — but it also meant
394
+ that a Unity project whose file was written before the engine rules
395
+ existed went on sending its entire import cache, and nothing anywhere
396
+ said so. One real upload of forty-seven thousand files was forty-five
397
+ thousand of rebuildable cache, and it broke the project on the machine it
398
+ was downloaded to.
399
+ */
400
+ const engine = async () => {
401
+ const { kind, missing } = await (0, worktree_js_1.missingEngineRules)(folder);
402
+ if (!kind.length || !missing.length)
403
+ return;
404
+ /*
405
+ Only the rules that would actually leave something out.
406
+
407
+ Naming every line the template has and this folder does not produced
408
+ sixty-odd of them, most matching nothing at all — a Visual Studio
409
+ template mentions `[Dd]ebug/x64/` whether or not you have one. What a
410
+ person needs is the two that are costing them forty thousand files, so
411
+ each candidate is measured against what this folder is currently
412
+ sending and the ones worth nothing are not mentioned.
413
+ */
414
+ const sending = found.map((one) => one.path);
415
+ const worth = missing
416
+ .map((rule) => {
417
+ const layers = [{ base: "", rules: (0, rules_js_1.parseRules)(rule) }];
418
+ return {
419
+ rule,
420
+ files: sending.filter((one) => (0, rules_js_1.excludes)(one, false, layers)).length,
421
+ };
422
+ })
423
+ .filter((one) => one.files > 0)
424
+ .sort((left, right) => right.files - left.files);
425
+ if (!worth.length)
426
+ return;
427
+ const total = worth.reduce((sum, one) => sum + one.files, 0);
428
+ console.log(dim(`\nThis looks like a ${kind.join(" + ")} project, and its own rules are ` +
429
+ `missing ${worth.length} that would leave out ` +
430
+ `${total.toLocaleString()} file${total === 1 ? "" : "s"} you are sending — ` +
431
+ `${accent("cbx ignore --template " + kind[0])} adds them.`));
432
+ for (const one of worth.slice(0, 6)) {
433
+ console.log(dim(` ${String(one.files).padStart(7)} ${one.rule}`));
434
+ }
435
+ };
436
+ const deletions = () => {
437
+ if (!gone.length)
438
+ return;
439
+ const many = gone.length === 1 ? "" : "s";
440
+ console.log(dim(`\n${gone.length} file${many} deleted here ${gone.length === 1 ? "is" : "are"} ` +
441
+ `still in the saved version. A save adds and updates, so ` +
442
+ `${gone.length === 1 ? "it stays" : "they stay"} until you pass ` +
443
+ `${accent("--sync")}.`));
444
+ for (const file of gone.slice(0, 10)) {
445
+ console.log(` ${red("deleted")} ${file.path}`);
446
+ }
447
+ if (gone.length > 10)
448
+ console.log(dim(` …and ${gone.length - 10} more`));
449
+ };
372
450
  if (!files.length) {
373
451
  console.log(stale.length
374
452
  ? "Nothing to submit; your own work is all saved."
375
- : "Nothing to submit; this folder matches the saved version.");
453
+ : gone.length
454
+ ? "Nothing here to add or update."
455
+ : "Nothing to submit; this folder matches the saved version.");
456
+ deletions();
457
+ await engine();
376
458
  return 0;
377
459
  }
378
460
  console.log(`\n${files.length} file${files.length === 1 ? "" : "s"} to submit:`);
379
461
  for (const file of files.slice(0, 50)) {
380
- console.log(` ${file.deleted ? red("deleted") : accent("changed")} ${file.path}`);
462
+ console.log(` ${accent("changed")} ${file.path}`);
381
463
  }
382
464
  if (files.length > 50)
383
465
  console.log(dim(` …and ${files.length - 50} more`));
466
+ deletions();
467
+ await engine();
384
468
  return 0;
385
469
  }
386
470
  /**
@@ -1310,11 +1394,14 @@ async function commandRules(parsed) {
1310
1394
  return suggestRules(folder, hasFlag(parsed, "apply"));
1311
1395
  }
1312
1396
  if (hasFlag(parsed, "init")) {
1397
+ /*
1398
+ Whatever `readRules` decided, which is now the template that matches the
1399
+ folder rather than one generic list. Writing STARTER_IGNORE here would
1400
+ have handed a Unity project the JavaScript rules, which is how an upload
1401
+ came back missing things inside Assets.
1402
+ */
1313
1403
  const existing = await (0, worktree_js_1.readRules)(folder);
1314
- await (0, worktree_js_1.writeRules)(folder, {
1315
- shared: existing.shared.trim() ? existing.shared : worktree_js_1.STARTER_IGNORE,
1316
- local: existing.local,
1317
- });
1404
+ await (0, worktree_js_1.writeRules)(folder, existing);
1318
1405
  console.log(`Wrote ${node_path_1.default.join(folder, ".gitignore")}`);
1319
1406
  return 0;
1320
1407
  }
@@ -1722,6 +1809,30 @@ const SPECS = [
1722
1809
  ],
1723
1810
  run: commandSubmit,
1724
1811
  },
1812
+ {
1813
+ name: "diff",
1814
+ aliases: ["changes"],
1815
+ group: "Working with a folder",
1816
+ summary: "what changed, line by line",
1817
+ usage: "diff [from] [to]",
1818
+ detail: "With nothing after it, compares this folder against the version it is\n" +
1819
+ "on. With one version, compares this folder against that one. With two,\n" +
1820
+ "compares two saved versions — which the service answers from its own\n" +
1821
+ "stored trees, so an untouched corner of a large project costs nothing.\n\n" +
1822
+ "A version can be given as a number, as v12, or by the name it was\n" +
1823
+ "released under.",
1824
+ options: [
1825
+ { flags: "--name-only", description: "list the files and not their lines" },
1826
+ { flags: "--path <text>", description: "only paths containing this" },
1827
+ {
1828
+ flags: "--context <n>",
1829
+ description: "lines of context around a change (default 3)",
1830
+ },
1831
+ { flags: "--project <name>", description: "which project" },
1832
+ ],
1833
+ examples: ["cbx diff", "cbx diff 12", "cbx diff 11 12 --name-only"],
1834
+ run: diff_command_js_1.commandDiff,
1835
+ },
1725
1836
  {
1726
1837
  name: "unlink",
1727
1838
  group: "Working with a folder",
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.commandDiff = commandDiff;
7
+ /**
8
+ * What changed, from the terminal.
9
+ *
10
+ * This was the one central thing the command line could not do. The service
11
+ * has been able to compare two versions since the beginning — GET /compare
12
+ * answers it from the stored trees — and nothing called the route. Meanwhile
13
+ * the desktop application's diff shells out to `git diff`, so on a project
14
+ * that is not a git repository the feature is silently absent, which is an odd
15
+ * thing for a version host to depend on.
16
+ *
17
+ * So this reads the service's own answer and renders it. Two questions, which
18
+ * people mean at different times:
19
+ *
20
+ * cbx diff what have I changed since the version I am on
21
+ * cbx diff 12 the same, against version 12
22
+ * cbx diff 11 12 what changed between two saved versions
23
+ */
24
+ const promises_1 = require("node:fs/promises");
25
+ const node_path_1 = __importDefault(require("node:path"));
26
+ const node_process_1 = __importDefault(require("node:process"));
27
+ const api_js_1 = require("./api.js");
28
+ const config_js_1 = require("./config.js");
29
+ const project_commands_js_1 = require("./project_commands.js");
30
+ const merge_diff_js_1 = require("../../desktop-app/src/shared/merge_diff.js");
31
+ const worktree_js_1 = require("../../desktop-app/src/main/worktree.js");
32
+ /*
33
+ Built rather than typed, so the escape character never sits literally in the
34
+ source. The other command files spell theirs out; this reads the same on a
35
+ terminal and survives being copied through a tool that eats control bytes.
36
+ */
37
+ const ESC = String.fromCharCode(27);
38
+ const paint = (code) => (value) => `${ESC}[${code}m${value}${ESC}[0m`;
39
+ const dim = paint("2");
40
+ const bold = paint("1");
41
+ const red = paint("31");
42
+ const green = paint("32");
43
+ const accent = paint("33");
44
+ /*
45
+ The line comparison builds a table of one side's lines against the other's,
46
+ which is exact and quadratic. On a conflicted file — what it was written for
47
+ — that is a few hundred kilobytes. On this repository's own eighteen-thousand
48
+ line runtime it would be gigabytes, so past this a file is reported as
49
+ changed without its lines. Saying so beats both wedging the terminal and
50
+ printing a heuristic diff that quietly invents a change.
51
+ */
52
+ const MOST_LINES_COMPARED = 4_000;
53
+ /** How a change reads at a glance. */
54
+ const MARK = {
55
+ added: green("+"),
56
+ removed: red("-"),
57
+ changed: accent("~"),
58
+ };
59
+ /** Print one file's lines, folded to the interesting parts. */
60
+ function printHunks(before, after, context) {
61
+ const left = (0, merge_diff_js_1.linesOf)(before);
62
+ const right = (0, merge_diff_js_1.linesOf)(after);
63
+ if (left.length > MOST_LINES_COMPARED || right.length > MOST_LINES_COMPARED) {
64
+ console.log(dim(` ${Math.max(left.length, right.length).toLocaleString()} lines — ` +
65
+ `too large to compare line by line`));
66
+ return;
67
+ }
68
+ for (const chunk of (0, merge_diff_js_1.foldUnchanged)((0, merge_diff_js_1.diffLines)(before, after), context)) {
69
+ if (chunk.kind === "gap") {
70
+ console.log(dim(` ... ${chunk.skipped} unchanged`));
71
+ continue;
72
+ }
73
+ for (const line of chunk.lines) {
74
+ const at = String(line.candidateLine ?? line.targetLine ?? "").padStart(5);
75
+ if (line.side === "same")
76
+ console.log(dim(`${at} ${line.text}`));
77
+ else if (line.side === "candidate")
78
+ console.log(`${dim(at)} ${green("+")} ${green(line.text)}`);
79
+ else
80
+ console.log(`${dim(at)} ${red("-")} ${red(line.text)}`);
81
+ }
82
+ }
83
+ }
84
+ /** Resolve `12`, `v12` or a version's name against the newest-first list. */
85
+ function versionBy(list, reference) {
86
+ const bare = reference.replace(/^v/i, "");
87
+ if (/^[0-9]+$/.test(bare)) {
88
+ const found = list.find((one) => one.sequence === Number(bare));
89
+ return found ? { id: found.id, sequence: found.sequence } : null;
90
+ }
91
+ const named = list.find((one) => (one.name ?? "").toLowerCase() === reference.toLowerCase());
92
+ return named ? { id: named.id, sequence: named.sequence } : null;
93
+ }
94
+ /** Whether these bytes are something a line comparison can speak about. */
95
+ function isText(value) {
96
+ return !value.includes(String.fromCharCode(0));
97
+ }
98
+ async function commandDiff(parsed) {
99
+ const nameOnly = parsed.flags.has("name-only");
100
+ const context = Number(parsed.flags.get("context") ?? 3) || 3;
101
+ const only = parsed.flags.get("path");
102
+ const named = parsed.flags.get("project");
103
+ const project = await (0, project_commands_js_1.resolveProject)(typeof named === "string" ? named : undefined);
104
+ if (!project)
105
+ return 1;
106
+ const list = await (0, api_js_1.versions)(project.id);
107
+ if (!list.length) {
108
+ console.log("Nothing saved yet, so there is nothing to compare.");
109
+ return 0;
110
+ }
111
+ const [first, second] = parsed.positional;
112
+ /*
113
+ Two saved versions, compared by the service. That is the whole point of
114
+ asking it rather than fetching both file lists: it walks the stored trees
115
+ and skips any subtree whose id has not moved, so an untouched corner of a
116
+ twenty-thousand-file project costs nothing.
117
+ */
118
+ if (first && second) {
119
+ const from = versionBy(list, first);
120
+ const to = versionBy(list, second);
121
+ if (!from || !to) {
122
+ console.error(red(`No such version: ${from ? second : first}`));
123
+ return 1;
124
+ }
125
+ const changes = (await (0, api_js_1.compareVersions)(project.id, from.id, to.id))
126
+ .filter((change) => !only || change.path.includes(String(only)))
127
+ .sort((left, right) => left.path.localeCompare(right.path));
128
+ console.log(`${bold(project.slug)} ${dim(`v${from.sequence} -> v${to.sequence}`)}`);
129
+ if (!changes.length) {
130
+ console.log("Nothing differs between them.");
131
+ return 0;
132
+ }
133
+ for (const change of changes) {
134
+ console.log(`${MARK[change.kind]} ${change.path}`);
135
+ if (nameOnly || change.kind !== "changed")
136
+ continue;
137
+ const [before, after] = await Promise.all([
138
+ (0, api_js_1.fileTextAt)(project.id, from.id, change.path),
139
+ (0, api_js_1.fileTextAt)(project.id, to.id, change.path),
140
+ ]);
141
+ if (before === null || after === null) {
142
+ console.log(dim(" not text"));
143
+ continue;
144
+ }
145
+ printHunks(before, after, context);
146
+ }
147
+ const many = changes.length === 1 ? "" : "s";
148
+ console.log(dim(`\n${changes.length} file${many} differ${many ? "" : "s"}`));
149
+ return 0;
150
+ }
151
+ /*
152
+ This folder against a saved version — the question somebody has open in
153
+ front of them, and the reason `cbx status` was never enough on its own: it
154
+ says which files changed and never what changed inside them.
155
+ */
156
+ const link = await (0, config_js_1.readLink)(node_process_1.default.cwd());
157
+ if (!link) {
158
+ console.error(red("This folder is not linked to a project, so there is no local side."));
159
+ return 1;
160
+ }
161
+ const against = first
162
+ ? versionBy(list, first)
163
+ : { id: link.baseVersionId ?? list[0].id, sequence: link.sequence };
164
+ if (!against) {
165
+ console.error(red(`No such version: ${first}`));
166
+ return 1;
167
+ }
168
+ const folder = node_process_1.default.cwd();
169
+ const rules = await (0, worktree_js_1.readRules)(folder);
170
+ /*
171
+ Which files differ is decided here, from digests, before anything is
172
+ fetched.
173
+
174
+ The first version of this compared contents, which meant downloading every
175
+ file in the project to find out which ones had moved — on this repository
176
+ that is eleven hundred requests to report a handful of edits. `changedFiles`
177
+ already answers the question locally and keeps a digest cache while it does
178
+ it, so the service is asked only for the files that actually differ.
179
+ */
180
+ const saved = link.manifest ?? {};
181
+ const changed = (await (0, worktree_js_1.changedFiles)(folder, rules, new Map(Object.entries(saved)), "synchronize"))
182
+ .filter((one) => !only || one.path.includes(String(only)))
183
+ .sort((left, right) => left.path.localeCompare(right.path));
184
+ console.log(`${bold(node_path_1.default.basename(folder))} ${dim(`this folder -> v${against.sequence}`)}`);
185
+ let differing = 0;
186
+ for (const one of changed) {
187
+ const wasSaved = Object.prototype.hasOwnProperty.call(saved, one.path);
188
+ const kind = one.deleted
189
+ ? "removed"
190
+ : wasSaved
191
+ ? "changed"
192
+ : "added";
193
+ differing += 1;
194
+ console.log(`${MARK[kind]} ${one.path}`);
195
+ if (nameOnly || kind !== "changed")
196
+ continue;
197
+ if (one.binary) {
198
+ console.log(dim(" not text"));
199
+ continue;
200
+ }
201
+ const [before, after] = await Promise.all([
202
+ (0, api_js_1.fileTextAt)(project.id, against.id, one.path),
203
+ (0, promises_1.readFile)(node_path_1.default.join(folder, one.path), "utf8").catch(() => null),
204
+ ]);
205
+ if (before === null || after === null || !isText(after)) {
206
+ console.log(dim(" not text"));
207
+ continue;
208
+ }
209
+ printHunks(before, after, context);
210
+ }
211
+ if (!differing) {
212
+ console.log("This folder matches the saved version.");
213
+ return 0;
214
+ }
215
+ const many = differing === 1 ? "" : "s";
216
+ console.log(dim(`\n${differing} file${many} differ${many ? "" : "s"}`));
217
+ return 0;
218
+ }
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.STARTER_TOPUP_HEADING = exports.SUGGESTED_HEADING = void 0;
7
+ exports.templatesFor = templatesFor;
6
8
  exports.suggestExclusions = suggestExclusions;
7
9
  exports.suggestBulkDirectories = suggestBulkDirectories;
8
10
  exports.rulesFromSuggestions = rulesFromSuggestions;
@@ -121,6 +123,83 @@ const norm = (value) => value.split("\\").join("/");
121
123
  * their upload will take and the biggest item answers it. Anything matching
122
124
  * nothing is simply absent — silence is the right output for a tidy folder.
123
125
  */
126
+ /**
127
+ * Which of github/gitignore's templates this folder is asking for.
128
+ *
129
+ * The templates have been vendored all along — three hundred of them, taken
130
+ * from github/gitignore — but nothing ever chose one. A folder with no rules
131
+ * of its own got the generic starter instead, and for a game project that is
132
+ * the wrong list: it carries `build/`, `dist/`, `out/` and `models/**` from
133
+ * the JavaScript and Python worlds, none of which are anchored, all of which
134
+ * can reach into somewhere like `Assets/Art/models`.
135
+ *
136
+ * Recognised from the files a project must have to be that kind of project,
137
+ * rather than from anything a person configures. Order matters: an engine is
138
+ * decided first, because a Unity project also has a package.json somewhere
139
+ * and the engine's own template is the one that knows about `Assets`.
140
+ */
141
+ function templatesFor(entries) {
142
+ const here = new Set(entries.map((name) => name.toLowerCase()));
143
+ const any = (test) => entries.some(test);
144
+ const picked = [];
145
+ const take = (key) => {
146
+ if (!picked.includes(key))
147
+ picked.push(key);
148
+ };
149
+ /* An engine first, and at most one: their templates overlap and disagree. */
150
+ if (here.has("assets") && here.has("projectsettings"))
151
+ take("unity");
152
+ else if (any((name) => name.toLowerCase().endsWith(".uproject"))) {
153
+ take("unrealengine");
154
+ }
155
+ else if (here.has("project.godot"))
156
+ take("godot");
157
+ /*
158
+ Cocos before Node, and this order is the whole reason it is written down.
159
+
160
+ A Cocos project carries a package.json, so recognising Node first called
161
+ it a Node project and handed it Node's rules — which know nothing about
162
+ `library` or `temp`, the caches Cocos rebuilds. Measured on the soak
163
+ shape: eight and a half thousand cache files went from left out to sent.
164
+ */
165
+ else if (here.has("project.json") && here.has("assets"))
166
+ take("cocos");
167
+ /* Then the languages, of which a folder may honestly be several. */
168
+ if (here.has("package.json"))
169
+ take("node");
170
+ if (here.has("pyproject.toml") ||
171
+ here.has("requirements.txt") ||
172
+ here.has("setup.py")) {
173
+ take("python");
174
+ }
175
+ if (here.has("cargo.toml"))
176
+ take("rust");
177
+ if (here.has("go.mod"))
178
+ take("go");
179
+ if (here.has("build.gradle") || here.has("build.gradle.kts"))
180
+ take("gradle");
181
+ /*
182
+ An engine's own project files are not a second kind of project.
183
+
184
+ Unity writes the .sln and the .csproj itself, and every engine here does
185
+ something similar. Reading that as "also a Visual Studio project" and
186
+ stacking GitHub's template on top is not a harmless extra: that template
187
+ ignores `*.meta`, which in the .NET world is build output and in Unity is
188
+ the sidecar carrying every asset's GUID. On a real project it would have
189
+ dropped 13,689 of them, and a Unity project without its metas is exactly
190
+ the broken-materials mess this was all started by.
191
+ */
192
+ const engine = picked.length > 0;
193
+ if (!engine && any((name) => name.toLowerCase().endsWith(".sln"))) {
194
+ take("visualstudio");
195
+ }
196
+ if (!engine && any((name) => name.toLowerCase().endsWith(".xcodeproj"))) {
197
+ take("objective-c");
198
+ }
199
+ if (!engine && here.has("cmakelists.txt"))
200
+ take("cmake");
201
+ return picked;
202
+ }
124
203
  function suggestExclusions(files) {
125
204
  const totals = new Map();
126
205
  for (const entry of files) {
@@ -267,12 +346,19 @@ function suggestBulkDirectories(files) {
267
346
  * looking: the detector proposes, this records a decision, and the two are
268
347
  * only connected by somebody choosing.
269
348
  */
349
+ /**
350
+ * The one heading under which accepted suggestions are recorded.
351
+ *
352
+ * Exported because three places wrote three different sentences for the same
353
+ * idea, and a .gitignore could end up carrying all of them. The tick screen
354
+ * keeps its own CHOSEN_HEADING: that block is a different statement, and it is
355
+ * rewritten wholesale where this one is added to.
356
+ */
357
+ exports.SUGGESTED_HEADING = "# Suggested for this folder, and accepted.";
358
+ /** And the one under which the starter rules are brought up to date. */
359
+ exports.STARTER_TOPUP_HEADING = "# Added when the starter rules were brought up to date.";
270
360
  function rulesFromSuggestions(accepted) {
271
361
  if (!accepted.length)
272
362
  return "";
273
- return [
274
- "# Suggested when this folder was added, and accepted.",
275
- ...accepted.map((one) => one.pattern),
276
- "",
277
- ].join("\n");
363
+ return [exports.SUGGESTED_HEADING, ...accepted.map((one) => one.pattern), ""].join("\n");
278
364
  }
@@ -7,6 +7,7 @@ exports.parseRules = parseRules;
7
7
  exports.ruleMatches = ruleMatches;
8
8
  exports.lastMatchingRule = lastMatchingRule;
9
9
  exports.decide = decide;
10
+ exports.decidingRule = decidingRule;
10
11
  exports.excludes = excludes;
11
12
  exports.negationReachesInto = negationReachesInto;
12
13
  exports.isIgnored = isIgnored;
@@ -198,7 +199,21 @@ function decide(relativePath, isDirectory, layers) {
198
199
  * is the dangerous direction to differ in: it uploads what somebody thought
199
200
  * they had excluded.
200
201
  */
201
- function excludes(relativePath, isDirectory, layers) {
202
+ /**
203
+ * The rule that settles this path, counting the directories above it.
204
+ *
205
+ * A directory rule is written about the directory — `/[Ll]ibrary/` matches
206
+ * `Library`, not `Library/Artifacts/f1.dat` — so anything asking only about
207
+ * the file finds no rule and calls it included. The upload has always walked
208
+ * the ancestors; the filter screen asked about the file alone, and so showed
209
+ * a Unity project's whole import cache as going up when the save would have
210
+ * left every byte of it behind.
211
+ *
212
+ * Returned as the rule rather than a yes or no, because the screen names the
213
+ * line responsible — "Library/ — 400 files" is the useful form, and a boolean
214
+ * cannot say it.
215
+ */
216
+ function decidingRule(relativePath, isDirectory, layers) {
202
217
  /*
203
218
  Ancestors first, outermost in. The moment one is excluded the answer is
204
219
  settled and nothing below it can appeal.
@@ -208,9 +223,12 @@ function excludes(relativePath, isDirectory, layers) {
208
223
  const ancestor = parts.slice(0, depth).join("/");
209
224
  const above = decide(ancestor, true, layers);
210
225
  if (above && !above.negated)
211
- return true;
226
+ return above;
212
227
  }
213
- const rule = decide(relativePath, isDirectory, layers);
228
+ return decide(relativePath, isDirectory, layers);
229
+ }
230
+ function excludes(relativePath, isDirectory, layers) {
231
+ const rule = decidingRule(relativePath, isDirectory, layers);
214
232
  return Boolean(rule && !rule.negated);
215
233
  }
216
234
  /**