@coderook/cli 0.30.1 → 0.31.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.30.1",
5
+ "version": "0.31.0",
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;
@@ -623,7 +623,21 @@ async function commandSubmit(parsed) {
623
623
  which is the wrong way round — so this is that capability, asked for
624
624
  plainly.
625
625
  */
626
- const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline, hasFlag(parsed, "sync") ? "synchronize" : "add-and-update");
626
+ /*
627
+ Digests this machine has taken before, read before the scan rather than
628
+ before the upload.
629
+
630
+ The uploader has always accepted these and the desktop has always passed
631
+ them, but on the command line the expensive part happens earlier: the
632
+ scan below reads and hashes the whole folder to decide what changed, and
633
+ that is where a 21,071-file project spent 169 of its 299 seconds. The
634
+ same map serves both — `changedFiles` fills it in place, so what the scan
635
+ learns is what the upload is handed.
636
+ */
637
+ const knownDigests = link?.repositoryId
638
+ ? await (0, config_js_1.readDigests)(link.repositoryId)
639
+ : new Map();
640
+ const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline, hasFlag(parsed, "sync") ? "synchronize" : "add-and-update", knownDigests);
627
641
  /*
628
642
  An upgraded folder with no materialisation record and something that
629
643
  differs from its recorded version is ambiguous in the one way that
@@ -788,6 +802,7 @@ Pass ${accent("--allow-secrets")} if these are not real keys.`);
788
802
  */
