@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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/config.js +186 -22
- package/dist/desktop-app/src/main/compress.js +21 -0
- package/dist/desktop-app/src/main/detect.js +84 -15
- package/dist/desktop-app/src/main/upload.js +173 -83
- package/dist/desktop-app/src/main/worktree.js +193 -5
- package/dist/desktop-app/src/shared/compression_policy.js +122 -5
- package/dist/desktop-app/src/shared/format.js +31 -0
- package/package.json +1 -1
|
@@ -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.
|
|
5
|
+
"version": "0.31.1",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
208
|
+
text = await (0, promises_1.readFile)(file, "utf8");
|
|
142
209
|
}
|
|
143
|
-
catch {
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
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
|
-
|
|
340
|
+
const stored = pack(key, {
|
|
182
341
|
...link,
|
|
183
342
|
...(baseVersionId ? { baseVersionId, versionId: baseVersionId } : {}),
|
|
184
|
-
};
|
|
185
|
-
await writePrivate(
|
|
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
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
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(
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2092
|
+
Three steps, and each file is read for as long as it has to be.
|
|
2079
2093
|
|
|
2080
|
-
This used to
|
|
2081
|
-
|
|
2082
|
-
the
|
|
2083
|
-
|
|
2084
|
-
|
|
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
|
-
|
|
2087
|
-
|
|
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
|
|
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
|
|
2109
|
-
|
|
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
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
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
|
|
2137
|
-
const
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
const
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
428
|
-
if (already)
|
|
429
|
-
|
|
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 =
|
|
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",
|
|
16
|
-
".mp3", ".mp4", ".odp", ".ods", ".odt", ".ogg", ".
|
|
17
|
-
".
|
|
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
|
+
}
|