@coderook/cli 0.30.1 → 0.30.2

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.30.1",
5
+ "version": "0.30.2",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -51,6 +51,21 @@ exports.collisionCheck = collisionCheck;
51
51
  /** The small part of the API the command-line tool needs directly. */
52
52
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
53
53
  const config_js_1 = require("./config.js");
54
+ /**
55
+ * How long one request may go unanswered before it is treated as lost.
56
+ *
57
+ * Nothing here had a deadline, so a request the service never answered
58
+ * waited for ever. A real save of a 38,913-file project stopped dead on one
59
+ * open connection with no bytes moving in either direction and no CPU, and
60
+ * said nothing at all — there is no output to watch, because the client is
61
+ * inside `await fetch`.
62
+ *
63
+ * Three minutes is far longer than any of these calls takes and short enough
64
+ * that a save cannot sit silent all afternoon. A deadline that fires is an
65
+ * ordinary error, so it reaches whatever the caller does about errors rather
66
+ * than ending the run.
67
+ */
68
+ const REQUEST_DEADLINE_MS = 180_000;
54
69
  async function call(route, options = {}) {
55
70
  const token = await (0, config_js_1.loadToken)();
56
71
  if (!token) {
@@ -66,6 +81,7 @@ async function call(route, options = {}) {
66
81
  ...(options.body ? { "content-type": "application/json" } : {}),
67
82
  },
68
83
  body: options.body ? JSON.stringify(options.body) : undefined,
84
+ signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
69
85
  });
70
86
  const text = await response.text();
71
87
  const body = text ? JSON.parse(text) : {};
@@ -99,6 +115,7 @@ async function send(route, method, bytes, contentType) {
99
115
  "content-length": String(bytes.byteLength),
100
116
  },
101
117
  body: bytes,
118
+ signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
102
119
  });
103
120
  const text = await response.text();
104
121
  const body = text ? JSON.parse(text) : {};
@@ -174,7 +191,10 @@ async function projects() {
174
191
  */
175
192
  async function findPublicProject(owner, slug) {
176
193
  try {
177
- const response = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/public/projects/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`, { headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() } });
194
+ const response = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/public/projects/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`, {
195
+ headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
196
+ signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
197
+ });
178
198
  if (!response.ok)
179
199
  return null;
180
200
  const row = (await response.json());
@@ -240,6 +260,8 @@ async function findProject(reference) {
240
260
  async function health() {
241
261
  const response = await fetch(`${(0, config_js_1.apiOrigin)()}/health?t=${Date.now()}`, {
242
262
  headers: { "cache-control": "no-cache" },
263
+ /* A health check that hangs is the one call where waiting is absurd. */
264
+ signal: AbortSignal.timeout(15_000),
243
265
  });
244
266
  return (await response.json());
245
267
  }
@@ -350,6 +372,7 @@ async function fileTextAt(repositoryId, versionId, filePath, mostBytes = 2 * 102
350
372
  ...(0, identify_js_1.clientHeaders)(),
351
373
  range: `bytes=0-${mostBytes - 1}`,
352
374
  },
375
+ signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
353
376
  });
354
377
  if (!response.ok)
355
378
  return null;
@@ -42,6 +42,11 @@ const progress_js_1 = require("./progress.js");
42
42
  const HEARTBEAT_MS = 30_000;
43
43
  /** How often log lines are sent up while a command is running. */
44
44
  const FLUSH_MS = 2_000;
45
+ /**
46
+ * The same deadline the rest of the client uses, so nothing here can wait for
47
+ * ever on a request the service never answers.
48
+ */
49
+ const REQUEST_DEADLINE_MS = 180_000;
45
50
  async function call(route, options = {}) {
46
51
  const token = await (0, config_js_1.loadToken)();
47
52
  if (!token)
@@ -55,6 +60,7 @@ async function call(route, options = {}) {
55
60
  ...(options.body ? { "content-type": "application/json" } : {}),
56
61
  },
57
62
  body: options.body ? JSON.stringify(options.body) : undefined,
63
+ signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
58
64
  });
59
65
  const text = await response.text();
60
66
  const body = text ? JSON.parse(text) : {};