789
803
  const uploadRequest = (0, publish_js_1.uploadRequestFor)({
790
804
  localPath: folder,
805
+ ...(knownDigests.size ? { knownDigests } : {}),
791
806
  changed: files.filter((file) => !file.deleted).map((file) => file.path),
792
807
  deleted: files.filter((file) => file.deleted).map((file) => file.path),
793
808
  /*
@@ -994,6 +1009,15 @@ The connection failed: ${text}`));
994
1009
  local: result.local,
995
1010
  manifest: result.manifest,
996
1011
  });
1012
+ /*
1013
+ What this save hashed, kept for the next one.
1014
+
1015
+ Merged rather than replaced: `digestsTaken` holds only the files actually
1016
+ read this time, and everything served from the existing record has to
1017
+ survive or the next save reads it all again — which is precisely the cost
1018
+ this exists to avoid.
1019
+ */
1020
+ await (0, config_js_1.writeDigests)(result.repositoryId, new Map([...knownDigests, ...uploader.digestsTaken()]));
997
1021
  if (result.repeated) {
998
1022
  console.log(`Already saved as ${accent(`v${result.sequence}`)} by an earlier attempt;` +
999
1023
  ` nothing was sent again.`);
@@ -13,6 +13,8 @@ exports.keyFor = keyFor;
13
13
  exports.readLink = readLink;
14
14
  exports.forgetLink = forgetLink;
15
15
  exports.writeLink = writeLink;
16
+ exports.readDigests = readDigests;
17
+ exports.writeDigests = writeDigests;
16
18
  /**
17
19
  * Where the command-line tool keeps its credential and its project links.
18
20
  *
@@ -182,3 +184,24 @@ async function writeLink(localPath, link) {
182
184
  };
183
185
  await writePrivate(linksFile(), JSON.stringify(links, null, 2));
184
186
  }
187
+ const digestsFile = (projectId) => node_path_1.default.join(configDirectory(), "digests", `${projectId}.json`);
188
+ async function readDigests(projectId) {
189
+ try {
190
+ const raw = await (0, promises_1.readFile)(digestsFile(projectId), "utf8");
191
+ return new Map(Object.entries(JSON.parse(raw)));
192
+ }
193
+ catch {
194
+ /* No record yet, or one this build cannot read: hash everything. */
195
+ return new Map();
196
+ }
197
+ }
198
+ async function writeDigests(projectId, digests) {
199
+ if (!digests.size)
200
+ return;
201
+ try {
202
+ await writePrivate(digestsFile(projectId), JSON.stringify(Object.fromEntries(digests)));
203
+ }
204
+ catch {
205
+ /* The next save hashes again, which is where this started. */
206
+ }
207
+ }
@@ -114,6 +114,7 @@ const node_os_1 = __importDefault(require("node:os"));
114
114
  const node_path_1 = __importDefault(require("node:path"));
115
115
  const node_process_1 = __importDefault(require("node:process"));
116
116
  const upload_js_1 = require("../../desktop-app/src/main/upload.js");
117
+ const stored_zip_js_1 = require("./stored_zip.js");
117
118
  const tracks_js_1 = require("../../desktop-app/src/main/tracks.js");
118
119
  const identify_js_1 = require("../../desktop-app/src/main/identify.js");
119
120
  const config_js_1 = require("./config.js");
@@ -533,6 +534,17 @@ async function recordPushedCommits(marks) {
533
534
  * Contents are deduplicated by SHA-256 across the whole history, so a file
534
535
  * unchanged across a thousand versions is downloaded and sent to git once.
535
536
  */
537
+ /*
538
+ How many blobs a clone fetches at once, and how much it will hold doing it.
539
+
540
+ Eight lanes for the same reason the folder walks use eight: these are
541
+ latency, not work, and the measured curve flattens there. The byte ceiling
542
+ is what stops a window of large files becoming a window of memory — one
543
+ oversized file still goes through on its own, because the count check comes
544
+ first and an empty window always accepts one more.
545
+ */
546
+ const BLOB_LANES = 8;
547
+ const BLOB_WINDOW_BYTES = 64 * 1024 * 1024;
536
548
  async function doImport(refs, url) {
537
549
  const { owner, slug } = (0, git_history_js_1.parseRemoteUrl)(url);
538
550
  const project = await (0, api_js_1.findProject)(owner ? `${owner}/${slug}` : slug);
@@ -639,22 +651,99 @@ async function doImport(refs, url) {
639
651
  let done = 0;
640
652
  for (const version of fresh) {
641
653
  const files = await downloader.files(repositoryId, version.id);
642
- // Blobs first: fast-import requires a mark to exist before a commit
643
- // names it.
644
- for (const file of files) {
645
- if (blobMark.has(file.sha256))
646
- continue;
647
- const target = node_path_1.default.join(scratch, "blob.bin");
648
- await (0, promises_1.rm)(target, { force: true });
649
- await downloader.fileTo(repositoryId, version.id, file, target);
650
- const bytes = await (0, promises_1.readFile)(target);
651
- const mark = nextMark++;
652
- blobMark.set(file.sha256, mark);
653
- send("blob");
654
- send(`mark :${mark}`);
655
- send(`data ${bytes.length}`);
656
- node_process_1.default.stdout.write(bytes);
657
- node_process_1.default.stdout.write("\n");
654
+ /*
655
+ Blobs first: fast-import requires a mark to exist before a commit
656
+ names it. Fetched a window at a time and written out in order.
657
+
658
+ This asked for one file, waited for it, wrote it to a scratch path,
659
+ read it back, and only then asked for the next — so a clone cost one
660
+ network round trip per file, in series. Against a 21,071-file project
661
+ that is 21,071 round trips at about seven tenths of a second each,
662
+ and a clone that never finished: measured over twenty seconds it was
663
+ using 0.17s of processor and moving two kilobytes.
664
+
665
+ The stream fast-import reads has to stay ordered, so the fetching is
666
+ what overlaps, not the writing. The window is bounded by count and by
667
+ bytes together, because sixteen source files and sixteen video files
668
+ are not the same amount to be holding at once.
669
+ */
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
+ }
705
+ const seenBlob = new Set();
706
+ const wantedBlobs = files.filter((file) => {
707
+ if (blobMark.has(file.sha256) || seenBlob.has(file.sha256))
708
+ return false;
709
+ seenBlob.add(file.sha256);
710
+ return true;
711
+ });
712
+ for (let at = 0; at < wantedBlobs.length;) {
713
+ const window = [];
714
+ let held = 0;
715
+ while (at < wantedBlobs.length && window.length < BLOB_LANES) {
716
+ const size = Number(wantedBlobs[at].sourceSize ?? 0);
717
+ if (window.length > 0 && held + size > BLOB_WINDOW_BYTES)
718
+ break;
719
+ held += size;
720
+ window.push(wantedBlobs[at]);
721
+ at += 1;
722
+ }
723
+ const fetched = await Promise.all(window.map(async (file, lane) => {
724
+ /* Already in hand, if the archive carried it. */
725
+ const packed = fromArchive.get(file.path);
726
+ if (packed)
727
+ return { file, bytes: packed };
728
+ /*
729
+ A scratch path per lane. They shared one name, which is fine
730
+ while only one download is ever in flight and silently wrong
731
+ the moment two are.
732
+ */
733
+ const target = node_path_1.default.join(scratch, `blob-${lane}.bin`);
734
+ await (0, promises_1.rm)(target, { force: true });
735
+ await downloader.fileTo(repositoryId, version.id, file, target);
736
+ return { file, bytes: await (0, promises_1.readFile)(target) };
737
+ }));
738
+ for (const { file, bytes } of fetched) {
739
+ const mark = nextMark++;
740
+ blobMark.set(file.sha256, mark);
741
+ send("blob");
742
+ send(`mark :${mark}`);
743
+ send(`data ${bytes.length}`);
744
+ node_process_1.default.stdout.write(bytes);
745
+ node_process_1.default.stdout.write("\n");
746
+ }
658
747
  }
659
748
  const mark = nextMark++;
660
749
  commitMark.set(version.id, mark);
@@ -23,6 +23,11 @@ function uploadRequestFor(input) {
23
23
  ? { expectedHeadVersionId: input.baseVersionId }
24
24
  : {}),
25
25
  ...(input.known ? { known: input.known } : {}),
26
+ /*
27
+ Digests this machine has taken before, so an unchanged file is not read
28
+ again. The desktop has always passed these; the command line never did.
29
+ */
30
+ ...(input.knownDigests ? { knownDigests: input.knownDigests } : {}),
26
31
  };
27
32
  }
28
33
  function classifyPublishFailure(error) {
@@ -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",
@@ -0,0 +1,85 @@
1
+ "use strict";
2
+ /**
3
+ * Reading the archive CodeRook builds, without a compression library.
4
+ *
5
+ * A clone fetched every file individually: one HTTP round trip per blob, in
6
+ * series. Against a 21,071-file project that is 21,071 round trips, which at
7
+ * seven tenths of a second each is four hours — and the clone that exposed
8
+ * this simply never finished. Fetching eight at a time cut it to about half
9
+ * an hour; asking for the version as one archive cuts it to one request.
10
+ *
11
+ * Written by hand for the same reason `backend/src/archive.ts` writes it by
12
+ * hand: the entries are *stored*, never deflated, so the whole job is a
13
+ * header, a body, and skipping the directory at the end. Pulling in a zip
14
+ * library to not decompress anything would be a poor trade.
15
+ *
16
+ * Only the format that writer produces is accepted. Anything else — a
17
+ * deflated entry, a size deferred to a data descriptor, a Zip64 field — is
18
+ * refused rather than guessed at, and the caller falls back to fetching the
19
+ * files one by one. A reader that quietly mangles a project is worse than a
20
+ * slow one.
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.readStoredZip = readStoredZip;
24
+ const LOCAL_HEADER = 0x04034b50;
25
+ const CENTRAL_HEADER = 0x02014b50;
26
+ /* An archive of nothing has no entries and no directory records — it opens
27
+ with this, and a reader that only knew the other two would refuse it. */
28
+ const END_OF_DIRECTORY = 0x06054b50;
29
+ /** Set in the general-purpose flags when sizes come after the body. */
30
+ const SIZES_DEFERRED = 0x0008;
31
+ const STORED = 0;
32
+ /**
33
+ * Every entry in a stored archive, in the order it was written.
34
+ *
35
+ * Deliberately whole-buffer rather than streaming: the caller is holding one
36
+ * version's files to hand to fast-import, which needs them in order anyway,
37
+ * and a version large enough for this to matter is one the archive path
38
+ * should not be taking.
39
+ */
40
+ function readStoredZip(archive) {
41
+ const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength);
42
+ const names = new TextDecoder("utf-8", { fatal: false });
43
+ const entries = [];
44
+ let at = 0;
45
+ while (at + 4 <= archive.length) {
46
+ const signature = view.getUint32(at, true);
47
+ /* Either record marks the end of the entries. */
48
+ if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY)
49
+ break;
50
+ if (signature !== LOCAL_HEADER) {
51
+ throw new Error(`Not a stored archive: unexpected record at byte ${at}`);
52
+ }
53
+ if (at + 30 > archive.length)
54
+ throw new Error("Archive ends inside a header");
55
+ const flags = view.getUint16(at + 6, true);
56
+ const method = view.getUint16(at + 8, true);
57
+ const compressed = view.getUint32(at + 18, true);
58
+ const nameLength = view.getUint16(at + 26, true);
59
+ const extraLength = view.getUint16(at + 28, true);
60
+ if (method !== STORED) {
61
+ throw new Error(`Archive entry is compressed (method ${method})`);
62
+ }
63
+ if (flags & SIZES_DEFERRED) {
64
+ throw new Error("Archive entry defers its size to a data descriptor");
65
+ }
66
+ /*
67
+ 0xffffffff is Zip64's marker for "the real size is in an extra field".
68
+ This writer never emits one, so seeing it means the archive did not come
69
+ from where it is assumed to have come from.
70
+ */
71
+ if (compressed === 0xffffffff)
72
+ throw new Error("Archive entry needs Zip64");
73
+ const nameAt = at + 30;
74
+ const bodyAt = nameAt + nameLength + extraLength;
75
+ const end = bodyAt + compressed;
76
+ if (end > archive.length)
77
+ throw new Error("Archive ends inside an entry");
78
+ entries.push({
79
+ path: names.decode(archive.subarray(nameAt, nameAt + nameLength)),
80
+ bytes: archive.subarray(bodyAt, end),
81
+ });
82
+ at = end;
83
+ }
84
+ return entries;
85
+ }
@@ -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
  /*
@@ -61,6 +61,23 @@ async function pruneEmpty(root, removed) {
61
61
  directory = node_path_1.default.dirname(directory);
62
62
  }
63
63
  }
64
+ /**
65
+ * How long one request may go unanswered before it is treated as lost.
66
+ *
67
+ * The same three minutes the uploader uses, and for the same reason: what
68
+ * follows a deadline here is a retry, so the cost of it firing early on a
69
+ * genuinely slow call is one repeated request.
70
+ */
71
+ const REQUEST_DEADLINE_MS = 180_000;
72
+ /**
73
+ * And how long a whole-version archive may take to arrive.
74
+ *
75
+ * The service reads every file in the version out of object storage to build
76
+ * it, so the wait is proportional to the project rather than to the network.
77
+ * Fifteen minutes is far beyond any archive measured and still an answer
78
+ * rather than an indefinite wait.
79
+ */
80
+ const ARCHIVE_DEADLINE_MS = 900_000;
64
81
  /**
65
82
  * A percentage for work that is still going.
66
83
  *
@@ -127,7 +144,18 @@ class Downloader {
127
144
  }
128
145
  }
129
146
  }
130
- async attempt(route) {
147
+ async attempt(route,
148
+ /*
149
+ Longer where the service has to build the answer before it can send it.
150
+
151
+ A deadline sized for an ordinary request aborts a large archive halfway
152
+ through making it, and the git import quietly falls back to fetching
153
+ every file on its own — so the fix for a slow clone became the cause of
154
+ one. The archive for a 21,071-file version takes about ninety seconds
155
+ to assemble and longer under load; three minutes is a request that has
156
+ failed, not one that is still working.
157
+ */
158
+ deadline = REQUEST_DEADLINE_MS) {
131
159
  const token = await this.credentials.token();
132
160
  if (!token)
133
161
  throw new Error("Sign in again before downloading");
@@ -137,7 +165,19 @@ class Downloader {
137
165
  "user-agent": "CodeRook/0.1",
138
166
  ...(0, identify_js_1.clientHeaders)(),
139
167
  },
140
- signal: this.controller.signal,
168
+ /*
169
+ The download's own cancellation, and a deadline.
170
+
171
+ This carried only the cancel signal, so a request the service never
172
+ answered waited for ever — the same defect that was fixed in the
173
+ uploader and in the command line's own client, and missed here. A
174
+ restore that stops with no bytes moving and no message is the worse
175
+ half of it: the person is left without the thing they were restoring.
176
+ */
177
+ signal: AbortSignal.any([
178
+ this.controller.signal,
179
+ AbortSignal.timeout(deadline),
180
+ ]),
141
181
  });
