@coderook/cli 0.31.0 → 0.31.1

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.0",
5
+ "version": "0.31.1",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -3,13 +3,14 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.credentials = exports.DEFAULT_API = void 0;
6
+ exports.DamagedLinkError = exports.credentials = exports.DEFAULT_API = void 0;
7
7
  exports.apiOrigin = apiOrigin;
8
8
  exports.configDirectory = configDirectory;
9
9
  exports.storeToken = storeToken;
10
10
  exports.loadToken = loadToken;
11
11
  exports.clearToken = clearToken;
12
12
  exports.keyFor = keyFor;
13
+ exports.linkFileFor = linkFileFor;
13
14
  exports.readLink = readLink;
14
15
  exports.forgetLink = forgetLink;
15
16
  exports.writeLink = writeLink;
@@ -23,6 +24,7 @@ exports.writeDigests = writeDigests;
23
24
  * location follows each platform's convention rather than scattering dot
24
25
  * directories about.
25
26
  */
27
+ const node_crypto_1 = require("node:crypto");
26
28
  const node_fs_1 = require("node:fs");
27
29
  const promises_1 = require("node:fs/promises");
28
30
  const node_os_1 = require("node:os");
@@ -44,7 +46,21 @@ function configDirectory() {
44
46
  return node_path_1.default.join(process.env.XDG_CONFIG_HOME ?? node_path_1.default.join((0, node_os_1.homedir)(), ".config"), "coderook");
45
47
  }
46
48
  const tokenFile = () => node_path_1.default.join(configDirectory(), "token");
47
- const linksFile = () => node_path_1.default.join(configDirectory(), "links.json");
49
+ /*
50
+ One small file per folder, under `links/`.
51
+
52
+ It was one file for every folder this machine had ever linked, read whole and
53
+ rewritten whole — pretty-printed — by every command. On a real machine that
54
+ file reached 419 MB across 549 folders, 536 of which no longer existed, and
55
+ every `cbx` paid about three seconds to read it before doing anything and
56
+ another two and a half to write it back after a save. It also lost updates:
57
+ two commands in two folders each rewrote the lot, and the later one erased
58
+ the earlier one's change.
59
+
60
+ The old file is read once, split into these, and renamed out of the way.
61
+ */
62
+ const linksDirectory = () => node_path_1.default.join(configDirectory(), "links");
63
+ const legacyLinksFile = () => node_path_1.default.join(configDirectory(), "links.json");
48
64
  /**
49
65
  * Write a file only its owner can read, and atomically.
50
66
  *
@@ -136,17 +152,165 @@ function keyFor(localPath) {
136
152
  }
137
153
  /** How the key used to be worked out, so existing links keep working. */
138
154
  const legacyKeyFor = (localPath) => node_path_1.default.resolve(localPath).toLowerCase();
