@coderook/cli 0.12.0 → 0.14.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.
@@ -12,7 +12,9 @@ exports.collectLayers = collectLayers;
12
12
  exports.assertReadable = assertReadable;
13
13
  exports.branchName = branchName;
14
14
  exports.changedFiles = changedFiles;
15
+ exports.surveyFiles = surveyFiles;
15
16
  exports.totalSize = totalSize;
17
+ exports.fileSizes = fileSizes;
16
18
  exports.fileDiff = fileDiff;
17
19
  exports.projectTree = projectTree;
18
20
  exports.evaluateRules = evaluateRules;
@@ -20,6 +22,7 @@ exports.detectSecrets = detectSecrets;
20
22
  /** Reading a project folder: changed files, diffs, and rule measurement. */
21
23
  const node_child_process_1 = require("node:child_process");
22
24
  const node_crypto_1 = require("node:crypto");
25
+ const node_fs_1 = require("node:fs");
23
26
  const promises_1 = require("node:fs/promises");
24
27
  const node_path_1 = __importDefault(require("node:path"));
25
28
  const node_util_1 = require("node:util");
@@ -47,24 +50,49 @@ async function git(root, ...args) {
47
50
  * file would produce an incomplete project that looked backed up, so likely
48
51
  * secrets are warned about instead (docs/UPLOAD_POLICY.md).
49
52
  */
50
- exports.STARTER_IGNORE = `# Dependencies and generated output
51
- node_modules/
52
- dist/
53
- build/
54
- out/
55
- .next/
56
- target/
57
- __pycache__/
58
- .venv/
59
- venv/
60
- *.log
61
-
62
- # Large model weights
63
- models/**
64
- *.safetensors
65
- *.ckpt
66
- *.pt
67
- *.pth
53
+ exports.STARTER_IGNORE = `# Dependencies and generated output
54
+ node_modules/
55
+ dist/
56
+ build/
57
+ out/
58
+ .next/
59
+ target/
60
+ __pycache__/
61
+ .venv/
62
+ venv/
63
+ *.log
64
+
65
+ # Large model weights
66
+ models/**
67
+ *.safetensors
68
+ *.ckpt
69
+ *.pt
70
+ *.pth
71
+
72
+ # Caches and local state
73
+ .venv/
74
+ .pytest_cache/
75
+ .mypy_cache/
76
+ .ruff_cache/
77
+ .cache/
78
+ *.pyc
79
+
80
+ # A browser or Electron profile that has been left in the project folder.
81
+ # These hold files another program keeps open — a LOCK that cannot be read
82
+ # while it runs will stop a save outright — and nothing in them is the work.
83
+ IndexedDB/
84
+ Local Storage/
85
+ Session Storage/
86
+ Service Worker/
87
+ Network/
88
+ GPUCache/
89
+ Code Cache/
90
+ blob_storage/
91
+ Local State
92
+ Preferences
93
+
94
+ # Archives of the project, inside the project
95
+ *.cbx
68
96
  `;
69
97
  /** The shared rules file, committed with the project. */
70
98
  exports.IGNORE_FILE = ".gitignore";
@@ -196,10 +224,73 @@ async function branchName(root) {
196
224
  const LINE_COUNT_LIMIT = 4 * 1024 * 1024;
197
225
  /** Enough rows for any real project; past it the rail would be unusable. */
198
226
  const LISTED_FILE_LIMIT = 20_000;
199
- /** How a file reads: line count, or binary, or too big to bother. */
200
- async function measure(full, size) {
201
- if (size > LINE_COUNT_LIMIT)
202
- return { lines: 0, binary: true, hash: "" };
227
+ /**
228
+ * The digest of a file, without holding it.
229
+ *
230
+ * Streamed because the files this exists for are the large ones, and reading
231
+ * a gigabyte into memory in order to hash it is the reason it was not being
232
+ * done at all.
233
+ */
234
+ async function digestOf(full) {
235
+ try {
236
+ const hash = (0, node_crypto_1.createHash)("sha256");
237
+ for await (const block of (0, node_fs_1.createReadStream)(full, {
238
+ highWaterMark: 4 * 1024 * 1024,
239
+ })) {
240
+ hash.update(block);
241
+ }
242
+ return hash.digest("hex");
243
+ }
244
+ catch {
245
+ return "";
246
+ }
247
+ }
248
+ /**
249
+ * How a file reads: its line count, whether it is binary, and its digest.
250
+ *
251
+ * The size decides only whether the lines are counted. It used to decide
252
+ * whether the digest was taken as well, and those are not the same question:
253
+ * counting lines needs the whole file in memory, which is a real reason to
254
+ * stop at a few megabytes, while the digest is what decides whether the file
255
+ * changed at all.
256
+ *
257
+ * Returning no digest for a large file therefore did not mean "not measured",
258
+ * it meant "cannot match" — the comparison below skips a file only when its
259
+ * digest equals the recorded one, and an empty string never does. So every
260
+ * file over four megabytes was reported as changed on every scan, forever,
261
+ * however untouched it was. A project holding two large files offered to
262
+ * upload them again after each save, said "2 not uploaded yet" beside a panel
263
+ * confirming the account already held them, and could not be talked out of it
264
+ * by refreshing, because refreshing recomputed the same empty answer.
265
+ */
266
+ async function measure(full, size, known) {
267
+ /*
268
+ Unchanged on disk, so unchanged in content. Checked before the size test
269
+ because the whole point is to answer without reading, and the files worth
270
+ not reading are the large ones.
271
+ */
272
+ const cached = known?.cached;
273
+ if (cached && cached.size === size && cached.mtimeMs === known.mtimeMs) {
274
+ return { lines: 0, binary: true, hash: cached.sha256 };
275
+ }
276
+ if (size > LINE_COUNT_LIMIT) {
277
+ /*
278
+ Read only when the answer could be "unchanged".
279
+
280
+ The digest exists to be compared against a recorded one. A file the
281
+ record has never heard of is new whatever it hashes to, so taking the
282
+ digest decides nothing and costs a full read of the file.
283
+
284
+ That is most of what a first scan is. Hashing large files was necessary
285
+ to stop them reading as changed forever, but doing it for files nothing
286
+ could match turned opening a six hundred megabyte project for the first
287
+ time into two minutes of reading to learn what the absence of a record
288
+ already said.
289
+ */
290
+ if (!known?.recorded)
291
+ return { lines: 0, binary: true, hash: "" };
292
+ return { lines: 0, binary: true, hash: await digestOf(full) };
293
+ }
203
294
  try {
204
295
  const contents = await (0, promises_1.readFile)(full);
205
296
  const binary = contents.subarray(0, 8192).includes(0);
@@ -222,7 +313,13 @@ async function measure(full, size) {
222
313
  * upload when no version has ever been saved. The filter rules decide what
223
314
  * is a candidate; the baseline decides what is new.
224
315
  */
225
- async function changedFiles(root, rules, baseline = null, mode = "add-and-update") {
316
+ async function changedFiles(root, rules, baseline = null, mode = "add-and-update",
317
+ /*
318
+ Digests already taken, and what the files looked like when they were.
319
+ Read and written in place, so the caller keeps whatever this learns and
320
+ the next scan does not read the same unchanged gigabyte again.
321
+ */
322
+ stats) {
226
323
  const layers = await collectLayers(root, rules);
227
324
  const files = [];
228
325
  const present = new Set();
@@ -258,14 +355,28 @@ async function changedFiles(root, rules, baseline = null, mode = "add-and-update
258
355
  if ((0, rules_js_1.excludes)(relative, false, layers))
259
356
  continue;
260
357
  let size;
358
+ let mtimeMs;
261
359
  try {
262
- size = (await (0, promises_1.stat)(full)).size;
360
+ const info = await (0, promises_1.stat)(full);
361
+ size = info.size;
362
+ mtimeMs = info.mtimeMs;
263
363
  }
264
364
  catch {
265
365
  continue;
266
366
  }
267
367
  present.add(relative);
268
- const measured = await measure(full, size);
368
+ const measured = await measure(full, size, {
369
+ mtimeMs,
370
+ cached: stats?.get(relative),
371
+ recorded: baseline?.has(relative) ?? false,
372
+ });
373
+ /*
374
+ Remembered whether or not it changed: the next scan wants to skip
375
+ reading this file again, and that is just as true of one that was
376
+ edited a moment ago as of one that never moves.
377
+ */
378
+ if (measured.hash)
379
+ stats?.set(relative, { size, mtimeMs, sha256: measured.hash });
269
380
  const saved = baseline?.get(relative);
270
381
  if (saved && measured.hash && saved === measured.hash)
271
382
  continue;
@@ -300,6 +411,61 @@ async function changedFiles(root, rules, baseline = null, mode = "add-and-update
300
411
  return files.sort((left, right) => left.path.localeCompare(right.path));
301
412
  }
302
413
  /** The size on disk of everything the rules would upload. */
414
+ /**
415
+ * Every file the rules would upload, with its size and nothing else.
416
+ *
417
+ * Deliberately not `changedFiles`. That function stops at twenty thousand rows
418
+ * because past it a list is unusable — a correct limit for a scrollbar and a
419
+ * catastrophic one for deciding what gets backed up, which is what it was also
420
+ * being used for. A project past that many files was silently uploaded in
421
+ * part.
422
+ *
423
+ * This walk has no cap and reads nothing: no line counting, no hashing, no
424
+ * content at all. That is what makes it affordable to run over everything
425
+ * before deciding anything.
426
+ */
427
+ async function surveyFiles(root, rules) {
428
+ const layers = await collectLayers(root, rules);
429
+ const found = [];
430
+ const pending = [root];
431
+ while (pending.length) {
432
+ const directory = pending.pop();
433
+ let entries;
434
+ try {
435
+ entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
436
+ }
437
+ catch {
438
+ continue;
439
+ }
440
+ for (const entry of entries) {
441
+ const full = node_path_1.default.join(directory, entry.name);
442
+ if (entry.isSymbolicLink())
443
+ continue;
444
+ if ((0, rules_js_1.isUncounted)(entry.name))
445
+ continue;
446
+ const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
447
+ if (entry.isDirectory()) {
448
+ if ((0, rules_js_1.excludes)(relative, true, layers) &&
449
+ !(0, rules_js_1.negationReachesInto)(relative, layers)) {
450
+ continue;
451
+ }
452
+ pending.push(full);
453
+ continue;
454
+ }
455
+ if (!entry.isFile())
456
+ continue;
457
+ if ((0, rules_js_1.excludes)(relative, false, layers))
458
+ continue;
459
+ try {
460
+ found.push({ path: relative, size: (await (0, promises_1.stat)(full)).size });
461
+ }
462
+ catch {
463
+ /* Gone or unreadable since the walk began; the upload reports it. */
464
+ }
465
+ }
466
+ }
467
+ return found.sort((left, right) => left.path.localeCompare(right.path));
468
+ }
303
469
  async function totalSize(root, files) {
304
470
  let total = 0;
305
471
  for (const file of files) {
@@ -312,6 +478,29 @@ async function totalSize(root, files) {
312
478
  }
313
479
  return total;
314
480
  }
481
+ /**
482
+ * The same walk, keeping each file's size rather than only the sum.
483
+ *
484
+ * Used where something has to reason about which files are large — the
485
+ * exclusion suggestions, whose whole value is saying which folder is costing
486
+ * the upload its time. Separate from totalSize so that the common path still
487
+ * carries nothing it does not need.
488
+ */
489
+ async function fileSizes(root, files) {
490
+ const sized = [];
491
+ for (const file of files) {
492
+ try {
493
+ sized.push({
494
+ path: file.path,
495
+ size: (await (0, promises_1.stat)(node_path_1.default.join(root, file.path))).size,
496
+ });
497
+ }
498
+ catch {
499
+ /* deleted since the scan; it cannot be measured and does not count */
500
+ }
501
+ }
502
+ return sized;
503
+ }
315
504
  /** The unified diff for one file, including files git does not track yet. */
316
505
  async function fileDiff(root, file, ignoreWhitespace = false) {
317
506
  const output = await git(root, "diff", "--no-ext-diff", "--unified=3", ...(ignoreWhitespace ? ["--ignore-all-space"] : []), "--", file);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
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",