142
182
  if (!response.ok) {
143
183
  const text = await response.text().catch(() => "");
@@ -217,6 +257,22 @@ class Downloader {
217
257
  authorName: String(row.authorName ?? ""),
218
258
  }));
219
259
  }
260
+ /**
261
+ * A whole version as one archive, for a caller that wants all of it.
262
+ *
263
+ * The git import asked for each file in its own request, which on a
264
+ * 21,071-file project is 21,071 round trips — four hours in series, and
265
+ * about half an hour even eight at a time. The service already builds the
266
+ * whole version as a single stored archive for the download button; this
267
+ * is that, for a caller that would otherwise ask file by file.
268
+ *
269
+ * Returned whole rather than streamed because the caller is handing the
270
+ * files to `git fast-import`, which wants them one after another anyway.
271
+ */
272
+ async archive(repositoryId, versionId) {
273
+ const response = await this.attempt(`/v1/repositories/${repositoryId}/versions/${versionId}/archive`, ARCHIVE_DEADLINE_MS);
274
+ return new Uint8Array(await response.arrayBuffer());
275
+ }
220
276
  async files(repositoryId, versionId) {
221
277
  const body = await this.json(`/v1/repositories/${repositoryId}/versions/${versionId}/files`);
222
278
  return (body.files ?? []).map((row) => {
@@ -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(/\/+$/, "")}`;