@coderook/cli 0.31.1 → 0.31.3

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.31.1",
5
+ "version": "0.31.3",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -18,6 +18,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
18
18
  const promises_1 = require("node:readline/promises");
19
19
  const node_os_1 = __importDefault(require("node:os"));
20
20
  const promises_2 = require("node:fs/promises");
21
+ const node_fs_1 = require("node:fs");
21
22
  const node_path_1 = __importDefault(require("node:path"));
22
23
  const node_process_1 = __importDefault(require("node:process"));
23
24
  const api_js_1 = require("./api.js");
@@ -385,6 +386,22 @@ async function commandStatus(parsed) {
385
386
  `— run ${accent("cbx get")} to bring ${stale.length === 1 ? "it" : "them"} down.`));
386
387
  for (const [path] of stale.slice(0, 10))
387
388
  console.log(dim(` behind ${path}`));
389
+ /*
390
+ Behind, and not here at all. Usually somebody else's file that has not
391
+ been fetched — but it is also what a file deleted here looked like after
392
+ a save that did not send the deletion, because until 0.31.2 that save
393
+ dropped it from this folder's record. Told plainly, since the one advice
394
+ above would bring back a file the person meant to be rid of.
395
+ */
396
+ const absent = stale.filter(([file]) => !(0, node_fs_1.existsSync)(node_path_1.default.join(folder, ...file.split("/"))));
397
+ if (absent.length) {
398
+ const one = absent.length === 1;
399
+ const which = absent.length === stale.length
400
+ ? one ? "It is not" : "They are not"
401
+ : `${absent.length} of them ${one ? "is" : "are"} not`;
402
+ console.log(dim(`${which} in this folder at all. If you deleted ${one ? "it" : "them"} yourself, ` +
403
+ `run ${accent("cbx get")}, delete ${one ? "it" : "them"} again, and save with ${accent("--sync")}.`));
404
+ }
388
405
  }
389
406
  /** The deletions, and what a plain save will do about them. */
