@coderook/cli 0.27.0 → 0.29.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.
@@ -14,7 +14,6 @@ exports.branchName = branchName;
14
14
  exports.changedFiles = changedFiles;
15
15
  exports.surveyFiles = surveyFiles;
16
16
  exports.totalSize = totalSize;
17
- exports.fileSizes = fileSizes;
18
17
  exports.fileDiff = fileDiff;
19
18
  exports.projectTree = projectTree;
20
19
  exports.evaluateRules = evaluateRules;
@@ -55,49 +54,49 @@ async function git(root, ...args) {
55
54
  * file would produce an incomplete project that looked backed up, so likely
56
55
  * secrets are warned about instead (docs/UPLOAD_POLICY.md).
57
56
  */
58
- exports.STARTER_IGNORE = `# Dependencies and generated output
59
- node_modules/
60
- dist/
61
- build/
62
- out/
63
- .next/
64
- target/
65
- __pycache__/
66
- .venv/
67
- venv/
68
- *.log
69
-
70
- # Large model weights
71
- models/**
72
- *.safetensors
73
- *.ckpt
74
- *.pt
75
- *.pth
76
-
77
- # Caches and local state
78
- .venv/
79
- .pytest_cache/
80
- .mypy_cache/
81
- .ruff_cache/
82
- .cache/
83
- *.pyc
84
-
85
- # A browser or Electron profile that has been left in the project folder.
86
- # These hold files another program keeps open — a LOCK that cannot be read
87
- # while it runs will stop a save outright — and nothing in them is the work.
88
- IndexedDB/
89
- Local Storage/
90
- Session Storage/
91
- Service Worker/
92
- Network/
93
- GPUCache/
94
- Code Cache/
95
- blob_storage/
96
- Local State
97
- Preferences
98
-
99
- # Archives of the project, inside the project
100
- *.cbx
57
+ exports.STARTER_IGNORE = `# Dependencies and generated output
58
+ node_modules/
59
+ dist/
60
+ build/
61
+ out/
62
+ .next/
63
+ target/
64
+ __pycache__/
65
+ .venv/
66
+ venv/
67
+ *.log
68
+
69
+ # Large model weights
70
+ models/**
71
+ *.safetensors
72
+ *.ckpt
73
+ *.pt
74
+ *.pth
75
+
76
+ # Caches and local state
77
+ .venv/
78
+ .pytest_cache/
79
+ .mypy_cache/
80
+ .ruff_cache/
81
+ .cache/
82
+ *.pyc
83
+
84
+ # A browser or Electron profile that has been left in the project folder.
85
+ # These hold files another program keeps open — a LOCK that cannot be read
86
+ # while it runs will stop a save outright — and nothing in them is the work.
87
+ IndexedDB/
88
+ Local Storage/
89
+ Session Storage/
90
+ Service Worker/
91
+ Network/
92
+ GPUCache/
93
+ Code Cache/
94
+ blob_storage/
95
+ Local State
96
+ Preferences
97
+
98
+ # Archives of the project, inside the project
99
+ *.cbx
101
100
  `;
102
101
  /** The shared rules file, committed with the project. */
103
102
  exports.IGNORE_FILE = ".gitignore";
@@ -318,19 +317,69 @@ async function measure(full, size, known) {
318
317
  * upload when no version has ever been saved. The filter rules decide what
319
318
  * is a candidate; the baseline decides what is new.
320
319
  */
320
+ /**
321
+ * The same files, ordered so that a cap spends itself across the folder.
322
+ *
323
+ * Taken in walk order, a cap is handed to whichever directory the walk
324
+ * reached first — and the directory a person cares about is rarely that one.
325
+ * Round-robin by directory means the first row of every directory is chosen
326
+ * before the second row of any, so a folder of forty-five thousand cache
327
+ * files cannot bury the eight hundred beside it that are the work.
328
+ */
329
+ function spreadAcrossDirectories(found) {
330
+ const byDirectory = new Map();
331
+ for (const one of found) {
332
+ const at = one.relative.slice(0, one.relative.lastIndexOf("/") + 1);
333
+ const held = byDirectory.get(at);
334
+ if (held)
335
+ held.push(one);
336
+ else
337
+ byDirectory.set(at, [one]);
338
+ }
339
+ const queues = [...byDirectory.values()];
340
+ const deepest = queues.reduce((most, queue) => Math.max(most, queue.length), 0);
341
+ const spread = [];
342
+ for (let round = 0; round < deepest; round++) {
343
+ for (const queue of queues) {
344
+ const one = queue[round];
345
+ if (one)
346
+ spread.push(one);
347
+ }
348
+ }
349
+ return spread;
350
+ }
321
351
  async function changedFiles(root, rules, baseline = null, mode = "add-and-update",
322
352
  /*
323
353
  Digests already taken, and what the files looked like when they were.
324
354
  Read and written in place, so the caller keeps whatever this learns and
325
355
  the next scan does not read the same unchanged gigabyte again.
326
356
  */
327
- stats) {
357
+ stats,
358
+ /** Filled with how much of the folder the cap allowed through. */
359
+ outcome) {
328
360
  const layers = await collectLayers(root, rules);
329
361
  const files = [];
330
362
  const present = new Set();
331
363
  const pending = [root];
332
- while (pending.length && files.length < LISTED_FILE_LIMIT) {
333
- const directory = pending.pop();
364
+ /** Everything the rules would send, including what the cap keeps out. */
365
+ const found = [];
366
+ const limit = outcome?.limit ?? LISTED_FILE_LIMIT;
367
+ /*
368
+ Walk the whole folder first, and decide what to read afterwards.
369
+
370
+ These used to be one pass: walk until twenty thousand rows had been
371
+ collected, then stop where it stood. That made the cap decide the shape of
372
+ the answer — the walk was depth first, so the first directory it descended
373
+ into took the entire allowance. In a Unity project that is `Library/`,
374
+ forty-five thousand files of import cache, and `Assets/` — the actual game
375
+ — appeared in the list nowhere at all.
376
+
377
+ Walking is cheap: a readdir and a stat, no file is opened. Reading is what
378
+ costs, so reading is what the cap should govern, and it can only be spent
379
+ well once the whole folder is known.
380
+ */
381
+ for (let next = 0; next < pending.length; next++) {
382
+ const directory = pending[next];
334
383
  let entries;
335
384
  try {
336
385
  entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
@@ -338,6 +387,7 @@ stats) {
338
387
  catch {
339
388
  continue;
340
389
  }
390
+ const wanted = [];
341
391
  for (const entry of entries) {
342
392
  const full = node_path_1.default.join(directory, entry.name);
343
393
  if (entry.isSymbolicLink())
@@ -359,43 +409,75 @@ stats) {
359
409
  continue;
360
410
  if ((0, rules_js_1.excludes)(relative, false, layers))
361
411
  continue;
362
- let size;
363
- let mtimeMs;
412
+ wanted.push({ full, relative });
413
+ }
414
+ /*
415
+ Sized a directory at a time rather than a file at a time. One `stat` is
416
+ a syscall's round trip, and forty-seven thousand of them taken strictly
417
+ one after another is most of the scan; asked for together they overlap.
418
+ */
419
+ const sized = await Promise.all(wanted.map(async (one) => {
364
420
  try {
365
- const info = await (0, promises_1.stat)(full);
366
- size = info.size;
367
- mtimeMs = info.mtimeMs;
421
+ const info = await (0, promises_1.stat)(one.full);
422
+ return { ...one, size: info.size, mtimeMs: info.mtimeMs };
368
423
  }
369
424
  catch {
370
- continue;
425
+ /* Gone or unreadable since the walk began; the upload reports it. */
426
+ return null;
371
427
  }
372
- present.add(relative);
373
- const measured = await measure(full, size, {
374
- mtimeMs,
375
- cached: stats?.get(relative),
376
- recorded: baseline?.has(relative) ?? false,
377
- });
428
+ }));
429
+ for (const one of sized) {
430
+ if (!one)
431
+ continue;
378
432
  /*
379
- Remembered whether or not it changed: the next scan wants to skip
380
- reading this file again, and that is just as true of one that was
381
- edited a moment ago as of one that never moves.
433
+ Recorded as present for every file the walk saw, capped or not.
434
+
435
+ Synchronize mode calls anything in the last version that is missing
436
+ from this set a deletion. Filling it only for the rows that fitted
437
+ meant a folder past the cap proposed deleting every file the cap had
438
+ kept out — files sitting right there on disk.
382
439
  */
383
- if (measured.hash)
384
- stats?.set(relative, { size, mtimeMs, sha256: measured.hash });
385
- const saved = baseline?.get(relative);
386
- if (saved && measured.hash && saved === measured.hash)
387
- continue;
388
- files.push({
389
- path: relative,
390
- // Against a saved version the true line delta needs the old copy;
391
- // until a version exists to fetch, a changed file counts as rewritten.
392
- added: measured.lines,
393
- removed: 0,
394
- included: true,
395
- binary: measured.binary,
396
- });
440
+ present.add(one.relative);
441
+ outcome?.sizes.push({ path: one.relative, size: one.size });
442
+ found.push(one);
397
443
  }
398
444
  }
445
+ /*
446
+ Read in an order that gives every directory a share, and stop at the cap.
447
+
448
+ An unchanged file costs a digest and produces no row, so this runs until
449
+ the list is full rather than over a fixed slice — a folder where nothing
450
+ has changed still has to be walked through to find that out.
451
+ */
452
+ for (const one of spreadAcrossDirectories(found)) {
453
+ if (files.length >= limit)
454
+ break;
455
+ const { full, relative, size, mtimeMs } = one;
456
+ const measured = await measure(full, size, {
457
+ mtimeMs,
458
+ cached: stats?.get(relative),
459
+ recorded: baseline?.has(relative) ?? false,
460
+ });
461
+ /*
462
+ Remembered whether or not it changed: the next scan wants to skip
463
+ reading this file again, and that is just as true of one that was
464
+ edited a moment ago as of one that never moves.
465
+ */
466
+ if (measured.hash)
467
+ stats?.set(relative, { size, mtimeMs, sha256: measured.hash });
468
+ const saved = baseline?.get(relative);
469
+ if (saved && measured.hash && saved === measured.hash)
470
+ continue;
471
+ files.push({
472
+ path: relative,
473
+ // Against a saved version the true line delta needs the old copy;
474
+ // until a version exists to fetch, a changed file counts as rewritten.
475
+ added: measured.lines,
476
+ removed: 0,
477
+ included: true,
478
+ binary: measured.binary,
479
+ });
480
+ }
399
481
  // A file that was in the last version and is gone now is only a change in
400
482
  // synchronize mode. The safe default adds and updates, and leaves the
401
483
  // saved copy of a missing file alone (docs/UPLOAD_POLICY.md).
@@ -413,6 +495,15 @@ stats) {
413
495
  });
414
496
  }
415
497
  }
498
+ if (outcome) {
499
+ outcome.listed = files.length;
500
+ /*
501
+ Deletions are rows the walk never saw, so they are added to both sides
502
+ rather than left out of the total — otherwise a synchronize scan could
503
+ report listing more files than the folder was found to hold.
504
+ */
505
+ outcome.total = Math.max(found.length, files.length);
506
+ }
416
507
  return files.sort((left, right) => left.path.localeCompare(right.path));
417
508
  }
418
509
  /** The size on disk of everything the rules would upload. */
@@ -483,29 +574,6 @@ async function totalSize(root, files) {
483
574
  }
484
575
  return total;
485
576
  }
486
- /**
487
- * The same walk, keeping each file's size rather than only the sum.
488
- *
489
- * Used where something has to reason about which files are large — the
490
- * exclusion suggestions, whose whole value is saying which folder is costing
491
- * the upload its time. Separate from totalSize so that the common path still
492
- * carries nothing it does not need.
493
- */
494
- async function fileSizes(root, files) {
495
- const sized = [];
496
- for (const file of files) {
497
- try {
498
- sized.push({
499
- path: file.path,
500
- size: (await (0, promises_1.stat)(node_path_1.default.join(root, file.path))).size,
501
- });
502
- }
503
- catch {
504
- /* deleted since the scan; it cannot be measured and does not count */
505
- }
506
- }
507
- return sized;
508
- }
509
577
  /** The unified diff for one file, including files git does not track yet. */
510
578
  async function fileDiff(root, file, ignoreWhitespace = false) {
511
579
  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.27.0",
3
+ "version": "0.29.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",