139
- async function readLinks() {
155
+ /**
156
+ * A link record that exists and cannot be read.
157
+ *
158
+ * Deliberately not the same as "no link". Reading a damaged record as absent
159
+ * makes a linked folder look new, and the next save creates a second project
160
+ * beside the real one — so this stops and names the file instead.
161
+ */
162
+ class DamagedLinkError extends Error {
163
+ file;
164
+ constructor(file, cause) {
165
+ super(`CodeRook's record of which project a folder belongs to is damaged: ${file}\n` +
166
+ "Nothing was changed. Restore that file from a backup, or move it aside " +
167
+ "to treat the folder as not yet linked.");
168
+ this.file = file;
169
+ this.name = "DamagedLinkError";
170
+ this.cause = cause;
171
+ }
172
+ }
173
+ exports.DamagedLinkError = DamagedLinkError;
174
+ /** The file one folder's link lives in. Hashed, because a key is a path. */
175
+ function linkFileFor(localPath) {
176
+ return storedFileFor(keyFor(localPath));
177
+ }
178
+ const storedFileFor = (key) => node_path_1.default.join(linksDirectory(), `${(0, node_crypto_1.createHash)("sha256").update(key).digest("hex").slice(0, 32)}.json`);
179
+ function sameMap(left, right) {
180
+ const keys = Object.keys(left);
181
+ if (keys.length !== Object.keys(right).length)
182
+ return false;
183
+ for (const key of keys)
184
+ if (left[key] !== right[key])
185
+ return false;
186
+ return true;
187
+ }
188
+ function pack(key, link) {
189
+ const { local, ...rest } = link;
190
+ if (local && sameMap(local, link.manifest)) {
191
+ return { key, link: { ...rest, localIsManifest: true } };
192
+ }
193
+ return { key, link: local ? { ...rest, local } : rest };
194
+ }
195
+ function unpack(stored) {
196
+ const { localIsManifest, ...link } = stored.link;
197
+ /* A copy, so a caller changing one cannot quietly change the other. */
198
+ return localIsManifest ? { ...link, local: { ...link.manifest } } : link;
199
+ }
200
+ async function exists(file) {
201
+ return (0, promises_1.access)(file).then(() => true, () => false);
202
+ }
203
+ /** One folder's record: undefined when there is none, and loud when damaged. */
204
+ async function readStored(key) {
205
+ const file = storedFileFor(key);
206
+ let text;
140
207
  try {
141
- return JSON.parse(await (0, promises_1.readFile)(linksFile(), "utf8"));
208
+ text = await (0, promises_1.readFile)(file, "utf8");
142
209
  }
143
- catch {
144
- return {};
210
+ catch (error) {
211
+ if (error.code === "ENOENT")
212
+ return undefined;
213
+ throw error;
214
+ }
215
+ try {
216
+ const stored = JSON.parse(text);
217
+ if (stored?.key !== key)
218
+ throw new Error("the record is for another folder");
219
+ return stored;
220
+ }
221
+ catch (error) {
222
+ throw new DamagedLinkError(file, error);
145
223
  }
146
224
  }
225
+ /**
226
+ * Write a record only if there is not one already.
227
+ *
228
+ * The migration's writer. A link written by a command running at the same
229
+ * moment is newer than anything in the old file, and must win. A hard link
230
+ * makes "create only if absent" one atomic step, and a reader never sees a
231
+ * half-written file; where the disk cannot do hard links the check and the
232
+ * move are two steps, which is only a race against a command in that same
233
+ * folder in the same instant.
234
+ */
235
+ async function writeStoredIfAbsent(stored) {
236
+ const target = storedFileFor(stored.key);
237
+ if (await exists(target))
238
+ return;
239
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
240
+ const temporary = `${target}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
241
+ await (0, promises_1.writeFile)(temporary, JSON.stringify(stored), "utf8");
242
+ await (0, promises_1.chmod)(temporary, 0o600).catch(() => undefined);
243
+ try {
244
+ await (0, promises_1.link)(temporary, target);
245
+ }
246
+ catch (error) {
247
+ const code = error.code;
248
+ if (code !== "EEXIST" && !(await exists(target))) {
249
+ await (0, promises_1.rename)(temporary, target);
250
+ return;
251
+ }
252
+ }
253
+ finally {
254
+ await (0, promises_1.rm)(temporary, { force: true }).catch(() => undefined);
255
+ }
256
+ }
257
+ /**
258
+ * Split the old single file into one per folder, once.
259
+ *
260
+ * Whoever renames it to `.migrating` first does the work, and anyone arriving
261
+ * while that is going on — or after a run that was interrupted — works from
262
+ * the `.migrating` copy too. Every write here is "only if absent", so running
263
+ * it twice is harmless and a newer link written meanwhile is never overwritten.
264
+ *
265
+ * A damaged old file is put back and reported rather than migrated as empty:
266
+ * that would silently unlink every folder on the machine.
267
+ */
268
+ async function migrateLegacyLinks() {
269
+ const legacy = legacyLinksFile();
270
+ const claimed = `${legacy}.migrating`;
271
+ await (0, promises_1.rename)(legacy, claimed).catch(() => undefined);
272
+ let text;
273
+ try {
274
+ text = await (0, promises_1.readFile)(claimed, "utf8");
275
+ }
276
+ catch (error) {
277
+ if (error.code === "ENOENT")
278
+ return;
279
+ throw error;
280
+ }
281
+ let links;
282
+ try {
283
+ links = JSON.parse(text);
284
+ }
285
+ catch (error) {
286
+ await (0, promises_1.rename)(claimed, legacy).catch(() => undefined);
287
+ throw new DamagedLinkError(legacy, error);
288
+ }
289
+ for (const [key, link] of Object.entries(links)) {
290
+ await writeStoredIfAbsent(pack(key, link));
291
+ }
292
+ /* Kept rather than deleted: it is somebody's data, and moving it is enough. */
293
+ await (0, promises_1.rename)(claimed, `${legacy}.migrated`).catch(() => undefined);
294
+ }
295
+ async function lookUp(localPath) {
296
+ for (const key of new Set([keyFor(localPath), legacyKeyFor(localPath)])) {
297
+ const stored = await readStored(key);
298
+ if (!stored)
299
+ continue;
300
+ if ("forgotten" in stored)
301
+ return null;
302
+ return unpack(stored);
303
+ }
304
+ return null;
305
+ }
147
306
  async function readLink(localPath) {
148
- const links = await readLinks();
149
- const link = links[keyFor(localPath)] ?? links[legacyKeyFor(localPath)] ?? null;
307
+ let link = await lookUp(localPath);
308
+ if (!link &&
309
+ ((await exists(legacyLinksFile())) ||
310
+ (await exists(`${legacyLinksFile()}.migrating`)))) {
311
+ await migrateLegacyLinks();
312
+ link = await lookUp(localPath);
313
+ }
150
314
  if (!link)
151
315
  return null;
152
316
  return {
@@ -162,27 +326,27 @@ async function readLink(localPath) {
162
326
  * afterwards — which is the one thing this is for.
163
327
  */
164
328
  async function forgetLink(localPath) {
165
- const links = await readLinks();
166
- const had = links[keyFor(localPath)] !== undefined ||
167
- links[legacyKeyFor(localPath)] !== undefined;
168
- if (!had)
329
+ if (!(await readLink(localPath)))
169
330
  return false;
170
- delete links[keyFor(localPath)];
171
- delete links[legacyKeyFor(localPath)];
172
- await writePrivate(linksFile(), JSON.stringify(links, null, 2));
331
+ for (const key of new Set([keyFor(localPath), legacyKeyFor(localPath)])) {
332
+ const forgotten = { key, forgotten: true };
333
+ await writePrivate(storedFileFor(key), JSON.stringify(forgotten));
334
+ }
173
335
  return true;
174
336
  }
175
337
  async function writeLink(localPath, link) {
176
- const links = await readLinks();
177
- // A folder recorded under the old key moves to the new one rather than
178
- // being left behind as a second connection to the same place.
179
- delete links[legacyKeyFor(localPath)];
338
+ const key = keyFor(localPath);
180
339
  const baseVersionId = link.baseVersionId ?? link.versionId;
181
- links[keyFor(localPath)] = {
340
+ const stored = pack(key, {
182
341
  ...link,
183
342
  ...(baseVersionId ? { baseVersionId, versionId: baseVersionId } : {}),
184
- };
185
- await writePrivate(linksFile(), JSON.stringify(links, null, 2));
343
+ });
344
+ await writePrivate(storedFileFor(key), JSON.stringify(stored));
345
+ // A folder recorded under the old key moves to the new one rather than
346
+ // being left behind as a second connection to the same place.
347
+ const legacy = legacyKeyFor(localPath);
348
+ if (legacy !== key)
349
+ await (0, promises_1.rm)(storedFileFor(legacy), { force: true });
186
350
  }
187
351
  const digestsFile = (projectId) => node_path_1.default.join(configDirectory(), "digests", `${projectId}.json`);
188
352
  async function readDigests(projectId) {
@@ -55,6 +55,27 @@ async function encodeForUpload(original, relativePath, allowGzip) {
55
55
  if (!(0, compression_policy_1.shouldTryGzip)(relativePath, original.byteLength, allowGzip))
56
56
  return plain;
57
57
  try {
58
+ /*
59
+ Ask a little of it before asking all of it.
60
+
61
+ The extension list is a guess and the cost of a miss used to be the whole
62
+ file. Two sampled windows make it an eighth of that, which is what lets
63
+ the decision stay measured — the principle above — for a format nothing
64
+ here has heard of, instead of measured-at-any-price.
65
+ */
66
+ if ((0, compression_policy_1.worthSampling)(original.byteLength)) {
67
+ let anyWindowArguesFor = false;
68
+ for (const window of (0, compression_policy_1.sampleWindows)(original.byteLength)) {
69
+ const sample = original.subarray(window.at, window.at + window.size);
70
+ const tried = await deflate(sample, { level: 6 });
71
+ if (!(0, compression_policy_1.sampleSaysDoNotBother)(sample.byteLength, tried.byteLength)) {
72
+ anyWindowArguesFor = true;
73
+ break;
74
+ }
75
+ }
76
+ if (!anyWindowArguesFor)
77
+ return plain;
78
+ }
58
79
  const packed = await deflate(original, { level: 6 });
59
80
  if (!(0, compression_policy_1.gzipIsWorthKeeping)(original.byteLength, packed.byteLength))
60
81
  return plain;
@@ -25,6 +25,7 @@ exports.rulesFromSuggestions = rulesFromSuggestions;
25
25
  * exactly as long as nothing is applied without somebody saying so.
26
26
  */
27
27
  const node_path_1 = __importDefault(require("node:path"));
28
+ const format_js_1 = require("../shared/format.js");
28
29
  /*
29
30
  Everything here is recoverable from something else in the project, or is
30
31
  another program's working state. Nothing that could be somebody's only copy
@@ -302,6 +303,46 @@ const BULK_FILES = 2_000;
302
303
  const BULK_SHARE = 0.7;
303
304
  /** Above this share of hand-written files it is source, not output. */
304
305
  const BULK_SOURCE_SHARE = 0.1;
306
+ /**
307
+ * And the same question asked in bytes, for a folder that is huge but not many.
308
+ *
309
+ * Counting files misses the shape that actually stops a save finishing. A real
310
+ * project — 253 files — kept thirteen `.gguf` model weights in `output/`: 73
311
+ * files, 10.67 GB, 99% of everything it would have sent and 29% of its file
312
+ * count. Every threshold above is about counts, so nothing was suggested, and
313
+ * the app set out to upload 10.7 GB of rebuildable model output without a word.
314
+ * It never finished, and the project still has no version.
315
+ *
316
+ * A gigabyte is the floor because below it the upload is not the problem and
317
+ * the question is not worth asking. The share is the same 0.7: a folder that is
318
+ * most of the bytes, whatever its file count.
319
+ */
320
+ const BULK_BYTES = 1024 * 1024 * 1024;
321
+ /*
322
+ Media somebody made, as opposed to bytes a machine emitted.
323
+
324
+ Needed only by the size test, and needed badly there. `SOURCE_EXTENSIONS`
325
+ above asks "did a person type this", which is the right question for a folder
326
+ judged on its file count — a cache holds no `.ts` files. It is the wrong
327
+ question for a folder judged on its size, because a game's art holds no `.ts`
328
+ files either, and by bytes the art is almost always most of the project. The
329
+ count rule has a calibration case for exactly that folder; a size rule without
330
+ this list would walk straight back into it and offer to leave the game out.
331
+ */
332
+ const AUTHORED_EXTENSIONS = new Set([
333
+ ".aseprite", ".blend", ".bmp", ".dds", ".exr", ".fbx", ".flac", ".gif",
334
+ ".glb", ".gltf", ".hdr", ".jpeg", ".jpg", ".m4a", ".mov", ".mp3", ".mp4",
335
+ ".obj", ".ogg", ".otf", ".pdf", ".png", ".psd", ".svg", ".tga", ".tif",
336
+ ".tiff", ".ttf", ".wav", ".webm", ".webp", ".woff", ".woff2",
337
+ ]);
338
+ /**
339
+ * Above this share of a folder's *bytes* it is a library of made things.
340
+ *
341
+ * By bytes rather than by file count, because that is the currency the test it
342
+ * guards is arguing in: one 4 GB video beside a thousand generated logs is a
343
+ * folder of video, and counting files would call it a folder of logs.
344
+ */
345
+ const AUTHORED_BYTE_SHARE = 0.5;
305
346
  /**
306
347
  * One enormous folder that no list of names could have known about.
307
348
  *
@@ -309,42 +350,70 @@ const BULK_SOURCE_SHARE = 0.1;
309
350
  * things every project has and not at all for the one a person invented. This
310
351
  * repository's own `work/` is 63,210 of the 64,554 files it would otherwise
311
352
  * send — scratch output from a test harness, named nothing in particular, and
312
- * invisible to every rule in this file.
353
+ * invisible to every rule in this file. Another project's `output/` is 10.67 GB
354
+ * of model weights in 73 files, which no count-based threshold can see at all.
313
355
  *
314
- * So this asks about shape instead of name: one folder that is most of the
315
- * upload and holds no source in it. Never recommended, because the same shape
316
- * is also what a genuine dataset looks like, and a project whose work really
317
- * is fifty thousand data files should be asked once and then believed.
356
+ * So this asks about shape instead of name, in both currencies: one folder that
357
+ * is most of what would be sent — by file count or by size — and holds no
358
+ * source in it. Never recommended, because the same shape is also what a
359
+ * genuine dataset looks like, and a project whose work really is fifty thousand
360
+ * data files should be asked once and then believed.
318
361
  */
319
362
  function suggestBulkDirectories(files) {
320
- if (files.length < BULK_FILES)
321
- return [];
322
363
  const tally = new Map();
364
+ let totalBytes = 0;
323
365
  for (const entry of files) {
324
366
  const normalised = entry.path.split("\\").join("/");
367
+ totalBytes += entry.size;
325
368
  const top = normalised.includes("/") ? normalised.slice(0, normalised.indexOf("/")) : "";
326
369
  /* A file at the root belongs to no folder, so it cannot be one of these. */
327
370
  if (!top)
328
371
  continue;
329
- const held = tally.get(top) ?? { bytes: 0, files: 0, source: 0 };
372
+ const held = tally.get(top) ?? { bytes: 0, files: 0, source: 0, authored: 0 };
373
+ const extension = node_path_1.default.extname(normalised).toLowerCase();
330
374
  held.bytes += entry.size;
331
375
  held.files += 1;
332
- if (SOURCE_EXTENSIONS.has(node_path_1.default.extname(normalised).toLowerCase()))
376
+ if (SOURCE_EXTENSIONS.has(extension))
333
377
  held.source += 1;
378
+ if (AUTHORED_EXTENSIONS.has(extension))
379
+ held.authored += entry.size;
334
380
  tally.set(top, held);
335
381
  }
336
382
  const found = [];
337
383
  for (const [top, held] of tally) {
338
- if (held.files < BULK_FILES)
339
- continue;
340
- if (held.files / files.length < BULK_SHARE)
341
- continue;
384
+ /*
385
+ Whatever else is true of it, a folder with source in it is somebody's
386
+ work. This is the one test both shapes below have to pass.
387
+ */
342
388
  if (held.source / held.files >= BULK_SOURCE_SHARE)
343
389
  continue;
390
+ const byCount = files.length >= BULK_FILES &&
391
+ held.files >= BULK_FILES &&
392
+ held.files / files.length >= BULK_SHARE;
393
+ const byBytes = held.bytes >= BULK_BYTES &&
394
+ held.bytes / Math.max(totalBytes, 1) >= BULK_SHARE &&
395
+ /*
396
+ And it is not a library of made things. A game's art is most of a game
397
+ by size and is the last thing anybody wants left out of the save.
398
+ */
399
+ held.authored / Math.max(held.bytes, 1) < AUTHORED_BYTE_SHARE;
400
+ if (!byCount && !byBytes)
401
+ continue;
402
+ /*
403
+ Described in whichever currency makes the case. "73 of 253 files" is not
404
+ an argument for leaving anything out; "10.7 GB of 10.8 GB" is the entire
405
+ argument, and the person reading it is deciding whether to spend an
406
+ evening uploading. Where both are true, size is still the thing that
407
+ hurts, so size is what gets said.
408
+ */
409
+ const reason = byBytes
410
+ ? `${(0, format_js_1.formatBytes)(held.bytes)} of ${(0, format_js_1.formatBytes)(totalBytes)}, ` +
411
+ `and no source in it — generated?`
412
+ : `${held.files.toLocaleString()} of ${files.length.toLocaleString()} files, ` +
413
+ `and no source in it — generated?`;
344
414
  found.push({
345
415
  pattern: `${top}/`,
346
- reason: `${held.files.toLocaleString()} of ${files.length.toLocaleString()} files, ` +
347
- `and no source in it — generated?`,
416
+ reason,
348
417
  bytes: held.bytes,
349
418
  files: held.files,
350
419
  recommended: false,
@@ -21,6 +21,7 @@ const identify_js_1 = require("./identify.js");
21
21
  const worktree_js_1 = require("./worktree.js");
22
22
  const publish_name_js_1 = require("./publish_name.js");
23
23
  const compress_js_1 = require("./compress.js");
24
+ const compression_policy_js_1 = require("../shared/compression_policy.js");
24
25
  const chunking_js_1 = require("../shared/chunking.js");
25
26
  const publication_js_1 = require("../shared/publication.js");
26
27
  const profile_js_1 = require("./profile.js");
@@ -125,9 +126,21 @@ class Uploader {
125
126
  /** One shared preflight, so parallel upload lanes cannot race /health. */
126
127
  capabilityRequest = null;
127
128
  telemetry = new telemetry_js_1.RepositoryTelemetry();
128
- /** Planning runs the real partition/compression choices without body I/O. */
129
+ /*
130
+ Planning makes the real partition and compression choices, and for a
131
+ chunked file that means reading it: a chunk's identity is the digest of its
132
+ bytes, and the quote has to name every one. This comment used to say the
133
+ plan did no body I/O, which was never true, and the belief that it was is
134
+ how the send came to do all of that reading a second time.
135
+ */
129
136
  planning = false;
130
137
  plannedObjects = new Map();
138
+ /**
139
+ * Where each chunked file was cut, as the plan found it, so the send does not
140
+ * cut it again. Keyed by path and whole-file digest: a file that changed in
141
+ * between has a different digest, finds nothing here, and is walked afresh.
142
+ */
143
+ plannedLayouts = new Map();
131
144
  plannedQuote = null;
132
145
  /*
133
146
  Digests worked out while planning, so sending does not work them out again.
@@ -320,6 +333,7 @@ class Uploader {
320
333
  this.plannedQuote = null;
321
334
  this.planningCreatedRepositoryId = null;
322
335
  this.plannedObjects.clear();
336
+ this.plannedLayouts.clear();
323
337
  try {
324
338
  await this.runPass(request, report);
325
339
  const quote = this.plannedQuote;
@@ -2075,18 +2089,36 @@ class Uploader {
2075
2089
  */
2076
2090
  async putChunked(repositoryId, declaration, profile, onOffset) {
2077
2091
  /*
2078
- Read in windows, not all at once.
2092
+ Three steps, and each file is read for as long as it has to be.
2079
2093
 
2080
- This used to load the whole file to chunk it, which for the files that
2081
- reach this path — everything past sixteen megabytes — meant an allocation
2082
- the size of the file. A three gigabyte video was a three gigabyte buffer
2083
- and an out-of-memory crash partway through an upload, which is the one
2084
- failure worse than a slow one: it happens after the work.
2094
+ This used to walk the file once, asking the service about every four
2095
+ chunks as it went and waiting for each answer before reading on — then
2096
+ the send walked it again and asked all over again. A 10.6 GB project is
2097
+ 8,395 chunks: 2,099 lookups per pass, one after another, at about a
2098
+ third of a second each. Twelve minutes of waiting per pass, on a stage
2099
+ the window labels "Working out what to send", before a byte had moved.
2085
2100
 
2086
- The window holds at most one read plus one maximum chunk, whatever the
2087
- file is. Exactly how cbx.ts packs a bundle, and for the same reason.
2101
+ Now: find where the file is cut and what each piece's digest is (the plan
2102
+ does this; the send is handed the plan's answer), ask about every digest
2103
+ at once — one request per five thousand — and then read only the pieces
2104
+ that are not already held, by position, several at a time.
2088
2105
  */
2089
- const chunks = [];
2106
+ const key = `${declaration.path}\n${declaration.sha256}`;
2107
+ let layout = this.planning ? undefined : this.plannedLayouts.get(key);
2108
+ /*
2109
+ Walking the file is half this file's progress when it happens here and
2110
+ none of it when the plan already did it, so the bar neither jumps back
2111
+ between the two halves nor sits still through the first.
2112
+ */
2113
+ const walking = !layout;
2114
+ if (!layout) {
2115
+ layout = await this.layoutOf(declaration, profile, (offset) => onOffset(Math.floor(offset / 2)));
2116
+ if (this.planning)
2117
+ this.plannedLayouts.set(key, layout);
2118
+ }
2119
+ const already = walking ? declaration.size / 2 : 0;
2120
+ const share = walking ? 0.5 : 1;
2121
+ const chunks = new Array(layout.length);
2090
2122
  let reused = 0;
2091
2123
  let sent = 0;
2092
2124
  let done = 0;
@@ -2096,6 +2128,12 @@ class Uploader {
2096
2128
  else
2097
2129
  sent += bytes;
2098
2130
  };
2131
+ /*
2132
+ Asked all at once, because nothing about the question needs the bytes.
2133
+ The old batch was capped at four because each entry held a piece in
2134
+ memory; the digests alone are sixty-four characters each.
2135
+ */
2136
+ const held = await this.storedInBulk(repositoryId, layout.map((piece) => piece.digest));
2099
2137
  /*
2100
2138
  A file's chunks go up several at a time.
2101
2139
 
@@ -2105,68 +2143,108 @@ class Uploader {
2105
2143
  out and only the big one was left — five lanes idle while one crawled.
2106
2144
  For a folder that is *one* large file, that was the whole upload.
2107
2145
 
2108
- Bounded tighter than the file lanes because each one holds a piece in
2109
- memory: four in flight of at most a maximum chunk each, rather than six.
2146
+ Bounded because each lane holds one piece in memory: four in flight of
2147
+ at most a maximum chunk each.
2110
2148
  */
2111
2149
  const CHUNK_LANES = 4;
2112
- const active = new Set();
2113
- const dispatch = async (piece, at, digest) => {
2114
- const task = this.sendChunk(repositoryId, declaration, piece, digest, chunks, at, note).finally(() => {
2115
- active.delete(task);
2116
- done += piece.length;
2117
- onOffset(done);
2118
- });
2119
- active.add(task);
2120
- if (active.size >= CHUNK_LANES)
2121
- await Promise.race(active);
2122
- };
2123
2150
  /*
2124
- Ask about a bounded group of chunks before sending any of them.
2125
-
2126
- The old path asked once per chunk, with four GETs in flight. Besides
2127
- spending one network round trip per piece, an unavailable lookup was
2128
- deliberately treated as a miss, so a transient failure retransmitted
2129
- every otherwise reusable chunk. The website already uses the bulk
2130
- endpoint. Reusing it here gives Desktop and CLI the same wire behaviour
2131
- while retaining the safe "send it if unsure" fallback.
2132
-
2133
- A batch is no larger than the existing chunk lane count, so this does
2134
- not increase the memory ceiling: at most four bounded pieces are held.
2151
+ One verdict for this file, shared by its lanes, and consulted only where
2152
+ the plan has not already decided. The send takes each chunk's encoding
2153
+ from the plan instead, because the service holds every object to exactly
2154
+ what was quoted and rejects anything else — and this verdict depends on
2155
+ which of four lanes finishes compressing first, so two passes asking it
2156
+ separately could disagree about a chunk right where it gives up.
2135
2157
  */
2136
- const batch = [];
2137
- const flush = async () => {
2138
- if (!batch.length)
2139
- return;
2140
- const ready = batch.splice(0, batch.length);
2141
- const held = await this.storedInBulk(repositoryId, ready.map((item) => item.digest));
2142
- for (const item of ready) {
2143
- const stored = held.get(item.digest);
2144
- if (stored) {
2145
- note("reused", 0);
2146
- chunks[item.at] = {
2147
- objectId: stored.objectId,
2148
- sourceSize: item.piece.length,
2149
- storedSize: stored.size,
2150
- };
2151
- done += item.piece.length;
2152
- onOffset(done);
2153
- continue;
2158
+ const gzip = (0, compression_policy_js_1.freshGzipVerdict)();
2159
+ const pieces = layout;
2160
+ const file = await (0, promises_1.open)(declaration.full, "r");
2161
+ try {
2162
+ let cursor = 0;
2163
+ const lane = async () => {
2164
+ for (;;) {
2165
+ const at = cursor;
2166
+ cursor += 1;
2167
+ if (at >= pieces.length)
2168
+ return;
2169
+ this.check();
2170
+ const piece = pieces[at];
2171
+ const stored = held.get(piece.digest);
2172
+ if (stored) {
2173
+ note("reused", 0);
2174
+ chunks[at] = {
2175
+ objectId: stored.objectId,
2176
+ sourceSize: piece.length,
2177
+ storedSize: stored.size,
2178
+ };
2179
+ }
2180
+ else {
2181
+ const bytes = Buffer.allocUnsafe(piece.length);
2182
+ let got = 0;
2183
+ while (got < piece.length) {
2184
+ const { bytesRead } = await file.read(bytes, got, piece.length - got, piece.offset + got);
2185
+ if (!bytesRead)
2186
+ break;
2187
+ got += bytesRead;
2188
+ }
2189
+ /*
2190
+ Checked, because the layout may be the plan's and the file may
2191
+ have been written to since without its size or time moving. The
2192
+ service would refuse the piece anyway; this says why, in words.
2193
+ */
2194
+ if (got !== piece.length ||
2195
+ (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex") !== piece.digest) {
2196
+ throw new Error(`${declaration.path} changed while it was being saved. ` +
2197
+ "Save again to send what it holds now.");
2198
+ }
2199
+ await this.sendChunk(repositoryId, declaration, bytes, piece.digest, chunks, at, note, gzip);
2200
+ }
2201
+ done += piece.length;
2202
+ onOffset(Math.floor(already + done * share));
2154
2203
  }
2155
- await dispatch(item.piece, item.at, item.digest);
2204
+ };
2205
+ await Promise.all(Array.from({ length: Math.min(CHUNK_LANES, pieces.length) }, () => lane()));
2206
+ }
2207
+ finally {
2208
+ await file.close();
2209
+ }
2210
+ /*
2211
+ Order is the file. Every position must be filled: a hole would mean a
2212
+ chunk that never landed, and publishing around it would produce a version
2213
+ whose bytes are individually verified and collectively wrong.
2214
+
2215
+ Counted by index rather than with `some`, which skips the holes of a
2216
+ sparse array — the one thing this check exists to find — and so could
2217
+ never have fired.
2218
+ */
2219
+ for (let at = 0; at < pieces.length; at += 1) {
2220
+ if (!chunks[at]) {
2221
+ throw new Error(`${declaration.path}: a chunk did not finish uploading`);
2156
2222
  }
2157
- await Promise.all(active);
2158
- };
2159
- const queue = async (piece) => {
2160
- batch.push({
2161
- piece,
2162
- at: ordinal++,
2163
- digest: (0, node_crypto_1.createHash)("sha256").update(piece).digest("hex"),
2164
- });
2165
- if (batch.length >= CHUNK_LANES)
2166
- await flush();
2223
+ }
2224
+ return {
2225
+ chunks: chunks,
2226
+ reused,
2227
+ sentBytes: sent,
2167
2228
  };
2168
- let ordinal = 0;
2229
+ }
2230
+ /**
2231
+ * Where a file is cut into chunks, and the digest of each piece.
2232
+ *
2233
+ * Read in windows, not all at once. This used to load the whole file to
2234
+ * chunk it, which for the files that reach this path meant an allocation the
2235
+ * size of the file: a three gigabyte video was a three gigabyte buffer and an
2236
+ * out-of-memory crash partway through an upload. The window holds at most
2237
+ * one read plus one maximum chunk, whatever the file is — exactly how cbx.ts
2238
+ * packs a bundle, and for the same reason.
2239
+ *
2240
+ * Nothing is kept but offsets and digests, so a ten gigabyte file costs a few
2241
+ * hundred kilobytes to remember between the plan and the send.
2242
+ */
2243
+ async layoutOf(declaration, profile, onScanned) {
2244
+ const layout = [];
2245
+ const digestOf = (bytes) => (0, node_crypto_1.createHash)("sha256").update(bytes).digest("hex");
2169
2246
  let pending = Buffer.alloc(0);
2247
+ let pendingAt = 0;
2170
2248
  for await (const block of (0, node_fs_1.createReadStream)(declaration.full, {
2171
2249
  highWaterMark: 8 * 1024 * 1024,
2172
2250
  })) {
@@ -2175,10 +2253,16 @@ class Uploader {
2175
2253
  pending = pending.length ? Buffer.concat([pending, incoming]) : incoming;
2176
2254
  let from = 0;
2177
2255
  for (const cut of (0, chunking_js_1.cutPoints)(pending, profile)) {
2178
- await queue(Buffer.from(pending.subarray(from, cut)));
2256
+ layout.push({
2257
+ offset: pendingAt + from,
2258
+ length: cut - from,
2259
+ digest: digestOf(pending.subarray(from, cut)),
2260
+ });
2179
2261
  from = cut;
2180
2262
  }
2263
+ pendingAt += from;
2181
2264
  pending = Buffer.from(pending.subarray(from));
2265
+ onScanned(pendingAt);
2182
2266
  }
2183
2267
  /*
2184
2268
  cutPoints returns the boundaries *inside* what it was given and never the
@@ -2186,23 +2270,10 @@ class Uploader {
2186
2270
  mention — the packer in cbx.ts emits that tail itself, after its loop.
2187
2271
  Dropping it truncates every chunked file by exactly its last piece.
2188
2272
  */
2189
- if (pending.length)
2190
- await queue(pending);
2191
- await flush();
2192
- /*
2193
- Order is the file. Every position must be filled: a hole would mean a
2194
- chunk that never landed, and publishing around it would produce a version
2195
- whose bytes are individually verified and collectively wrong.
2196
- */
2197
- const ordered = chunks.slice(0, ordinal);
2198
- if (ordered.some((one) => !one)) {
2199
- throw new Error(`${declaration.path}: a chunk did not finish uploading`);
2273
+ if (pending.length) {
2274
+ layout.push({ offset: pendingAt, length: pending.length, digest: digestOf(pending) });
2200
2275
  }
2201
- return {
2202
- chunks: ordered,
2203
- reused,
2204
- sentBytes: sent,
2205
- };
2276
+ return layout;
2206
2277
  }
2207
2278
  /** Send one chunk, reusing it if the account already holds those bytes. */
2208
2279
  async sendChunk(repositoryId, declaration, piece, digest,
@@ -2213,10 +2284,29 @@ class Uploader {
2213
2284
  large file out of sequence, which produces bytes that are all present,
2214
2285
  all verified individually, and wrong.
2215
2286
  */
2216
- chunks, at, note) {
2287
+ chunks, at, note,
2288
+ /** This file's running verdict on compression; see {@link noteChunkOutcome}. */
2289
+ gzip) {
2217
2290
  {
2218
2291
  this.check();
2219
- const encoded = await (0, compress_js_1.encodeForUpload)(new Uint8Array(piece), declaration.path, await this.gzipAllowed());
2292
+ /*
2293
+ What the plan decided, where it decided. The quote names each object's
2294
+ encoding and the service rejects a piece that arrives any other way,
2295
+ so the send repeats the plan's answer rather than working one out. The
2296
+ same bytes with the same answer compress to the same bytes, which is
2297
+ what makes repeating it exact.
2298
+ */
2299
+ const planned = this.planning ? undefined : this.plannedObjects.get(digest);
2300
+ const encoded = await (0, compress_js_1.encodeForUpload)(new Uint8Array(piece), declaration.path, planned
2301
+ ? planned.encoding === "gzip"
2302
+ : gzip.allowed && (await this.gzipAllowed()));
2303
+ /*
2304
+ Read the outcome back rather than the guess. A chunk that kept gzip
2305
+ proves the file is worth compressing; a run of chunks that did not
2306
+ proves it is not, and there is no reason to ask the rest of a gigabyte.
2307
+ */
2308
+ if (!planned)
2309
+ (0, compression_policy_js_1.noteChunkOutcome)(gzip, encoded.encoding === "gzip");
2220
2310
  const query = encoded.encoding === "gzip"
2221
2311
  ? `?kind=chunk&role=chunk&encoding=gzip` +
2222
2312
  `&logicalSize=${piece.length}` +
@@ -4,6 +4,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.IGNORE_HEADING = exports.KEEP_HEADING = exports.LOCAL_EXCLUDE_FILE = exports.IGNORE_FILE = exports.STARTER_IGNORE = exports.CODEROOK_TAIL = exports.CODEROOK_TAIL_HEADING = exports.KEPT_ANYWAY = exports.KEPT_ANYWAY_HEADING = exports.ALSO_LEFT_OUT = exports.ALSO_LEFT_OUT_HEADING = exports.STARTER_MARKER = exports.EVALUATION_FILE_LIMIT = void 0;
7
+ exports.ruleTarget = ruleTarget;
8
+ exports.keptTargets = keptTargets;
9
+ exports.withoutOpposites = withoutOpposites;
7
10
  exports.starterAuthored = starterAuthored;
8
11
  exports.missingStarterRules = missingStarterRules;
9
12
  exports.missingEngineRules = missingEngineRules;
@@ -11,6 +14,7 @@ exports.isRepository = isRepository;
11
14
  exports.migrateRetiredRules = migrateRetiredRules;
12
15
  exports.starterFor = starterFor;
13
16
  exports.moveRule = moveRule;
17
+ exports.acceptSuggestions = acceptSuggestions;
14
18
  exports.readRules = readRules;
15
19
  exports.writeRules = writeRules;
16
20
  exports.collectLayers = collectLayers;
@@ -224,6 +228,93 @@ function ruleLines(text) {
224
228
  .map((line) => line.trim())
225
229
  .filter((line) => line.length > 0 && !line.startsWith("#"));
226
230
  }
231
+ /**
232
+ * What a rule line is about, with the parts that do not change the answer
233
+ * taken off: the `!`, and a slash at either end.
234
+ *
235
+ * `output/`, `/output/` and `!output/` all name the same folder, and deciding
236
+ * whether two lines disagree has to compare what they name rather than how they
237
+ * were spelt — the detector writes one spelling, the filter screen another, and
238
+ * a person a third.
239
+ */
240
+ function ruleTarget(line) {
241
+ return line.trim().replace(/^!/, "").replace(/^\/+|\/+$/g, "");
242
+ }
243
+ /** Every thing this file explicitly keeps, by {@link ruleTarget}. */
244
+ function keptTargets(text) {
245
+ return new Set(ruleLines(text)
246
+ .filter((line) => line.startsWith("!"))
247
+ .map(ruleTarget));
248
+ }
249
+ /**
250
+ * The file without any line that says the opposite of `line` about the same
251
+ * thing, and without a heading that is left with nothing under it.
252
+ *
253
+ * Needed because every writer here appends. A keep has to come after whatever
254
+ * would take the file, and a new ignore after a keep beats it, so the last
255
+ * word always wins — but the earlier word stayed in the file. A real project
256
+ * ended up with `# Always include` / `!output/` directly above
257
+ * `# Suggested for this folder, and accepted.` / `output/`: correct to a
258
+ * matcher, and to anybody reading it, a file that says both things.
259
+ *
260
+ * Only the exact opposite goes. A broader rule — `*.log` above a kept
261
+ * `!important.log` — is not about the same thing and is what makes the keep
262
+ * mean anything, so it is left alone. Everything the removal did not touch is
263
+ * returned byte for byte, line endings included.
264
+ */
265
+ function withoutOpposites(body, line) {
266
+ const target = ruleTarget(line);
267
+ const keeping = line.trim().startsWith("!");
268
+ const lines = body.split(NEWLINE);
269
+ const plain = (at) => (lines[at] ?? "").trim();
270
+ const isRule = (at) => plain(at).length > 0 && !plain(at).startsWith("#");
271
+ const drop = new Set();
272
+ /* Paragraphs are runs of non-blank lines; a heading belongs to its own. */
273
+ let start = 0;
274
+ while (start < lines.length) {
275
+ if (!plain(start)) {
276
+ start += 1;
277
+ continue;
278
+ }
279
+ let end = start;
280
+ while (end + 1 < lines.length && plain(end + 1))
281
+ end += 1;
282
+ let touched = false;
283
+ for (let at = start; at <= end; at += 1) {
284
+ if (!isRule(at))
285
+ continue;
286
+ const other = plain(at);
287
+ if (ruleTarget(other) === target && other.startsWith("!") !== keeping) {
288
+ drop.add(at);
289
+ touched = true;
290
+ }
291
+ }
292
+ /*
293
+ A heading over nothing is a claim about nothing. Dropped with its
294
+ paragraph, and one blank line beside it, so the file does not gather
295
+ empty gaps where decisions used to be.
296
+ */
297
+ if (touched) {
298
+ let left = false;
299
+ for (let at = start; at <= end; at += 1) {
300
+ if (isRule(at) && !drop.has(at))
301
+ left = true;
302
+ }
303
+ if (!left) {
304
+ for (let at = start; at <= end; at += 1)
305
+ drop.add(at);
306
+ if (end + 1 < lines.length && !plain(end + 1))
307
+ drop.add(end + 1);
308
+ else if (start > 0 && !plain(start - 1))
309
+ drop.add(start - 1);
310
+ }
311
+ }
312
+ start = end + 1;
313
+ }
314
+ if (!drop.size)
315
+ return body;
316
+ return lines.filter((_line, at) => !drop.has(at)).join(NEWLINE);
317
+ }
227
318
  /**
228
319
  * Whether this .gitignore was written by CodeRook rather than by a person.
229
320
  *
@@ -255,7 +346,14 @@ function starterAuthored(text) {
255
346
  */
256
347
  function missingStarterRules(text, reference = exports.STARTER_IGNORE) {
257
348
  const present = new Set(ruleLines(text));
258
- return ruleLines(reference).filter((line) => !present.has(line));
349
+ /*
350
+ Not a line the person has already answered. A starter rule for something
351
+ this file explicitly keeps is not missing, it was overruled — and offering
352
+ it back would append it after the keep, where it wins, and take out the
353
+ very thing somebody said to always include.
354
+ */
355
+ const kept = keptTargets(text);
356
+ return ruleLines(reference).filter((line) => !present.has(line) && (line.startsWith("!") || !kept.has(ruleTarget(line))));
259
357
  }
260
358
  /**
261
359
  * The rules this folder's own file is missing for the kind of project it is.
@@ -423,12 +521,23 @@ async function moveRule(root, file, keep) {
423
521
  .catch(() => file.endsWith("/"));
424
522
  const target = directory ? `${bare}/` : bare;
425
523
  const line = keep ? `!${target}` : target;
524
+ /*
525
+ The opposite answer goes first. Moving a folder that was kept onto the
526
+ ignore list used to add `output/` after `!output/` and leave both, so the
527
+ file said both things and only the order decided which it meant.
528
+ */
529
+ const settled = withoutOpposites(rules.shared, line);
426
530
  /* Saying it twice changes nothing and makes the file harder to read. */
427
- const already = ruleLines(rules.shared).some((one) => one === line);
428
- if (already)
429
- return rules;
531
+ const already = ruleLines(settled).some((one) => one === line);
532
+ if (already) {
533
+ if (settled === rules.shared)
534
+ return rules;
535
+ const next = { ...rules, shared: settled };
536
+ await writeRules(root, next);
537
+ return next;
538
+ }
430
539
  const heading = keep ? exports.KEEP_HEADING : exports.IGNORE_HEADING;
431
- let body = rules.shared.replace(/\s*$/, "");
540
+ let body = settled.replace(/\s*$/, "");
432
541
  /*
433
542
  A keep inside an excluded folder needs the folder opened first.
434
543
 
@@ -484,6 +593,85 @@ async function moveRule(root, file, keep) {
484
593
  await writeRules(root, next);
485
594
  return next;
486
595
  }
596
+ /**
597
+ * Take the suggestions somebody accepted, and write them.
598
+ *
599
+ * The accepted patterns and the missing starter lines are written as two
600
+ * labelled blocks rather than one, because they answer different questions:
601
+ * one is "this folder holds a cache", the other is "the rules this folder
602
+ * was set up with have moved on". A person reading the file later should be
603
+ * able to tell which was which.
604
+ *
605
+ * Lifted out of the window's event handler for the same reason `moveRule` was:
606
+ * inside it nothing could test it, and the defect it had — leaving the keep it
607
+ * had just overruled in the file — was found by a person reading their own
608
+ * .gitignore rather than by anything here.
609
+ */
610
+ async function acceptSuggestions(root, patterns, starter) {
611
+ const rules = await readRules(root);
612
+ /*
613
+ A suggestion somebody accepts is their answer about that folder, and it
614
+ replaces any earlier one. A real project kept `# Always include` /
615
+ `!output/` directly above the `output/` the size prompt had just
616
+ written: right to a matcher, because the later line wins, and to
617
+ anybody reading the file a flat contradiction.
618
+
619
+ Starter lines are the other way round. Nobody chose those one by one,
620
+ so they never overrule a keep — `missingStarterRules` already leaves
621
+ out anything kept, and this skips it again in case the file changed
622
+ between the prompt being drawn and the button being pressed.
623
+ */
624
+ let settled = rules.shared;
625
+ for (const pattern of patterns)
626
+ settled = withoutOpposites(settled, pattern);
627
+ const keptNow = keptTargets(settled);
628
+ starter = starter.filter((line) => line.startsWith("!") || !keptNow.has(ruleTarget(line)));
629
+ /*
630
+ Added to, never stacked.
631
+
632
+ The first version of this appended a fresh block every time, so a
633
+ folder that grew a second cache later ended up with two identical
634
+ headings and the same rule written twice — the same fault the template
635
+ appender had already been fixed for. Lines that are already in the file
636
+ are skipped, and a heading that is already there is extended under
637
+ itself rather than repeated.
638
+ */
639
+ const has = new Set(settled
640
+ .split(/\r?\n/)
641
+ .map((line) => line.trim())
642
+ .filter(Boolean));
643
+ const add = (heading, lines) => {
644
+ const wanted = lines.filter((line) => !has.has(line.trim()));
645
+ if (!wanted.length)
646
+ return "";
647
+ for (const line of wanted)
648
+ has.add(line.trim());
649
+ return [heading, ...wanted].join("\n");
650
+ };
651
+ let shared = settled.replace(/\s*$/, "");
652
+ for (const [heading, lines] of [
653
+ [detect_js_1.SUGGESTED_HEADING, patterns],
654
+ [detect_js_1.STARTER_TOPUP_HEADING, starter],
655
+ ]) {
656
+ const block = add(heading, lines);
657
+ if (!block)
658
+ continue;
659
+ const at = shared.indexOf(heading);
660
+ if (at === -1) {
661
+ shared = shared ? `${shared}\n\n${block}` : block;
662
+ continue;
663
+ }
664
+ /* Extend the block that is already there, directly under its heading. */
665
+ const after = at + heading.length;
666
+ shared =
667
+ shared.slice(0, after) +
668
+ block.slice(heading.length) +
669
+ shared.slice(after);
670
+ }
671
+ const next = { ...rules, shared: `${shared}\n` };
672
+ await writeRules(root, next);
673
+ return next;
674
+ }
487
675
  /** The two headings a move writes under, so a person can find their own edits. */
488
676
  exports.KEEP_HEADING = "# Always include";
489
677
  exports.IGNORE_HEADING = "# Left out from the filter screen";
@@ -6,16 +6,21 @@
6
6
  * make the same decision for the same object.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.WORTHWHILE_COMPRESSION_RATIO = exports.SMALLEST_WORTH_COMPRESSING = void 0;
9
+ exports.GZIP_KEEP_RATE = exports.GZIP_REVIEW_EVERY = exports.SAMPLE_BYTES = exports.WORTHWHILE_COMPRESSION_RATIO = exports.SMALLEST_WORTH_COMPRESSING = void 0;
10
10
  exports.looksCompressed = looksCompressed;
11
11
  exports.shouldTryGzip = shouldTryGzip;
12
+ exports.worthSampling = worthSampling;
13
+ exports.sampleWindows = sampleWindows;
14
+ exports.sampleSaysDoNotBother = sampleSaysDoNotBother;
12
15
  exports.gzipIsWorthKeeping = gzipIsWorthKeeping;
16
+ exports.freshGzipVerdict = freshGzipVerdict;
17
+ exports.noteChunkOutcome = noteChunkOutcome;
13
18
  const ALREADY_COMPRESSED = new Set([
14
19
  ".7z", ".aac", ".avi", ".br", ".bz2", ".cbx", ".docx", ".flac", ".gif",
15
- ".gz", ".heic", ".jpeg", ".jpg", ".jxl", ".m4a", ".m4v", ".mkv", ".mov",
16
- ".mp3", ".mp4", ".odp", ".ods", ".odt", ".ogg", ".opus", ".pdf", ".png",
17
- ".pptx", ".rar", ".safetensors", ".webm", ".webp", ".xlsx", ".xz", ".zip",
18
- ".zst",
20
+ ".gguf", ".gz", ".heic", ".jpeg", ".jpg", ".jxl", ".m4a", ".m4v", ".mkv",
21
+ ".mov", ".mp3", ".mp4", ".odp", ".ods", ".odt", ".ogg", ".onnx", ".opus",
22
+ ".pdf", ".png", ".pptx", ".rar", ".safetensors", ".webm", ".webp", ".xlsx",
23
+ ".xz", ".zip", ".zst",
19
24
  ]);
20
25
  exports.SMALLEST_WORTH_COMPRESSING = 4 * 1024;
21
26
  exports.WORTHWHILE_COMPRESSION_RATIO = 0.95;
@@ -30,6 +35,118 @@ function shouldTryGzip(relativePath, sourceBytes, allowGzip) {
30
35
  sourceBytes >= exports.SMALLEST_WORTH_COMPRESSING &&
31
36
  !looksCompressed(relativePath));
32
37
  }
38
+ /**
39
+ * Above this, find out by trying a little before trying all of it.
40
+ *
41
+ * The extension list above is a guess, and the cost of a miss is unbounded.
42
+ * A project of thirteen `.gguf` model weights — 10.6 GB, an extension nothing
43
+ * here had heard of — was deflated at level 6, in full, to discover that it
44
+ * compressed by 3.3% and the result had to be thrown away. That was 87% of the
45
+ * time the save spent before it sent a single byte, and the save runs the whole
46
+ * pipeline twice, so it was paid twice. The project never finished saving.
47
+ *
48
+ * A quarter of a megabyte is eight times deflate's window, which is enough for
49
+ * the answer to mean something, and 6% of a maximum chunk, which is what makes
50
+ * a wrong guess in the list above cheap instead of ruinous.
51
+ */
52
+ exports.SAMPLE_BYTES = 256 * 1024;
53
+ /** Whether a piece is big enough that guessing wrong on it is worth avoiding. */
54
+ function worthSampling(sourceBytes) {
55
+ /*
56
+ Only where the sample is a real saving over the whole thing. Sampling a
57
+ 300 KB file costs most of what compressing it would and answers a question
58
+ that was about to be answered properly anyway.
59
+ */
60
+ return sourceBytes >= exports.SAMPLE_BYTES * 8;
61
+ }
62
+ /**
63
+ * Where to look, for a piece of this size.
64
+ *
65
+ * Two windows, not one, because a single window at the front judges the whole
66
+ * file by its header. A container with a text manifest and an incompressible
67
+ * payload would be compressed in full on the strength of its first block —
68
+ * which is the waste this exists to avoid — and one shaped the other way round
69
+ * would be abandoned despite compressing. The middle disagrees with the front
70
+ * in both cases, and either window arguing for compression is enough.
71
+ *
72
+ * Shared rather than chosen per caller so Desktop and the browser make the same
73
+ * decision about the same bytes, which is the point of this module.
74
+ */
75
+ function sampleWindows(sourceBytes) {
76
+ return [
77
+ { at: 0, size: exports.SAMPLE_BYTES },
78
+ { at: Math.floor(sourceBytes / 2), size: exports.SAMPLE_BYTES },
79
+ ];
80
+ }
81
+ /**
82
+ * Whether a window that compressed this little argues against the whole piece.
83
+ *
84
+ * Deliberately the same test {@link gzipIsWorthKeeping} applies at the end: the
85
+ * sample is an estimate of that answer, so estimating it against a softer
86
+ * threshold was the bug in the first attempt at this. A window that saved 3%
87
+ * would have passed a "did it compress at all" test, committed the file to a
88
+ * full deflate, and then had the result discarded for saving 3% — which is
89
+ * exactly the several minutes this was meant to stop spending.
90
+ *
91
+ * Getting it wrong costs a little space and never a wrong result: an object's
92
+ * identity is the digest of its original bytes, whatever travels.
93
+ */
94
+ function sampleSaysDoNotBother(sampleBytes, compressedSampleBytes) {
95
+ return !gzipIsWorthKeeping(sampleBytes, compressedSampleBytes);
96
+ }
33
97
  function gzipIsWorthKeeping(sourceBytes, compressedBytes) {
34
98
  return compressedBytes < sourceBytes * exports.WORTHWHILE_COMPRESSION_RATIO;
35
99
  }
100
+ /** How often a chunked file's compression is reviewed. */
101
+ exports.GZIP_REVIEW_EVERY = 16;
102
+ /** The fraction of reviewed chunks that must have kept gzip, as its divisor. */
103
+ exports.GZIP_KEEP_RATE = 4;
104
+ /** A file nothing is known about yet. */
105
+ function freshGzipVerdict() {
106
+ return { allowed: true, tried: 0, kept: 0, chunks: 0 };
107
+ }
108
+ /**
109
+ * Record how one chunk turned out, and decide whether to keep trying.
110
+ *
111
+ * A large file is not one object, it is hundreds: a 1 GB file at a 1 MB target
112
+ * is a thousand chunks, each compressed and judged on its own. So the guard
113
+ * that stops wasted work on a whole *file* cannot live in the piece — sampling
114
+ * inside a 1 MB chunk saves a fraction of a job that was already small.
115
+ *
116
+ * Thirteen `.gguf` model weights, 10.6 GB, went chunk by chunk through deflate
117
+ * at level 6. That was 87% of the time the save spent before it sent a byte,
118
+ * and a save runs the whole pipeline twice, so it was spent twice. The project
119
+ * never finished saving.
120
+ *
121
+ * Measured on one of those files, 24 of 208 chunks did clear the 5% threshold
122
+ * and keep their compression — so "give up once nothing has compressed" never
123
+ * fired, which was the first attempt at this. What those 24 chunks saved was
124
+ * 0.7% of the file. The question is a rate, not an existence.
125
+ *
126
+ * Each window is judged on itself, and the count starts again after every
127
+ * review. Judging the file cumulatively instead lets a compressible opening buy
128
+ * an arbitrarily long incompressible tail: a run of 256 chunks that compressed
129
+ * would carry the next thousand that did not, which is the cost this exists to
130
+ * bound. Starting again means the work wasted after a file changes character is
131
+ * never more than one window, whereever in the file that happens.
132
+ *
133
+ * Being wrong costs a little storage and never a wrong result: an object's
134
+ * identity is the digest of its original bytes, whatever those bytes travelled
135
+ * as, so chunks already sent compressed stay exactly as valid as before.
136
+ */
137
+ function noteChunkOutcome(verdict, keptGzip) {
138
+ if (!verdict.allowed)
139
+ return;
140
+ verdict.chunks += 1;
141
+ verdict.tried += 1;
142
+ if (keptGzip)
143
+ verdict.kept += 1;
144
+ if (verdict.tried < exports.GZIP_REVIEW_EVERY)
145
+ return;
146
+ if (verdict.kept * exports.GZIP_KEEP_RATE < verdict.tried) {
147
+ verdict.allowed = false;
148
+ return;
149
+ }
150
+ verdict.tried = 0;
151
+ verdict.kept = 0;
152
+ }
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ /**
3
+ * Number formatting in the design's phrasing, where both sides can reach it.
4
+ *
5
+ * Byte sizes started out in the renderer, because only the renderer showed
6
+ * them. Then the detector began writing a person-facing sentence of its own —
7
+ * "10.7 GB of 10.8 GB, and no source in it" — and a second formatter in the
8
+ * main process would have meant the same folder described two ways in two
9
+ * places. The renderer re-exports these, so nothing that already imports from
10
+ * `renderer/format` had to change.
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.formatBytes = formatBytes;
14
+ exports.formatCount = formatCount;
15
+ /** Short byte text: "812 MB", "3.6 GB", "24 GB". A fraction only when there is one. */
16
+ function formatBytes(value) {
17
+ let size = value;
18
+ for (const unit of ["B", "KB", "MB", "GB", "TB"]) {
19
+ if (size < 1024 || unit === "TB") {
20
+ if (unit === "B")
21
+ return `${Math.round(size)} B`;
22
+ const text = size >= 100 ? size.toFixed(0) : size.toFixed(1);
23
+ return `${text.endsWith(".0") ? text.slice(0, -2) : text} ${unit}`;
24
+ }
25
+ size /= 1024;
26
+ }
27
+ return `${value} B`;
28
+ }
29
+ function formatCount(value) {
30
+ return value.toLocaleString("en-US");
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
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",