@@ -221,6 +227,7 @@ async function uploadArtifacts(repositoryId, runId, workspace, patterns, say) {
221
227
  const stored = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/repositories/${repositoryId}` +
222
228
  `/objects/${digest}?kind=chunk&role=chunk`, {
223
229
  method: "PUT",
230
+ signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
224
231
  headers: {
225
232
  authorization: `Bearer ${token}`,
226
233
  "content-type": "application/octet-stream",
@@ -71,7 +71,11 @@ async function readJson(url, token, forge) {
71
71
  headers.authorization =
72
72
  forge.kind === "gitlab" ? `Bearer ${token}` : `token ${token}`;
73
73
  }
74
- const response = await fetch(url, { headers });
74
+ /* A forge that stops answering must not hold the import open for ever. */
75
+ const response = await fetch(url, {
76
+ headers,
77
+ signal: AbortSignal.timeout(60_000),
78
+ });
75
79
  if (!response.ok) {
76
80
  throw new Error(`${response.status} from ${new URL(url).pathname}`);
77
81
  }
@@ -265,9 +265,16 @@ function suggestExclusions(files) {
265
265
  Merged in here rather than offered through a second channel, so everything
266
266
  that shows a suggestion keeps working unchanged. A folder already named by
267
267
  a rule above is dropped: it has a better reason than "no source in it".
268
+
269
+ Matched on the folder, not on how the line is written. The rules emit an
270
+ anchored `/Library/` and this emits `Library/`, so comparing the patterns
271
+ as text found no overlap and a Unity project was offered its own import
272
+ cache twice over — once as "the editor rebuilds this" and once, below it,
273
+ as "37,000 files and no source in it".
268
274
  */
269
- const already = new Set(named.map((one) => one.pattern));
270
- const bulk = suggestBulkDirectories(files).filter((one) => !already.has(one.pattern));
275
+ const asFolder = (pattern) => pattern.replace(/^\/+|\/+$/g, "").toLowerCase();
276
+ const already = new Set(named.map((one) => asFolder(one.pattern)));
277
+ const bulk = suggestBulkDirectories(files).filter((one) => !already.has(asFolder(one.pattern)));
271
278
  return [...named, ...bulk].sort((left, right) => right.bytes - left.bytes);
272
279
  }
273
280
  /*
@@ -200,39 +200,7 @@ function decide(relativePath, isDirectory, layers) {
200
200
  }
201
201
  return found;
202
202
  }
203
- /**
204
- * Whether these layers exclude the path, negations included.
205
- *
206
- * An excluded directory takes everything under it, whatever a later negation
207
- * says. This is git's rule — "it is not possible to re-include a file if a
208
- * parent directory of that file is excluded" — and it is followed here for a
209
- * plain reason: the file is called .gitignore, git reads it too, and a rule
210
- * that means one thing to one reader and something else to the other is worse
211
- * than either behaviour on its own.
212
- *
213
- * It was the other way once, deliberately, on the reasoning that somebody
214
- * writing `!` meant it. What that produced was a project excluding a folder
215
- * and publishing two files out of it anyway, because an unrelated
216
- * `!.env.example` further down reached inside — git had ignored them, so
217
- * nothing else in the toolchain gave any sign. Being more permissive than git
218
- * is the dangerous direction to differ in: it uploads what somebody thought
219
- * they had excluded.
220
- */
221
- /**
222
- * The rule that settles this path, counting the directories above it.
223
- *
224
- * A directory rule is written about the directory — `/[Ll]ibrary/` matches
225
- * `Library`, not `Library/Artifacts/f1.dat` — so anything asking only about
226
- * the file finds no rule and calls it included. The upload has always walked
227
- * the ancestors; the filter screen asked about the file alone, and so showed
228
- * a Unity project's whole import cache as going up when the save would have
229
- * left every byte of it behind.
230
- *
231
- * Returned as the rule rather than a yes or no, because the screen names the
232
- * line responsible — "Library/ — 400 files" is the useful form, and a boolean
233
- * cannot say it.
234
- */
235
- function decidingRule(relativePath, isDirectory, layers) {
203
+ function decidingRule(relativePath, isDirectory, layers, remembered) {
236
204
  /*
237
205
  Ancestors first, outermost in. The moment one is excluded the answer is
238
206
  settled and nothing below it can appeal.
@@ -240,7 +208,14 @@ function decidingRule(relativePath, isDirectory, layers) {
240
208
  const parts = relativePath.split("/");
241
209
  for (let depth = 1; depth < parts.length; depth += 1) {
242
210
  const ancestor = parts.slice(0, depth).join("/");
243
- const above = decide(ancestor, true, layers);
211
+ let above;
212
+ if (remembered && remembered.has(ancestor)) {
213
+ above = remembered.get(ancestor) ?? null;
214
+ }
215
+ else {
216
+ above = decide(ancestor, true, layers);
217
+ remembered?.set(ancestor, above);
218
+ }
244
219
  if (above && !above.negated)
245
220
  return above;
246
221
  }
@@ -250,6 +225,29 @@ function excludes(relativePath, isDirectory, layers) {
250
225
  const rule = decidingRule(relativePath, isDirectory, layers);
251
226
  return Boolean(rule && !rule.negated);
252
227
  }
228
+ /**
229
+ * Whether an anchored pattern could name anything inside this directory.
230
+ *
231
+ * Walked a segment at a time against the directory's own segments. A `**`
232
+ * makes everything below it possible and settles the question; a segment that
233
+ * simply does not match settles it the other way. Where the pattern runs out
234
+ * first it is left as possible, because this only ever needs to be certain
235
+ * when it says no.
236
+ */
237
+ function couldDescendTo(pattern, directory) {
238
+ const wanted = pattern.replace(/^\/+/, "").replace(/\/+$/, "").split("/");
239
+ const here = directory.split("/").filter(Boolean);
240
+ for (let at = 0; at < here.length; at += 1) {
241
+ const part = wanted[at];
242
+ if (part === undefined)
243
+ return true;
244
+ if (part === "**")
245
+ return true;
246
+ if (!matcher(part).test(here[at]))
247
+ return false;
248
+ }
249
+ return true;
250
+ }
253
251
  /**
254
252
  * Whether a negation could rescue something inside this directory.
255
253
  *
@@ -269,7 +267,22 @@ function negationReachesInto(relativePath, layers) {
269
267
  // A bare glob is matched against every segment, so it reaches anywhere.
270
268
  if (!rule.anchored && !rule.pattern.includes("/"))
271
269
  return true;
272
- // A wildcard that spans separators can descend to any depth.
270
+ // A ** used to end the question here, and that one line was the
271
+ // reason a scan read every directory in the project.
272
+ //
273
+ // The starter carries the negation that keeps Unity's meta files, and
274
+ // it is written with a ** in the middle, so `includes` said yes to
275
+ // it for every directory on disk. `Library/`, excluded and 45,000
276
+ // files deep, was descended into on every scan of every Unity
277
+ // project, because a rule about `Assets` might conceivably have
278
+ // reached in.
279
+ //
280
+ // It cannot: the pattern is anchored and its first segment is
281
+ // `[Aa]ssets`, which does not match `Library`. Comparing segment by
282
+ // segment answers that exactly, and stops only where a **
283
+ // genuinely makes the rest unknowable.
284
+ if (!couldDescendTo(`${prefix}${rule.pattern}`, relativePath))
285
+ continue;
273
286
  if (rule.pattern.includes("**"))
274
287
  return true;
275
288
  const target = `${prefix}${rule.pattern.replace(/\/+$/, "")}`;
@@ -92,6 +92,15 @@ async function digestOf(full) {
92
92
  hash.update(chunk);
93
93
  return hash.digest("hex");
94
94
  }
95
+ /**
96
+ * How long one request may go unanswered before it is treated as lost.
97
+ *
98
+ * Three minutes, which is far longer than any request this makes has taken
99
+ * and short enough that a save cannot sit silent all afternoon. What follows
100
+ * a deadline is a retry, not a failure, so the cost of it firing early on a
101
+ * genuinely slow call is one repeated request.
102
+ */
103
+ const REQUEST_DEADLINE_MS = 180_000;
95
104
  class UploadCancelled extends Error {
96
105
  constructor() {
97
106
  super("Upload cancelled");
@@ -220,7 +229,24 @@ class Uploader {
220
229
  ...(init.contentType ? { "content-type": init.contentType } : {}),
221
230
  },
222
231
  body: init.body,
223
- signal: this.controller.signal,
232
+ /*
233
+ The upload's own cancellation, and a deadline.
234
+
235
+ This carried only the cancel signal, so a request the service never
236
+ answered waited for ever: a real save of a 38,913-file project stopped
237
+ dead with one open connection, no bytes moving either way and no CPU,
238
+ and nothing anywhere said so. `worthRetrying` has always named "a
239
+ request that timed out" as the case worth repeating — there was simply
240
+ no way for one to time out, so the branch could not be reached.
241
+
242
+ Generous on purpose. The service legitimately spends tens of seconds
243
+ on a large batch, and a deadline tight enough to cut that short would
244
+ turn slow saves into failing ones.
245
+ */
246
+ signal: AbortSignal.any([
247
+ this.controller.signal,
248
+ AbortSignal.timeout(REQUEST_DEADLINE_MS),
249
+ ]),
224
250
  });
225
251
  const text = await response.text();
226
252
  this.telemetry.request(route.split("?")[0] ?? route, init.body, new TextEncoder().encode(text).byteLength, performance.now() - started);
@@ -21,6 +21,8 @@ exports.surveyFiles = surveyFiles;
21
21
  exports.totalSize = totalSize;
22
22
  exports.fileDiff = fileDiff;
23
23
  exports.projectTree = projectTree;
24
+ exports.treeFrom = treeFrom;
25
+ exports.walkProject = walkProject;
24
26
  exports.evaluateRules = evaluateRules;
25
27
  exports.detectPrivateDirectories = detectPrivateDirectories;
26
28
  exports.detectPastedCredentials = detectPastedCredentials;
@@ -871,56 +873,85 @@ async function surveyFiles(root, rules,
871
873
  onFound) {
872
874
  const layers = await collectLayers(root, rules);
873
875
  const found = [];
874
- const pending = [root];
875
- while (pending.length) {
876
- const directory = pending.pop();
877
- let entries;
878
- try {
879
- entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
880
- }
881
- catch {
882
- continue;
883
- }
884
- for (const entry of entries) {
885
- const full = node_path_1.default.join(directory, entry.name);
886
- if (entry.isSymbolicLink())
887
- continue;
888
- if ((0, rules_js_1.isUncounted)(entry.name))
889
- continue;
890
- const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
891
- if (entry.isDirectory()) {
892
- if ((0, rules_js_1.excludes)(relative, true, layers) &&
893
- !(0, rules_js_1.negationReachesInto)(relative, layers)) {
876
+ /*
877
+ A level at a time, eight in flight.
878
+
879
+ Asking the disk for one thing and waiting for the answer before asking
880
+ for the next is most of what this cost: the same walk with eight lanes
881
+ open is about four times quicker, and a ninth buys nothing. The order the
882
+ answers arrive in does not matter because the list is sorted at the end.
883
+ */
884
+ let level = [root];
885
+ while (level.length) {
886
+ const next = [];
887
+ let at = 0;
888
+ const lane = async () => {
889
+ while (at < level.length) {
890
+ const directory = level[at];
891
+ at += 1;
892
+ let entries;
893
+ try {
894
+ entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
895
+ }
896
+ catch {
894
897
  continue;
895
898
  }
896
- pending.push(full);
897
- continue;
898
- }
899
- if (!entry.isFile())
900
- continue;
901
- if ((0, rules_js_1.excludes)(relative, false, layers))
902
- continue;
903
- try {
904
- found.push({ path: relative, size: (await (0, promises_1.stat)(full)).size });
905
- if (onFound && found.length % 500 === 0)
906
- onFound(found.length);
907
- }
908
- catch {
909
- /* Gone or unreadable since the walk began; the upload reports it. */
899
+ const here = [];
900
+ for (const entry of entries) {
901
+ const full = node_path_1.default.join(directory, entry.name);
902
+ if (entry.isSymbolicLink())
903
+ continue;
904
+ if ((0, rules_js_1.isUncounted)(entry.name))
905
+ continue;
906
+ const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
907
+ if (entry.isDirectory()) {
908
+ if ((0, rules_js_1.excludes)(relative, true, layers) &&
909
+ !(0, rules_js_1.negationReachesInto)(relative, layers)) {
910
+ continue;
911
+ }
912
+ next.push(full);
913
+ continue;
914
+ }
915
+ if (!entry.isFile())
916
+ continue;
917
+ if ((0, rules_js_1.excludes)(relative, false, layers))
918
+ continue;
919
+ here.push({ full, relative });
920
+ }
921
+ for (let from = 0; from < here.length; from += WALK_LANES) {
922
+ const sized = await Promise.all(here.slice(from, from + WALK_LANES).map(async (one) => {
923
+ try {
924
+ return { path: one.relative, size: (await (0, promises_1.stat)(one.full)).size };
925
+ }
926
+ catch {
927
+ /* Gone or unreadable since the walk began; the upload says so. */
928
+ return null;
929
+ }
930
+ }));
931
+ for (const one of sized) {
932
+ if (!one)
933
+ continue;
934
+ found.push(one);
935
+ if (onFound && found.length % 500 === 0)
936
+ onFound(found.length);
937
+ }
938
+ }
910
939
  }
911
- }
940
+ };
941
+ await Promise.all(Array.from({ length: Math.min(WALK_LANES, level.length) }, () => lane()));
942
+ level = next;
912
943
  }
913
944
  return found.sort((left, right) => left.path.localeCompare(right.path));
914
945
  }
915
946
  async function totalSize(root, files) {
916
947
  let total = 0;
917
- for (const file of files) {
918
- try {
919
- total += (await (0, promises_1.stat)(node_path_1.default.join(root, file.path))).size;
920
- }
921
- catch {
922
- /* deleted since the scan; it contributes nothing */
923
- }
948
+ /* Same reason as the walk: these are all waiting, so let them wait together. */
949
+ for (let from = 0; from < files.length; from += WALK_LANES) {
950
+ const sizes = await Promise.all(files.slice(from, from + WALK_LANES).map((file) => (0, promises_1.stat)(node_path_1.default.join(root, file.path)).then((info) => info.size,
951
+ /* deleted since the scan; it contributes nothing */
952
+ () => 0)));
953
+ for (const size of sizes)
954
+ total += size;
924
955
  }
925
956
  return total;
926
957
  }
@@ -1018,71 +1049,168 @@ async function fileDiff(root, file, ignoreWhitespace = false) {
1018
1049
  * than by writing glob patterns. Sizes are rolled up, because the only way
1019
1050
  * to decide whether to keep a folder is to know what it costs.
1020
1051
  */
1021
- async function projectTree(root, limit = exports.EVALUATION_FILE_LIMIT) {
1022
- let seen = 0;
1023
- const walk = async (directory, relative) => {
1052
+ /**
1053
+ * The folder as a tree, built from one walk rather than another descent.
1054
+ *
1055
+ * It used to recurse the disk itself, in step with an evaluation doing the
1056
+ * same descent alongside it — two serial passes over a folder that on a real
1057
+ * Unity project holds 47,387 files. Now both are worked out from a single
1058
+ * concurrent walk, so the filter screen reads the disk once.
1059
+ */
1060
+ async function projectTree(root, limitOrWalked = exports.EVALUATION_FILE_LIMIT) {
1061
+ const walked = typeof limitOrWalked === "number"
1062
+ ? await walkProject(root, { limit: limitOrWalked })
1063
+ : limitOrWalked;
1064
+ return treeFrom(walked);
1065
+ }
1066
+ /** Fold the walk into nodes, carrying sizes and counts up as it goes. */
1067
+ function treeFrom(walked) {
1068
+ const root = {
1069
+ path: "",
1070
+ name: ".",
1071
+ directory: true,
1072
+ size: 0,
1073
+ files: 0,
1074
+ children: [],
1075
+ ...(walked.truncated ? { truncated: true } : {}),
1076
+ };
1077
+ const folders = new Map([["", root]]);
1078
+ const folderAt = (relative) => {
1079
+ const held = folders.get(relative);
1080
+ if (held)
1081
+ return held;
1082
+ const cut = relative.lastIndexOf("/");
1083
+ const parent = folderAt(cut < 0 ? "" : relative.slice(0, cut));
1024
1084
  const node = {
1025
1085
  path: relative,
1026
- name: relative ? relative.slice(relative.lastIndexOf("/") + 1) : ".",
1086
+ name: relative.slice(cut + 1),
1027
1087
  directory: true,
1028
1088
  size: 0,
1029
1089
  files: 0,
1030
1090
  children: [],
1031
1091
  };
1032
- let entries;
1033
- try {
1034
- entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
1035
- }
1036
- catch {
1037
- return node;
1038
- }
1039
- for (const entry of entries) {
1040
- if (entry.isSymbolicLink())
1041
- continue;
1042
- if ((0, rules_js_1.isUncounted)(entry.name))
1043
- continue;
1044
- const full = node_path_1.default.join(directory, entry.name);
1045
- const childPath = relative ? `${relative}/${entry.name}` : entry.name;
1046
- if (entry.isDirectory()) {
1047
- const child = await walk(full, childPath);
1048
- node.children.push(child);
1049
- node.size += child.size;
1050
- node.files += child.files;
1051
- continue;
1052
- }
1053
- if (!entry.isFile())
1054
- continue;
1055
- seen += 1;
1056
- if (seen > limit) {
1057
- node.truncated = true;
1058
- break;
1059
- }
1060
- let size = 0;
1061
- try {
1062
- size = (await (0, promises_1.stat)(full)).size;
1063
- }
1064
- catch {
1065
- continue;
1066
- }
1067
- node.children.push({
1068
- path: childPath,
1069
- name: entry.name,
1070
- directory: false,
1071
- size,
1072
- files: 1,
1073
- });
1074
- node.size += size;
1075
- node.files += 1;
1092
+ parent.children.push(node);
1093
+ folders.set(relative, node);
1094
+ return node;
1095
+ };
1096
+ /* Empty folders are folders, and a tree that hides them is a different
1097
+ folder from the one on disk. */
1098
+ for (const directory of walked.directories)
1099
+ folderAt(directory);
1100
+ for (const file of walked.files) {
1101
+ const cut = file.path.lastIndexOf("/");
1102
+ const parent = folderAt(cut < 0 ? "" : file.path.slice(0, cut));
1103
+ parent.children.push({
1104
+ path: file.path,
1105
+ name: file.path.slice(cut + 1),
1106
+ directory: false,
1107
+ size: file.size,
1108
+ files: 1,
1109
+ });
1110
+ /* Up the chain, so a folder can be judged without opening it. */
1111
+ for (let at = parent; at;) {
1112
+ at.size += file.size;
1113
+ at.files += 1;
1114
+ const cutAt = at.path.lastIndexOf("/");
1115
+ at = at.path === "" ? undefined : folders.get(cutAt < 0 ? "" : at.path.slice(0, cutAt));
1076
1116
  }
1077
- // Folders first, then files, each alphabetically — the familiar order.
1117
+ }
1118
+ // Folders first, then files, each alphabetically — the familiar order.
1119
+ for (const node of folders.values()) {
1078
1120
  node.children.sort((left, right) => left.directory === right.directory
1079
1121
  ? left.name.localeCompare(right.name)
1080
1122
  : left.directory
1081
1123
  ? -1
1082
1124
  : 1);
1083
- return node;
1084
- };
1085
- return walk(root, "");
1125
+ }
1126
+ return root;
1127
+ }
1128
+ /**
1129
+ * Every file under a folder, with its size, walked eight at a time.
1130
+ *
1131
+ * The filter screen used to walk the same folder twice over, once to build
1132
+ * the tree and once to apply the rules, and both walks asked the disk for one
1133
+ * thing at a time. On a 47,387-file Unity project that is 47,387 serial stat
1134
+ * calls, and a stat is almost entirely waiting: the same walk with eight in
1135
+ * flight is 2,483ms down to 638ms, and more lanes than eight buy nothing.
1136
+ *
1137
+ * So there is one walk, it is concurrent, and the tree and the evaluation are
1138
+ * both worked out from what it returns rather than from the disk.
1139
+ */
1140
+ const WALK_LANES = 8;
1141
+ async function walkProject(root, options = {}) {
1142
+ const limit = options.limit ?? exports.EVALUATION_FILE_LIMIT;
1143
+ const files = [];
1144
+ const directories = [];
1145
+ let truncated = false;
1146
+ let stopped = false;
1147
+ /* Breadth-first, a level at a time, so the lanes always have work. */
1148
+ let level = [root];
1149
+ while (level.length && !stopped) {
1150
+ const next = [];
1151
+ let at = 0;
1152
+ const lane = async () => {
1153
+ while (at < level.length) {
1154
+ if (stopped)
1155
+ return;
1156
+ const directory = level[at];
1157
+ at += 1;
1158
+ if (options.shouldStop?.()) {
1159
+ stopped = true;
1160
+ return;
1161
+ }
1162
+ let entries;
1163
+ try {
1164
+ entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
1165
+ }
1166
+ catch {
1167
+ continue;
1168
+ }
1169
+ const here = [];
1170
+ for (const entry of entries) {
1171
+ if (entry.isSymbolicLink())
1172
+ continue;
1173
+ if ((0, rules_js_1.isUncounted)(entry.name))
1174
+ continue;
1175
+ const full = node_path_1.default.join(directory, entry.name);
1176
+ if (entry.isDirectory()) {
1177
+ next.push(full);
1178
+ directories.push(relativeTo(root, full));
1179
+ continue;
1180
+ }
1181
+ if (entry.isFile())
1182
+ here.push(full);
1183
+ }
1184
+ for (let from = 0; from < here.length; from += WALK_LANES) {
1185
+ const sized = await Promise.all(here.slice(from, from + WALK_LANES).map(async (full) => {
1186
+ try {
1187
+ return { full, size: (await (0, promises_1.stat)(full)).size };
1188
+ }
1189
+ catch {
1190
+ return null;
1191
+ }
1192
+ }));
1193
+ for (const one of sized) {
1194
+ if (!one)
1195
+ continue;
1196
+ if (files.length >= limit) {
1197
+ truncated = true;
1198
+ stopped = true;
1199
+ return;
1200
+ }
1201
+ files.push({ path: relativeTo(root, one.full), size: one.size });
1202
+ }
1203
+ }
1204
+ }
1205
+ };
1206
+ await Promise.all(Array.from({ length: Math.min(WALK_LANES, level.length) }, () => lane()));
1207
+ level = next;
1208
+ }
1209
+ return { files, directories, truncated };
1210
+ }
1211
+ /** A path under the root, written the way the rules are. */
1212
+ function relativeTo(root, full) {
1213
+ return node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
1086
1214
  }
1087
1215
  /**
1088
1216
  * Measure what the supplied rules would upload, skip and force-keep.
@@ -1092,7 +1220,6 @@ async function projectTree(root, limit = exports.EVALUATION_FILE_LIMIT) {
1092
1220
  */
1093
1221
  async function evaluateRules(root, rules, options = {}) {
1094
1222
  const layers = await collectLayers(root, rules);
1095
- const limit = options.limit ?? exports.EVALUATION_FILE_LIMIT;
1096
1223
  const impactBytes = new Map();
1097
1224
  const impactFiles = new Map();
1098
1225
  const excluded = [];
@@ -1102,89 +1229,65 @@ async function evaluateRules(root, rules, options = {}) {
1102
1229
  let skippedBytes = 0;
1103
1230
  let keptFiles = 0;
1104
1231
  let keptBytes = 0;
1105
- let seen = 0;
1106
- let truncated = false;
1107
- const pending = [root];
1108
- while (pending.length) {
1109
- if (options.shouldStop?.())
1110
- return emptyEvaluation(true);
1111
- const directory = pending.pop();
1112
- let entries;
1113
- try {
1114
- entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
1115
- }
1116
- catch {
1117
- continue;
1118
- }
1119
- for (const entry of entries) {
1120
- if (options.shouldStop?.())
1121
- return emptyEvaluation(true);
1122
- const full = node_path_1.default.join(directory, entry.name);
1123
- if (entry.isSymbolicLink())
1124
- continue;
1125
- if ((0, rules_js_1.isUncounted)(entry.name))
1126
- continue;
1127
- if (entry.isDirectory()) {
1128
- pending.push(full);
1129
- continue;
1130
- }
1131
- if (!entry.isFile())
1132
- continue;
1133
- seen += 1;
1134
- if (seen > limit) {
1135
- truncated = true;
1136
- pending.length = 0;
1137
- break;
1138
- }
1139
- let size;
1140
- try {
1141
- size = (await (0, promises_1.stat)(full)).size;
1142
- }
1143
- catch {
1144
- continue;
1145
- }
1146
- const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
1147
- // The last rule to speak wins, across every .gitignore that governs
1148
- // this path. A `!` rule brings the file back and is worth reporting,
1149
- // because it is how "always include" is expressed.
1150
- /*
1151
- The directories above it count. Asking `decide` about the file alone
1152
- is what made this screen report a Unity project's whole Library as
1153
- going up: `/[Ll]ibrary/` speaks about the directory, so no rule
1154
- matched the file and every one of them looked included — while the
1155
- save, which has always walked the ancestors, left them all behind.
1156
- */
1157
- const rule = (0, rules_js_1.decidingRule)(relative, false, layers);
1158
- if (rule?.negated) {
1159
- keptFiles += 1;
1160
- keptBytes += size;
1161
- uploadFiles += 1;
1162
- uploadBytes += size;
1163
- excluded.push({
1164
- path: relative,
1165
- bytes: size,
1166
- rule: `!${rule.pattern}`,
1167
- forceKept: true,
1168
- });
1169
- continue;
1170
- }
1171
- if (rule) {
1172
- skippedFiles += 1;
1173
- skippedBytes += size;
1174
- impactBytes.set(rule.pattern, (impactBytes.get(rule.pattern) ?? 0) + size);
1175
- impactFiles.set(rule.pattern, (impactFiles.get(rule.pattern) ?? 0) + 1);
1176
- excluded.push({
1177
- path: relative,
1178
- bytes: size,
1179
- rule: rule.pattern,
1180
- forceKept: false,
1181
- });
1182
- continue;
1183
- }
1232
+ const walked = options.walked ?? (await walkProject(root, options));
1233
+ if (options.shouldStop?.())
1234
+ return emptyEvaluation(true);
1235
+ const truncated = walked.truncated;
1236
+ /* Kept for this evaluation only, so no edit to the rules can outlive it. */
1237
+ const folderAnswers = new Map();
1238
+ for (const file of walked.files) {
1239
+ const { path: relative, size } = file;
1240
+ // The last rule to speak wins, across every .gitignore that governs
1241
+ // this path. A `!` rule brings the file back and is worth reporting,
1242
+ // because it is how "always include" is expressed.
1243
+ /*
1244
+ The directories above it count. Asking `decide` about the file alone
1245
+ is what made this screen report a Unity project's whole Library as
1246
+ going up: `/[Ll]ibrary/` speaks about the directory, so no rule
1247
+ matched the file and every one of them looked included — while the
1248
+ save, which has always walked the ancestors, left them all behind.
1249
+ */
1250
+ const rule = (0, rules_js_1.decidingRule)(relative, false, layers, folderAnswers);
1251
+ if (rule?.negated) {
1252
+ keptFiles += 1;
1253
+ keptBytes += size;
1184
1254
  uploadFiles += 1;
1185
1255
  uploadBytes += size;
1256
+ excluded.push({
1257
+ path: relative,
1258
+ bytes: size,
1259
+ rule: `!${rule.pattern}`,
1260
+ forceKept: true,
1261
+ });
1262
+ continue;
1263
+ }
1264
+ if (rule) {
1265
+ skippedFiles += 1;
1266
+ skippedBytes += size;
1267
+ impactBytes.set(rule.pattern, (impactBytes.get(rule.pattern) ?? 0) + size);
1268
+ impactFiles.set(rule.pattern, (impactFiles.get(rule.pattern) ?? 0) + 1);
1269
+ excluded.push({
1270
+ path: relative,
1271
+ bytes: size,
1272
+ rule: rule.pattern,
1273
+ forceKept: false,
1274
+ });
1275
+ continue;
1186
1276
  }
1277
+ uploadFiles += 1;
1278
+ uploadBytes += size;
1187
1279
  }
1280
+ /*
1281
+ Sorted, and all of it.
1282
+
1283
+ This used to hand back the four hundred largest, which was right for a
1284
+ flat list somebody scrolls and wrong for what reads it now: the screen
1285
+ folds these into folders, so a Unity project's Library — 45,739 files —
1286
+ was drawn from four hundred of them and labelled "395 files" beside a
1287
+ header that said 45,739. The grouping happens here, in this process, and
1288
+ only the folded tree crosses to the window, so keeping all of them costs
1289
+ an array nobody sends.
1290
+ */
1188
1291
  excluded.sort((left, right) => right.bytes - left.bytes);
1189
1292
  const impacts = [...impactBytes.entries()]
1190
1293
  .map(([pattern, bytes]) => ({
@@ -1201,7 +1304,7 @@ async function evaluateRules(root, rules, options = {}) {
1201
1304
  keptFiles,
1202
1305
  keptBytes,
1203
1306
  impacts,
1204
- excluded: excluded.slice(0, 400),
1307
+ excluded,
1205
1308
  truncated,
1206
1309
  };
1207
1310
  }
@@ -1496,39 +1599,60 @@ function isCredentialByName(relativePath) {
1496
1599
  }
1497
1600
  async function detectSecrets(root) {
1498
1601
  const found = [];
1499
- const pending = [root];
1500
1602
  let seen = 0;
1501
- while (pending.length && found.length < 50) {
1502
- const directory = pending.pop();
1503
- let entries;
1504
- try {
1505
- entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
1506
- }
1507
- catch {
1508
- continue;
1509
- }
1510
- for (const entry of entries) {
1511
- if (seen > 40_000)
1512
- return found;
1513
- const full = node_path_1.default.join(directory, entry.name);
1514
- if ((0, rules_js_1.isUncounted)(entry.name))
1515
- continue;
1516
- if (entry.isDirectory()) {
1517
- if (entry.name !== "node_modules")
1518
- pending.push(full);
1519
- continue;
1603
+ let enough = false;
1604
+ /*
1605
+ Every directory, eight at a time. This reads names and nothing else, so
1606
+ it is pure waiting, and it was waiting one directory at a time over all
1607
+ two and a half thousand of them on a real project.
1608
+
1609
+ It still looks everywhere the rules do not. A credential inside a folder
1610
+ that is currently ignored is one rule edit away from being sent, and this
1611
+ is the check that is supposed to notice before that happens.
1612
+ */
1613
+ let level = [root];
1614
+ while (level.length && !enough) {
1615
+ const next = [];
1616
+ let at = 0;
1617
+ const lane = async () => {
1618
+ while (at < level.length && !enough) {
1619
+ const directory = level[at];
1620
+ at += 1;
1621
+ let entries;
1622
+ try {
1623
+ entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
1624
+ }
1625
+ catch {
1626
+ continue;
1627
+ }
1628
+ for (const entry of entries) {
1629
+ if (found.length >= 50 || seen > 40_000) {
1630
+ enough = true;
1631
+ return;
1632
+ }
1633
+ const full = node_path_1.default.join(directory, entry.name);
1634
+ if ((0, rules_js_1.isUncounted)(entry.name))
1635
+ continue;
1636
+ if (entry.isDirectory()) {
1637
+ if (entry.name !== "node_modules")
1638
+ next.push(full);
1639
+ continue;
1640
+ }
1641
+ seen += 1;
1642
+ /*
1643
+ Asked of the one function rather than repeated here. These were
1644
+ two copies of the same rule, which is how `.env.example` came to
1645
+ be refused by the command line long after the service had learned
1646
+ that templates are not credentials.
1647
+ */
1648
+ const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
1649
+ if (isCredentialByName(relative))
1650
+ found.push(relative);
1651
+ }
1520
1652
  }
1521
- seen += 1;
1522
- /*
1523
- Asked of the one function rather than repeated here. These were two
1524
- copies of the same rule, which is how `.env.example` came to be
1525
- refused by the command line long after the service had learned that
1526
- templates are not credentials.
1527
- */
1528
- const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
1529
- if (isCredentialByName(relative))
1530
- found.push(relative);
1531
- }
1653
+ };
1654
+ await Promise.all(Array.from({ length: Math.min(WALK_LANES, level.length) }, () => lane()));
1655
+ level = next;
1532
1656
  }
1533
1657
  return found;
1534
1658
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.30.1",
3
+ "version": "0.30.2",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",