390
407
  /*
@@ -22,6 +22,8 @@ exports.stamp = stamp;
22
22
  exports.importRef = importRef;
23
23
  exports.quotePath = quotePath;
24
24
  exports.refPlacements = refPlacements;
25
+ exports.withinDepth = withinDepth;
26
+ exports.historyLeftOut = historyLeftOut;
25
27
  /**
26
28
  * Git's constant hash for the empty tree.
27
29
  *
@@ -262,3 +264,59 @@ function refPlacements(wanted, markOfVersion) {
262
264
  }
263
265
  return { placed, unplaceable };
264
266
  }
267
+ /**
268
+ * The versions within `depth` generations of these heads: 1 is the heads
269
+ * alone, 2 adds their parents, and so on.
270
+ *
271
+ * What `git clone --depth` means here. The oldest version taken is imported
272
+ * with no parent, so it becomes a root commit — which also means its commit id
273
+ * differs from the same version's id in a full clone, because a commit's id
274
+ * covers its parents. That is the same trade git's own shallow clones make.
275
+ */
276
+ function withinDepth(heads, all, depth) {
277
+ const byId = new Map(all.map((version) => [version.id, version]));
278
+ const kept = new Set();
279
+ let level = heads.filter((id) => byId.has(id));
280
+ for (let generation = 0; generation < depth && level.length; generation += 1) {
281
+ const next = [];
282
+ for (const id of level) {
283
+ if (kept.has(id))
284
+ continue;
285
+ kept.add(id);
286
+ for (const parent of byId.get(id).parentVersionIds) {
287
+ if (byId.has(parent) && !kept.has(parent))
288
+ next.push(parent);
289
+ }
290
+ }
291
+ level = next;
292
+ }
293
+ return kept;
294
+ }
295
+ /**
296
+ * History an earlier shallow clone chose not to take.
297
+ *
298
+ * Every ancestor of something already imported that was not itself imported.
299
+ * A full clone imported every ancestor, so for it this is always empty; for a
300
+ * shallow one it is everything below the cut. Without it the first ordinary
301
+ * `git fetch` after a `--depth 1` clone would import the whole history anyway,
302
+ * as a second, disconnected line of commits nobody asked for.
303
+ */
304
+ function historyLeftOut(all, imported) {
305
+ const byId = new Map(all.map((version) => [version.id, version]));
306
+ const have = new Set(imported);
307
+ const left = new Set();
308
+ const pending = [];
309
+ for (const id of have) {
310
+ for (const parent of byId.get(id)?.parentVersionIds ?? [])
311
+ pending.push(parent);
312
+ }
313
+ while (pending.length) {
314
+ const id = pending.pop();
315
+ if (have.has(id) || left.has(id) || !byId.has(id))
316
+ continue;
317
+ left.add(id);
318
+ for (const parent of byId.get(id).parentVersionIds)
319
+ pending.push(parent);
320
+ }
321
+ return left;
322
+ }
@@ -544,8 +544,14 @@ async function recordPushedCommits(marks) {
544
544
  first and an empty window always accepts one more.
545
545
  */
546
546
  const BLOB_LANES = 8;
547
+ /** Fewer new files than this, and one request for them each is quicker. */
548
+ const ARCHIVE_MIN_FILES = 64;
549
+ /** The share of a version's bytes that must be new before it is fetched whole. */
550
+ const ARCHIVE_MIN_SHARE = 0.5;
547
551
  const BLOB_WINDOW_BYTES = 64 * 1024 * 1024;
548
- async function doImport(refs, url) {
552
+ async function doImport(refs, url,
553
+ /** From `git clone --depth N`; only honoured on a first import. */
554
+ depth = null) {
549
555
  const { owner, slug } = (0, git_history_js_1.parseRemoteUrl)(url);
550
556
  const project = await (0, api_js_1.findProject)(owner ? `${owner}/${slug}` : slug);
551
557
  if (!project) {
@@ -593,6 +599,26 @@ async function doImport(refs, url) {
593
599
  let nextMark = 1;
594
600
  for (const mark of commitMark.values())
595
601
  nextMark = Math.max(nextMark, mark + 1);
602
+ /*
603
+ What this import takes, out of everything the heads can reach.
604
+
605
+ Never history an earlier shallow clone left out: its first ordinary fetch
606
+ would otherwise bring the lot in as a second line of commits. And on a
607
+ first import with `--depth`, only that many generations of each line.
608
+ */
609
+ const leftOut = (0, git_history_js_1.historyLeftOut)(all, commitMark.keys());
610
+ let chosen = order.filter((version) => !leftOut.has(version.id));
611
+ if (depth && depth > 0) {
612
+ if (commitMark.size) {
613
+ say(` --depth applies to a new clone; fetching everything newer than what is here.`);
614
+ }
615
+ else {
616
+ const keep = (0, git_history_js_1.withinDepth)([...wanted.values()], all, depth);
617
+ chosen = chosen.filter((version) => keep.has(version.id));
618
+ say(`Taking the newest ${depth} version${depth === 1 ? "" : "s"} of each line ` +
619
+ `(--depth ${depth}); ${order.length - chosen.length} older left out.`);
620
+ }
621
+ }
596
622
  /*
597
623
  Point every ref git asked for at the commit its line's head became.
598
624
 
@@ -610,7 +636,7 @@ async function doImport(refs, url) {
610
636
  say(` could not place ${ref}: no commit for its head version`);
611
637
  }
612
638
  };
613
- const fresh = order.filter((version) => !commitMark.has(version.id));
639
+ const fresh = chosen.filter((version) => !commitMark.has(version.id));
614
640
  if (!fresh.length) {
615
641
  say(`Already up to date with ${slug}.`);
616
642
  /*
@@ -631,8 +657,8 @@ async function doImport(refs, url) {
631
657
  return;
632
658
  }
633
659
  say(`Importing ${fresh.length} version${fresh.length === 1 ? "" : "s"} from ${slug}` +
634
- (order.length > fresh.length
635
- ? ` (${order.length - fresh.length} already here)`
660
+ (chosen.length > fresh.length
661
+ ? ` (${chosen.length - fresh.length} already here)`
636
662
  : "") +
637
663
  ".");
638
664
  const { Downloader } = await Promise.resolve().then(() => __importStar(require("../../desktop-app/src/main/download.js")));
@@ -667,41 +693,7 @@ async function doImport(refs, url) {
667
693
  bytes together, because sixteen source files and sixteen video files
668
694
  are not the same amount to be holding at once.
669
695
  */
670
- /*
671
- The whole version in one request, where that works.
672
-
673
- Fetching per file is one round trip each: 21,071 of them on a real
674
- Unity project, which is four hours in series and about half an hour
675
- even eight at a time. The archive is the same bytes in one request.
676
-
677
- Anything unexpected about it — an entry this cannot read, a short
678
- download, a service that does not offer the route — falls through to
679
- the per-file path below, which is slow and known to work. A clone
680
- that is slow is a nuisance; a clone that writes the wrong bytes is
681
- not recoverable by trying again.
682
- */
683
- const fromArchive = new Map();
684
- try {
685
- const packed = await downloader.archive(repositoryId, version.id);
686
- for (const entry of (0, stored_zip_js_1.readStoredZip)(packed)) {
687
- fromArchive.set(entry.path, entry.bytes);
688
- }
689
- }
690
- catch (error) {
691
- /*
692
- Said out loud, not swallowed.
693
-
694
- This fell back silently, and the silence cost an hour: a deadline
695
- meant for ordinary requests was aborting the archive, the import
696
- dropped to fetching every file on its own, and from the outside it
697
- looked exactly like the slow clone the archive was added to fix.
698
- A fallback nobody can see is a fallback nobody can diagnose.
699
- */
700
- say(` the archive for v${version.sequence} could not be used ` +
701
- `(${error instanceof Error ? error.message : String(error)}); ` +
702
- `fetching its files one by one`);
703
- fromArchive.clear();
704
- }
696
+ /* Only content not already imported: a blob is written once, by digest. */
705
697
  const seenBlob = new Set();
706
698
  const wantedBlobs = files.filter((file) => {
707
699
  if (blobMark.has(file.sha256) || seenBlob.has(file.sha256))
@@ -709,6 +701,57 @@ async function doImport(refs, url) {
709
701
  seenBlob.add(file.sha256);
710
702
  return true;
711
703
  });
704
+ /*
705
+ The whole version in one request — but only when most of it is new.
706
+
707
+ The archive is every file the version holds, and this used to fetch it
708
+ for every version and then keep only the files not already imported.
709
+ After the first, that is nearly none of them: a five-version clone of
710
+ a large project downloaded its whole snapshot five times over to use
711
+ the changed files out of each, which is why an archive added to speed
712
+ clones up left one taking eighty-two minutes.
713
+
714
+ So it is weighed per version: where the files still wanted are at
715
+ least half of the version's bytes, one request for the lot wins; where
716
+ they are a handful, fetching just those does. Below a few dozen files
717
+ the round trips are not worth avoiding at all.
718
+
719
+ Where it is worth it, fetching per file would be one round trip each:
720
+ 21,071 of them on a real Unity project, four hours in series and about
721
+ half an hour even eight at a time.
722
+
723
+ Anything unexpected about the archive — an entry this cannot read, a
724
+ short download, a service that does not offer the route — falls
725
+ through to the per-file path below, which is slow and known to work.
726
+ A clone that is slow is a nuisance; a clone that writes the wrong bytes
727
+ is not recoverable by trying again.
728
+ */
729
+ const bytesOf = (list) => list.reduce((sum, file) => sum + Number(file.sourceSize ?? 0), 0);
730
+ const archiveWorthIt = wantedBlobs.length >= ARCHIVE_MIN_FILES &&
731
+ bytesOf(wantedBlobs) >= bytesOf(files) * ARCHIVE_MIN_SHARE;
732
+ const fromArchive = new Map();
733
+ if (archiveWorthIt)
734
+ try {
735
+ const packed = await downloader.archive(repositoryId, version.id);
736
+ for (const entry of (0, stored_zip_js_1.readStoredZip)(packed)) {
737
+ fromArchive.set(entry.path, entry.bytes);
738
+ }
739
+ }
740
+ catch (error) {
741
+ /*
742
+ Said out loud, not swallowed.
743
+
744
+ This fell back silently, and the silence cost an hour: a deadline
745
+ meant for ordinary requests was aborting the archive, the import
746
+ dropped to fetching every file on its own, and from the outside it
747
+ looked exactly like the slow clone the archive was added to fix.
748
+ A fallback nobody can see is a fallback nobody can diagnose.
749
+ */
750
+ say(` the archive for v${version.sequence} could not be used ` +
751
+ `(${error instanceof Error ? error.message : String(error)}); ` +
752
+ `fetching its files one by one`);
753
+ fromArchive.clear();
754
+ }
712
755
  for (let at = 0; at < wantedBlobs.length;) {
713
756
  const window = [];
714
757
  let held = 0;
@@ -1393,6 +1436,8 @@ async function main(argv) {
1393
1436
  clone of a project you cannot see left an empty folder and exited zero.
1394
1437
  */
1395
1438
  let unservable = false;
1439
+ /** `git clone --depth N`, when git passed one. */
1440
+ let depth = null;
1396
1441
  for await (const line of lines()) {
1397
1442
  const command = line.trim();
1398
1443
  if (command === "capabilities") {
@@ -1406,9 +1451,19 @@ async function main(argv) {
1406
1451
  continue;
1407
1452
  }
1408
1453
  if (command.startsWith("option ")) {
1409
- // Nothing here changes behaviour yet, and claiming otherwise would make
1410
- // git believe a setting took effect.
1411
- send("unsupported");
1454
+ /*
1455
+ Only what is actually acted on is accepted: answering "ok" to anything
1456
+ else would make git believe a setting took effect. Depth is honoured
1457
+ by importing fewer versions; git passes it for `clone --depth`.
1458
+ */
1459
+ const [, name, value] = command.split(" ");
1460
+ if (name === "depth" && /^[1-9][0-9]*$/.test(value ?? "")) {
1461
+ depth = Number(value);
1462
+ send("ok");
1463
+ }
1464
+ else {
1465
+ send("unsupported");
1466
+ }
1412
1467
  continue;
1413
1468
  }
1414
1469
  if (command === "list" || command === "list for-push") {
@@ -1521,7 +1576,7 @@ async function main(argv) {
1521
1576
  if (importing.length) {
1522
1577
  const batch = importing.splice(0, importing.length);
1523
1578
  try {
1524
- await doImport(batch, url);
1579
+ await doImport(batch, url, depth);
1525
1580
  }
1526
1581
  catch (error) {
1527
1582
  /*
@@ -42,11 +42,14 @@ function readStoredZip(archive) {
42
42
  const names = new TextDecoder("utf-8", { fatal: false });
43
43
  const entries = [];
44
44
  let at = 0;
45
+ let ended = false;
45
46
  while (at + 4 <= archive.length) {
46
47
  const signature = view.getUint32(at, true);
47
48
  /* Either record marks the end of the entries. */
48
- if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY)
49
+ if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY) {
50
+ ended = true;
49
51
  break;
52
+ }
50
53
  if (signature !== LOCAL_HEADER) {
51
54
  throw new Error(`Not a stored archive: unexpected record at byte ${at}`);
52
55
  }
@@ -81,5 +84,42 @@ function readStoredZip(archive) {
81
84
  });
82
85
  at = end;
83
86
  }
87
+ /*
88
+ A zip is only whole once its directory arrives.
89
+
90
+ Running out of bytes exactly between two entries used to read as the end
91
+ of the archive. It is also exactly what a download cut off at a file
92
+ boundary looks like — and the service's archive of a 9,010-file version
93
+ was being cut off, by a Worker limit, after 4,978 of them. Every one of
94
+ those read back perfectly, so the clone took them and fetched the other
95
+ 4,032 one at a time without a word, which is why it took half an hour.
96
+ Now the directory has to be there, and has to count what was read.
97
+ */
98
+ if (!ended) {
99
+ throw new Error(`Archive was cut off after ${entries.length} entries: it has no directory`);
100
+ }
101
+ const counted = directoryCount(view);
102
+ if (counted === null) {
103
+ throw new Error("Archive has no end-of-directory record");
104
+ }
105
+ if (counted !== entries.length) {
106
+ throw new Error(`Archive says it holds ${counted} entries and ${entries.length} were read`);
107
+ }
84
108
  return entries;
85
109
  }
110
+ /**
111
+ * How many entries the archive's end-of-directory record says it holds.
112
+ *
113
+ * Searched for from the end, as the format intends: the record is the last
114
+ * thing in the file, followed only by a comment of up to 64 KB.
115
+ */
116
+ function directoryCount(view) {
117
+ const last = view.byteLength - 22;
118
+ const first = Math.max(0, last - 0xffff);
119
+ for (let at = last; at >= first; at -= 1) {
120
+ if (view.getUint32(at, true) === END_OF_DIRECTORY) {
121
+ return view.getUint16(at + 10, true);
122
+ }
123
+ }
124
+ return null;
125
+ }
@@ -915,6 +915,53 @@ class Uploader {
915
915
  if (!completed.mergeTrack && !Object.keys(acceptedManifest).length) {
916
916
  acceptedManifest = Object.fromEntries([...(await this.versionFiles(repositoryId, completed.version.id))].map(([file, descriptor]) => [file, descriptor.sha256]));
917
917
  }
918
+ const manifest = Object.keys(acceptedManifest).length
919
+ ? acceptedManifest
920
+ : {
921
+ ...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
922
+ ...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
923
+ };
924
+ /*
925
+ What this folder holds after the save. A file that was sent holds
926
+ the bytes that were sent. Anything else keeps whatever digest was
927
+ recorded before: an unticked edit therefore stays pending rather
928
+ than looking saved, and a copy left stale by somebody else's merge
929
+ stays recognisably stale rather than looking like a new edit.
930
+
931
+ Looked up in a map rather than by searching the declarations for each
932
+ file, which on a 47,000-file project was over a billion comparisons at
933
+ the very end of a save.
934
+ */
935
+ const sentDigest = new Map(declarations.map((declaration) => [declaration.path, declaration.sha256]));
936
+ const onDisk = new Set(everything.map((file) => file.path));
937
+ const local = Object.fromEntries(everything.flatMap((file) => {
938
+ const sent = sentDigest.get(file.path);
939
+ if (sent)
940
+ return [[file.path, sent]];
941
+ const before = request.known?.[file.path] ?? prior.get(file.path)?.sha256;
942
+ return before ? [[file.path, before]] : [];
943
+ }));
944
+ /*
945
+ And a file deleted here that this save carried forward keeps its place.
946
+
947
+ A plain save adds and updates, so a file somebody deleted stays in the
948
+ version. It used to vanish from this record all the same, because the
949
+ record was built only from what is on disk — and the next status then
950
+ found the version holding a file this folder did not, called it
951
+ "behind", and offered to fetch it back. The person had deleted it; the
952
+ only thing that brought it back was being told to run `cbx get`.
953
+
954
+ Only where the record already had it. A file that was never here — one
955
+ somebody else's save merged into the version — has no entry to keep, so
956
+ it stays "behind", which is exactly what it is.
957
+ */
958
+ for (const path of Object.keys(manifest)) {
959
+ if (onDisk.has(path))
960
+ continue;
961
+ const held = request.known?.[path];
962
+ if (held)
963
+ local[path] = held;
964
+ }
918
965
  report({
919
966
  stage: "done",
920
967
  files: declarations.length,
@@ -940,29 +987,8 @@ class Uploader {
940
987
  /** Selected, but the service already held the content. */
941
988
  alreadyStoredFiles: progress.alreadyOnAccount,
942
989
  telemetry: this.telemetry.snapshot(),
943
- // A file that kept its old object records the digest of *that* copy,
944
- // not of the file on disk, so an unticked edit is still pending next
945
- // time rather than looking as though it had been saved.
946
- /*
947
- What this folder holds after the save. A file that was sent holds
948
- the bytes that were sent. Anything else keeps whatever digest was
949
- recorded before: an unticked edit therefore stays pending rather
950
- than looking saved, and a copy left stale by somebody else's merge
951
- stays recognisably stale rather than looking like a new edit.
952
- */
953
- local: Object.fromEntries(everything.flatMap((file) => {
954
- const sent = declarations.find((one) => one.path === file.path);
955
- if (sent)
956
- return [[file.path, sent.sha256]];
957
- const before = request.known?.[file.path] ?? prior.get(file.path)?.sha256;
958
- return before ? [[file.path, before]] : [];
959
- })),
960
- manifest: Object.keys(acceptedManifest).length
961
- ? acceptedManifest
962
- : {
963
- ...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
964
- ...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
965
- },
990
+ local,
991
+ manifest,
966
992
  };
967
993
  }
968
994
  /**
@@ -1452,7 +1478,50 @@ class Uploader {
1452
1478
  Read once, here. Everything below works from the declarations rather
1453
1479
  than from the disk.
1454
1480
  */
1455
- const { declarations, totalBytes } = await this.hashSelection(request, sending, deletions, readFailures, vanished, report);
1481
+ const hashed = await this.hashSelection(request, sending, deletions, readFailures, vanished, report);
1482
+ /*
1483
+ A file that is exactly what the last version held is kept, not sent.
1484
+
1485
+ Both callers select everything — the command line says so with an empty
1486
+ exclusion list, because its list of changes stops at twenty thousand —
1487
+ and a selected file used to be a file to send. Most of a project is
1488
+ small files, and small files are stored inside packs, so the service
1489
+ cannot recognise one as already held; the whole lot was packed again and
1490
+ sent again on every save. CodeBox's own saves sent 3.1 MB across 1,025
1491
+ files to record a change to eight.
1492
+
1493
+ The digest has just been taken, so the comparison is exact: same path,
1494
+ same bytes, and the previous version's object for them — a pack slice,
1495
+ its chunks, or a plain object — is named again instead.
1496
+ */
1497
+ const keptAsItWas = (declaration) => {
1498
+ const was = prior.get(declaration.path);
1499
+ return (!!was &&
1500
+ was.sha256 === declaration.sha256 &&
1501
+ !deletions.has(declaration.path) &&
1502
+ !!(was.pack?.objectId || was.chunks?.length || was.objectId));
1503
+ };
1504
+ const declarations = hashed.declarations.filter((one) => !keptAsItWas(one));
1505
+ let totalBytes = hashed.totalBytes;
1506
+ for (const one of hashed.declarations) {
1507
+ if (!keptAsItWas(one))
1508
+ continue;
1509
+ totalBytes -= one.size;
1510
+ const held = prior.get(one.path);
1511
+ reused.push({
1512
+ path: one.path,
1513
+ sourceSize: held.sourceSize,
1514
+ storedSize: held.storedSize,
1515
+ mediaType: held.mediaType,
1516
+ added: 0,
1517
+ removed: 0,
1518
+ included: true,
1519
+ deleted: false,
1520
+ binary: false,
1521
+ lines: 0,
1522
+ });
1523
+ }
1524
+ (0, profile_js_1.counted)("files kept as they were", hashed.declarations.length - declarations.length);
1456
1525
  // 2. The project needs somewhere on the account to live.
1457
1526
  const repositoryId = await this.ensureRepository(request);
1458
1527
  // Learn the wire contract once, before the two section pipelines begin.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.31.1",
3
+ "version": "0.31.3",
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",