@coderook/cli 0.30.0 → 0.30.2
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/api.js +24 -1
- package/dist/cli/src/runner.js +7 -0
- package/dist/cli/src/transfer_command.js +5 -1
- package/dist/desktop-app/src/main/detect.js +33 -20
- package/dist/desktop-app/src/main/rules.js +75 -43
- package/dist/desktop-app/src/main/upload.js +27 -1
- package/dist/desktop-app/src/main/worktree.js +441 -209
- 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.30.
|
|
5
|
+
"version": "0.30.2",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/api.js
CHANGED
|
@@ -51,6 +51,21 @@ exports.collisionCheck = collisionCheck;
|
|
|
51
51
|
/** The small part of the API the command-line tool needs directly. */
|
|
52
52
|
const identify_js_1 = require("../../desktop-app/src/main/identify.js");
|
|
53
53
|
const config_js_1 = require("./config.js");
|
|
54
|
+
/**
|
|
55
|
+
* How long one request may go unanswered before it is treated as lost.
|
|
56
|
+
*
|
|
57
|
+
* Nothing here had a deadline, so a request the service never answered
|
|
58
|
+
* waited for ever. A real save of a 38,913-file project stopped dead on one
|
|
59
|
+
* open connection with no bytes moving in either direction and no CPU, and
|
|
60
|
+
* said nothing at all — there is no output to watch, because the client is
|
|
61
|
+
* inside `await fetch`.
|
|
62
|
+
*
|
|
63
|
+
* Three minutes is far longer than any of these calls takes and short enough
|
|
64
|
+
* that a save cannot sit silent all afternoon. A deadline that fires is an
|
|
65
|
+
* ordinary error, so it reaches whatever the caller does about errors rather
|
|
66
|
+
* than ending the run.
|
|
67
|
+
*/
|
|
68
|
+
const REQUEST_DEADLINE_MS = 180_000;
|
|
54
69
|
async function call(route, options = {}) {
|
|
55
70
|
const token = await (0, config_js_1.loadToken)();
|
|
56
71
|
if (!token) {
|
|
@@ -66,6 +81,7 @@ async function call(route, options = {}) {
|
|
|
66
81
|
...(options.body ? { "content-type": "application/json" } : {}),
|
|
67
82
|
},
|
|
68
83
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
84
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
69
85
|
});
|
|
70
86
|
const text = await response.text();
|
|
71
87
|
const body = text ? JSON.parse(text) : {};
|
|
@@ -99,6 +115,7 @@ async function send(route, method, bytes, contentType) {
|
|
|
99
115
|
"content-length": String(bytes.byteLength),
|
|
100
116
|
},
|
|
101
117
|
body: bytes,
|
|
118
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
102
119
|
});
|
|
103
120
|
const text = await response.text();
|
|
104
121
|
const body = text ? JSON.parse(text) : {};
|
|
@@ -174,7 +191,10 @@ async function projects() {
|
|
|
174
191
|
*/
|
|
175
192
|
async function findPublicProject(owner, slug) {
|
|
176
193
|
try {
|
|
177
|
-
const response = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/public/projects/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`, {
|
|
194
|
+
const response = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/public/projects/${encodeURIComponent(owner)}/${encodeURIComponent(slug)}`, {
|
|
195
|
+
headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
|
|
196
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
197
|
+
});
|
|
178
198
|
if (!response.ok)
|
|
179
199
|
return null;
|
|
180
200
|
const row = (await response.json());
|
|
@@ -240,6 +260,8 @@ async function findProject(reference) {
|
|
|
240
260
|
async function health() {
|
|
241
261
|
const response = await fetch(`${(0, config_js_1.apiOrigin)()}/health?t=${Date.now()}`, {
|
|
242
262
|
headers: { "cache-control": "no-cache" },
|
|
263
|
+
/* A health check that hangs is the one call where waiting is absurd. */
|
|
264
|
+
signal: AbortSignal.timeout(15_000),
|
|
243
265
|
});
|
|
244
266
|
return (await response.json());
|
|
245
267
|
}
|
|
@@ -350,6 +372,7 @@ async function fileTextAt(repositoryId, versionId, filePath, mostBytes = 2 * 102
|
|
|
350
372
|
...(0, identify_js_1.clientHeaders)(),
|
|
351
373
|
range: `bytes=0-${mostBytes - 1}`,
|
|
352
374
|
},
|
|
375
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
353
376
|
});
|
|
354
377
|
if (!response.ok)
|
|
355
378
|
return null;
|
package/dist/cli/src/runner.js
CHANGED
|
@@ -42,6 +42,11 @@ const progress_js_1 = require("./progress.js");
|
|
|
42
42
|
const HEARTBEAT_MS = 30_000;
|
|
43
43
|
/** How often log lines are sent up while a command is running. */
|
|
44
44
|
const FLUSH_MS = 2_000;
|
|
45
|
+
/**
|
|
46
|
+
* The same deadline the rest of the client uses, so nothing here can wait for
|
|
47
|
+
* ever on a request the service never answers.
|
|
48
|
+
*/
|
|
49
|
+
const REQUEST_DEADLINE_MS = 180_000;
|
|
45
50
|
async function call(route, options = {}) {
|
|
46
51
|
const token = await (0, config_js_1.loadToken)();
|
|
47
52
|
if (!token)
|
|
@@ -55,6 +60,7 @@ async function call(route, options = {}) {
|
|
|
55
60
|
...(options.body ? { "content-type": "application/json" } : {}),
|
|
56
61
|
},
|
|
57
62
|
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
63
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
58
64
|
});
|
|
59
65
|
const text = await response.text();
|
|
60
66
|
const body = text ? JSON.parse(text) : {};
|
|
@@ -221,6 +227,7 @@ async function uploadArtifacts(repositoryId, runId, workspace, patterns, say) {
|
|
|
221
227
|
const stored = await fetch(`${(0, config_js_1.apiOrigin)()}/v1/repositories/${repositoryId}` +
|
|
222
228
|
`/objects/${digest}?kind=chunk&role=chunk`, {
|
|
223
229
|
method: "PUT",
|
|
230
|
+
signal: AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
224
231
|
headers: {
|
|
225
232
|
authorization: `Bearer ${token}`,
|
|
226
233
|
"content-type": "application/octet-stream",
|
|
@@ -71,7 +71,11 @@ async function readJson(url, token, forge) {
|
|
|
71
71
|
headers.authorization =
|
|
72
72
|
forge.kind === "gitlab" ? `Bearer ${token}` : `token ${token}`;
|
|
73
73
|
}
|
|
74
|
-
|
|
74
|
+
/* A forge that stops answering must not hold the import open for ever. */
|
|
75
|
+
const response = await fetch(url, {
|
|
76
|
+
headers,
|
|
77
|
+
signal: AbortSignal.timeout(60_000),
|
|
78
|
+
});
|
|
75
79
|
if (!response.ok) {
|
|
76
80
|
throw new Error(`${response.status} from ${new URL(url).pathname}`);
|
|
77
81
|
}
|
|
@@ -60,24 +60,24 @@ const RULES = [
|
|
|
60
60
|
project where the question "what should I leave out?" has the largest
|
|
61
61
|
possible answer was the one the detector had nothing to say about.
|
|
62
62
|
*/
|
|
63
|
-
{ directory: "Library", reason: "Unity's import cache — rebuilt when the project next opens", recommended: true },
|
|
64
|
-
{ directory: "Temp", reason: "Unity's scratch folder for the running editor", recommended: true },
|
|
65
|
-
{ directory: "MemoryCaptures", reason: "Unity memory snapshots — large, and not the project", recommended: true },
|
|
66
|
-
{ directory: "Recordings", reason: "Unity recorder output", recommended: true },
|
|
67
|
-
{ directory: "UserSettings", reason: "Your own editor layout — not part of the project", recommended: true },
|
|
68
|
-
{ directory: "DerivedDataCache", reason: "Unreal's derived data cache — rebuilt on demand", recommended: true },
|
|
69
|
-
{ directory: "Intermediate", reason: "Unreal build intermediates — rebuilt when you build", recommended: true },
|
|
70
|
-
{ directory: "Binaries", reason: "Unreal compiled output — rebuilt when you build", recommended: true },
|
|
63
|
+
{ directory: "Library", atRoot: true, reason: "Unity's import cache — rebuilt when the project next opens", recommended: true },
|
|
64
|
+
{ directory: "Temp", atRoot: true, reason: "Unity's scratch folder for the running editor", recommended: true },
|
|
65
|
+
{ directory: "MemoryCaptures", atRoot: true, reason: "Unity memory snapshots — large, and not the project", recommended: true },
|
|
66
|
+
{ directory: "Recordings", atRoot: true, reason: "Unity recorder output", recommended: true },
|
|
67
|
+
{ directory: "UserSettings", atRoot: true, reason: "Your own editor layout — not part of the project", recommended: true },
|
|
68
|
+
{ directory: "DerivedDataCache", atRoot: true, reason: "Unreal's derived data cache — rebuilt on demand", recommended: true },
|
|
69
|
+
{ directory: "Intermediate", atRoot: true, reason: "Unreal build intermediates — rebuilt when you build", recommended: true },
|
|
70
|
+
{ directory: "Binaries", atRoot: true, reason: "Unreal compiled output — rebuilt when you build", recommended: true },
|
|
71
71
|
/*
|
|
72
72
|
Named like the work, and it is not: Unreal keeps logs, crash reports and
|
|
73
73
|
autosaves here. Offered rather than ticked, because a folder called
|
|
74
74
|
"Saved" is the one nobody should have excluded on our say-so.
|
|
75
75
|
*/
|
|
76
|
-
{ directory: "Saved", reason: "Unreal logs and autosaves — check before excluding", recommended: false },
|
|
77
|
-
{ directory: ".godot", reason: "Godot's import cache — rebuilt when the project next opens", recommended: true },
|
|
78
|
-
{ directory: ".import", reason: "Godot's import cache — rebuilt when the project next opens", recommended: true },
|
|
79
|
-
{ directory: "Logs", reason: "Editor logs", recommended: true },
|
|
80
|
-
{ directory: "obj", reason: "Compiler intermediates — rebuilt when you build", recommended: true },
|
|
76
|
+
{ directory: "Saved", atRoot: true, reason: "Unreal logs and autosaves — check before excluding", recommended: false },
|
|
77
|
+
{ directory: ".godot", atRoot: true, reason: "Godot's import cache — rebuilt when the project next opens", recommended: true },
|
|
78
|
+
{ directory: ".import", atRoot: true, reason: "Godot's import cache — rebuilt when the project next opens", recommended: true },
|
|
79
|
+
{ directory: "Logs", atRoot: true, reason: "Editor logs", recommended: true },
|
|
80
|
+
{ directory: "obj", atRoot: true, reason: "Compiler intermediates — rebuilt when you build", recommended: true },
|
|
81
81
|
{ directory: ".vs", reason: "Visual Studio's local cache", recommended: true },
|
|
82
82
|
/*
|
|
83
83
|
Offered, not ticked. JetBrains keeps run configurations in here that some
|
|
@@ -104,7 +104,7 @@ const RULES = [
|
|
|
104
104
|
{ directory: "dist", reason: "Usually build output — check before excluding", recommended: false },
|
|
105
105
|
{ directory: "build", reason: "Usually build output — check before excluding", recommended: false },
|
|
106
106
|
{ directory: "out", reason: "Usually build output — check before excluding", recommended: false },
|
|
107
|
-
{ directory: "bin", reason: "Usually build output — check before excluding", recommended: false },
|
|
107
|
+
{ directory: "bin", atRoot: true, reason: "Usually build output — check before excluding", recommended: false },
|
|
108
108
|
{ extension: ".safetensors", reason: "Model weights — large, and usually downloadable", recommended: false },
|
|
109
109
|
{ extension: ".ckpt", reason: "Model weights — large, and usually downloadable", recommended: false },
|
|
110
110
|
{ extension: ".pt", reason: "Model weights — large, and usually downloadable", recommended: false },
|
|
@@ -222,11 +222,17 @@ function suggestExclusions(files) {
|
|
|
222
222
|
gets written matching the folder that is there.
|
|
223
223
|
*/
|
|
224
224
|
const wanted = rule.directory.toLowerCase();
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
225
|
+
/*
|
|
226
|
+
At the root, where the rule is about the project's own output, and
|
|
227
|
+
written anchored so it stays there. Unanchored, this offered to
|
|
228
|
+
leave out `Assets/Props/obj` — a folder of 3D models — because a
|
|
229
|
+
C# build also writes to a folder called obj. The file it proposed
|
|
230
|
+
removing was the one the rules had just been fixed to protect.
|
|
231
|
+
*/
|
|
232
|
+
const within = rule.atRoot ? segments.slice(0, 1) : segments.slice(0, -1);
|
|
233
|
+
const found = within.find((segment) => segment.toLowerCase() === wanted);
|
|
228
234
|
if (found)
|
|
229
|
-
pattern = `${found}/`;
|
|
235
|
+
pattern = rule.atRoot ? `/${found}/` : `${found}/`;
|
|
230
236
|
}
|
|
231
237
|
if (!pattern && rule.file && name === rule.file) {
|
|
232
238
|
pattern = rule.file;
|
|
@@ -259,9 +265,16 @@ function suggestExclusions(files) {
|
|
|
259
265
|
Merged in here rather than offered through a second channel, so everything
|
|
260
266
|
that shows a suggestion keeps working unchanged. A folder already named by
|
|
261
267
|
a rule above is dropped: it has a better reason than "no source in it".
|
|
268
|
+
|
|
269
|
+
Matched on the folder, not on how the line is written. The rules emit an
|
|
270
|
+
anchored `/Library/` and this emits `Library/`, so comparing the patterns
|
|
271
|
+
as text found no overlap and a Unity project was offered its own import
|
|
272
|
+
cache twice over — once as "the editor rebuilds this" and once, below it,
|
|
273
|
+
as "37,000 files and no source in it".
|
|
262
274
|
*/
|
|
263
|
-
const
|
|
264
|
-
const
|
|
275
|
+
const asFolder = (pattern) => pattern.replace(/^\/+|\/+$/g, "").toLowerCase();
|
|
276
|
+
const already = new Set(named.map((one) => asFolder(one.pattern)));
|
|
277
|
+
const bulk = suggestBulkDirectories(files).filter((one) => !already.has(asFolder(one.pattern)));
|
|
265
278
|
return [...named, ...bulk].sort((left, right) => right.bytes - left.bytes);
|
|
266
279
|
}
|
|
267
280
|
/*
|
|
@@ -132,17 +132,36 @@ function matcher(pattern) {
|
|
|
132
132
|
}
|
|
133
133
|
return expression;
|
|
134
134
|
}
|
|
135
|
+
/** A directory pattern with or without its trailing slash is the same rule. */
|
|
136
|
+
function trailingSlashOff(pattern) {
|
|
137
|
+
let end = pattern.length;
|
|
138
|
+
while (end > 0 && pattern[end - 1] === "/")
|
|
139
|
+
end -= 1;
|
|
140
|
+
return pattern.slice(0, end);
|
|
141
|
+
}
|
|
135
142
|
function ruleMatches(relativePath, isDirectory, rule) {
|
|
136
143
|
const expression = matcher(rule.pattern);
|
|
137
144
|
if (rule.directoryOnly) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
145
|
+
/*
|
|
146
|
+
A rule written `dir/` speaks about directories and about nothing else.
|
|
147
|
+
Everything under an excluded directory goes too, but that is git
|
|
148
|
+
declining to look inside it rather than the pattern reaching down, and
|
|
149
|
+
the difference shows the moment a later rule re-includes the directory:
|
|
150
|
+
`build/` then `!build/` leaves `build/keep.txt` travelling, which is the
|
|
151
|
+
escape hatch people are told to use. It shows again in reverse, which is
|
|
152
|
+
how this was found — asking `!Library/PackageCache/a.package/` about a
|
|
153
|
+
file let the whole cache out of a folder only meant to be opened.
|
|
154
|
+
|
|
155
|
+
So the pattern answers only for directories, and a file's ancestors are
|
|
156
|
+
asked separately, outermost in, by `decidingRule`.
|
|
157
|
+
*/
|
|
158
|
+
if (!isDirectory)
|
|
159
|
+
return false;
|
|
160
|
+
const bare = trailingSlashOff(rule.pattern);
|
|
161
|
+
if (rule.anchored || bare.includes("/"))
|
|
162
|
+
return expression.test(relativePath);
|
|
163
|
+
const own = relativePath.split("/").pop() ?? relativePath;
|
|
164
|
+
return expression.test(own);
|
|
146
165
|
}
|
|
147
166
|
if (rule.anchored || rule.pattern.includes("/")) {
|
|
148
167
|
return expression.test(relativePath);
|
|
@@ -181,39 +200,7 @@ function decide(relativePath, isDirectory, layers) {
|
|
|
181
200
|
}
|
|
182
201
|
return found;
|
|
183
202
|
}
|
|
184
|
-
|
|
185
|
-
* Whether these layers exclude the path, negations included.
|
|
186
|
-
*
|
|
187
|
-
* An excluded directory takes everything under it, whatever a later negation
|
|
188
|
-
* says. This is git's rule — "it is not possible to re-include a file if a
|
|
189
|
-
* parent directory of that file is excluded" — and it is followed here for a
|
|
190
|
-
* plain reason: the file is called .gitignore, git reads it too, and a rule
|
|
191
|
-
* that means one thing to one reader and something else to the other is worse
|
|
192
|
-
* than either behaviour on its own.
|
|
193
|
-
*
|
|
194
|
-
* It was the other way once, deliberately, on the reasoning that somebody
|
|
195
|
-
* writing `!` meant it. What that produced was a project excluding a folder
|
|
196
|
-
* and publishing two files out of it anyway, because an unrelated
|
|
197
|
-
* `!.env.example` further down reached inside — git had ignored them, so
|
|
198
|
-
* nothing else in the toolchain gave any sign. Being more permissive than git
|
|
199
|
-
* is the dangerous direction to differ in: it uploads what somebody thought
|
|
200
|
-
* they had excluded.
|
|
201
|
-
*/
|
|
202
|
-
/**
|
|
203
|
-
* The rule that settles this path, counting the directories above it.
|
|
204
|
-
*
|
|
205
|
-
* A directory rule is written about the directory — `/[Ll]ibrary/` matches
|
|
206
|
-
* `Library`, not `Library/Artifacts/f1.dat` — so anything asking only about
|
|
207
|
-
* the file finds no rule and calls it included. The upload has always walked
|
|
208
|
-
* the ancestors; the filter screen asked about the file alone, and so showed
|
|
209
|
-
* a Unity project's whole import cache as going up when the save would have
|
|
210
|
-
* left every byte of it behind.
|
|
211
|
-
*
|
|
212
|
-
* Returned as the rule rather than a yes or no, because the screen names the
|
|
213
|
-
* line responsible — "Library/ — 400 files" is the useful form, and a boolean
|
|
214
|
-
* cannot say it.
|
|
215
|
-
*/
|
|
216
|
-
function decidingRule(relativePath, isDirectory, layers) {
|
|
203
|
+
function decidingRule(relativePath, isDirectory, layers, remembered) {
|
|
217
204
|
/*
|
|
218
205
|
Ancestors first, outermost in. The moment one is excluded the answer is
|
|
219
206
|
settled and nothing below it can appeal.
|
|
@@ -221,7 +208,14 @@ function decidingRule(relativePath, isDirectory, layers) {
|
|
|
221
208
|
const parts = relativePath.split("/");
|
|
222
209
|
for (let depth = 1; depth < parts.length; depth += 1) {
|
|
223
210
|
const ancestor = parts.slice(0, depth).join("/");
|
|
224
|
-
|
|
211
|
+
let above;
|
|
212
|
+
if (remembered && remembered.has(ancestor)) {
|
|
213
|
+
above = remembered.get(ancestor) ?? null;
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
above = decide(ancestor, true, layers);
|
|
217
|
+
remembered?.set(ancestor, above);
|
|
218
|
+
}
|
|
225
219
|
if (above && !above.negated)
|
|
226
220
|
return above;
|
|
227
221
|
}
|
|
@@ -231,6 +225,29 @@ function excludes(relativePath, isDirectory, layers) {
|
|
|
231
225
|
const rule = decidingRule(relativePath, isDirectory, layers);
|
|
232
226
|
return Boolean(rule && !rule.negated);
|
|
233
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* Whether an anchored pattern could name anything inside this directory.
|
|
230
|
+
*
|
|
231
|
+
* Walked a segment at a time against the directory's own segments. A `**`
|
|
232
|
+
* makes everything below it possible and settles the question; a segment that
|
|
233
|
+
* simply does not match settles it the other way. Where the pattern runs out
|
|
234
|
+
* first it is left as possible, because this only ever needs to be certain
|
|
235
|
+
* when it says no.
|
|
236
|
+
*/
|
|
237
|
+
function couldDescendTo(pattern, directory) {
|
|
238
|
+
const wanted = pattern.replace(/^\/+/, "").replace(/\/+$/, "").split("/");
|
|
239
|
+
const here = directory.split("/").filter(Boolean);
|
|
240
|
+
for (let at = 0; at < here.length; at += 1) {
|
|
241
|
+
const part = wanted[at];
|
|
242
|
+
if (part === undefined)
|
|
243
|
+
return true;
|
|
244
|
+
if (part === "**")
|
|
245
|
+
return true;
|
|
246
|
+
if (!matcher(part).test(here[at]))
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
234
251
|
/**
|
|
235
252
|
* Whether a negation could rescue something inside this directory.
|
|
236
253
|
*
|
|
@@ -250,7 +267,22 @@ function negationReachesInto(relativePath, layers) {
|
|
|
250
267
|
// A bare glob is matched against every segment, so it reaches anywhere.
|
|
251
268
|
if (!rule.anchored && !rule.pattern.includes("/"))
|
|
252
269
|
return true;
|
|
253
|
-
// A
|
|
270
|
+
// A ** used to end the question here, and that one line was the
|
|
271
|
+
// reason a scan read every directory in the project.
|
|
272
|
+
//
|
|
273
|
+
// The starter carries the negation that keeps Unity's meta files, and
|
|
274
|
+
// it is written with a ** in the middle, so `includes` said yes to
|
|
275
|
+
// it for every directory on disk. `Library/`, excluded and 45,000
|
|
276
|
+
// files deep, was descended into on every scan of every Unity
|
|
277
|
+
// project, because a rule about `Assets` might conceivably have
|
|
278
|
+
// reached in.
|
|
279
|
+
//
|
|
280
|
+
// It cannot: the pattern is anchored and its first segment is
|
|
281
|
+
// `[Aa]ssets`, which does not match `Library`. Comparing segment by
|
|
282
|
+
// segment answers that exactly, and stops only where a **
|
|
283
|
+
// genuinely makes the rest unknowable.
|
|
284
|
+
if (!couldDescendTo(`${prefix}${rule.pattern}`, relativePath))
|
|
285
|
+
continue;
|
|
254
286
|
if (rule.pattern.includes("**"))
|
|
255
287
|
return true;
|
|
256
288
|
const target = `${prefix}${rule.pattern.replace(/\/+$/, "")}`;
|
|
@@ -92,6 +92,15 @@ async function digestOf(full) {
|
|
|
92
92
|
hash.update(chunk);
|
|
93
93
|
return hash.digest("hex");
|
|
94
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* How long one request may go unanswered before it is treated as lost.
|
|
97
|
+
*
|
|
98
|
+
* Three minutes, which is far longer than any request this makes has taken
|
|
99
|
+
* and short enough that a save cannot sit silent all afternoon. What follows
|
|
100
|
+
* a deadline is a retry, not a failure, so the cost of it firing early on a
|
|
101
|
+
* genuinely slow call is one repeated request.
|
|
102
|
+
*/
|
|
103
|
+
const REQUEST_DEADLINE_MS = 180_000;
|
|
95
104
|
class UploadCancelled extends Error {
|
|
96
105
|
constructor() {
|
|
97
106
|
super("Upload cancelled");
|
|
@@ -220,7 +229,24 @@ class Uploader {
|
|
|
220
229
|
...(init.contentType ? { "content-type": init.contentType } : {}),
|
|
221
230
|
},
|
|
222
231
|
body: init.body,
|
|
223
|
-
|
|
232
|
+
/*
|
|
233
|
+
The upload's own cancellation, and a deadline.
|
|
234
|
+
|
|
235
|
+
This carried only the cancel signal, so a request the service never
|
|
236
|
+
answered waited for ever: a real save of a 38,913-file project stopped
|
|
237
|
+
dead with one open connection, no bytes moving either way and no CPU,
|
|
238
|
+
and nothing anywhere said so. `worthRetrying` has always named "a
|
|
239
|
+
request that timed out" as the case worth repeating — there was simply
|
|
240
|
+
no way for one to time out, so the branch could not be reached.
|
|
241
|
+
|
|
242
|
+
Generous on purpose. The service legitimately spends tens of seconds
|
|
243
|
+
on a large batch, and a deadline tight enough to cut that short would
|
|
244
|
+
turn slow saves into failing ones.
|
|
245
|
+
*/
|
|
246
|
+
signal: AbortSignal.any([
|
|
247
|
+
this.controller.signal,
|
|
248
|
+
AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
249
|
+
]),
|
|
224
250
|
});
|
|
225
251
|
const text = await response.text();
|
|
226
252
|
this.telemetry.request(route.split("?")[0] ?? route, init.body, new TextEncoder().encode(text).byteLength, performance.now() - started);
|
|
@@ -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.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;
|
|
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
7
|
exports.starterAuthored = starterAuthored;
|
|
8
8
|
exports.missingStarterRules = missingStarterRules;
|
|
9
9
|
exports.missingEngineRules = missingEngineRules;
|
|
10
10
|
exports.isRepository = isRepository;
|
|
11
11
|
exports.migrateRetiredRules = migrateRetiredRules;
|
|
12
12
|
exports.starterFor = starterFor;
|
|
13
|
+
exports.moveRule = moveRule;
|
|
13
14
|
exports.readRules = readRules;
|
|
14
15
|
exports.writeRules = writeRules;
|
|
15
16
|
exports.collectLayers = collectLayers;
|
|
@@ -20,6 +21,8 @@ exports.surveyFiles = surveyFiles;
|
|
|
20
21
|
exports.totalSize = totalSize;
|
|
21
22
|
exports.fileDiff = fileDiff;
|
|
22
23
|
exports.projectTree = projectTree;
|
|
24
|
+
exports.treeFrom = treeFrom;
|
|
25
|
+
exports.walkProject = walkProject;
|
|
23
26
|
exports.evaluateRules = evaluateRules;
|
|
24
27
|
exports.detectPrivateDirectories = detectPrivateDirectories;
|
|
25
28
|
exports.detectPastedCredentials = detectPastedCredentials;
|
|
@@ -38,6 +41,8 @@ const ignore_templates_js_1 = require("./ignore_templates.js");
|
|
|
38
41
|
const rules_js_1 = require("./rules.js");
|
|
39
42
|
const secret_patterns_js_1 = require("./secret_patterns.js");
|
|
40
43
|
const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
44
|
+
/* A code point, so nothing between here and disk can eat the escape. */
|
|
45
|
+
const NEWLINE = String.fromCharCode(10);
|
|
41
46
|
/** Past this the evaluation reports truncated rather than walking forever. */
|
|
42
47
|
exports.EVALUATION_FILE_LIMIT = 300_000;
|
|
43
48
|
async function git(root, ...args) {
|
|
@@ -359,9 +364,14 @@ const EXTRA_TEMPLATES = {
|
|
|
359
364
|
/.creator/`,
|
|
360
365
|
},
|
|
361
366
|
};
|
|
362
|
-
async function starterFor(root) {
|
|
367
|
+
async function starterFor(root, chosen) {
|
|
368
|
+
/*
|
|
369
|
+
What somebody said it is beats what the folder looks like.
|
|
370
|
+
`none` is a real answer — "this is not any of those" — and gets the plain
|
|
371
|
+
starter rather than being read as "no answer given".
|
|
372
|
+
*/
|
|
363
373
|
const entries = await (0, promises_1.readdir)(root).catch(() => []);
|
|
364
|
-
const keys = (0, detect_js_1.templatesFor)(entries);
|
|
374
|
+
const keys = chosen ? (chosen === "none" ? [] : [chosen]) : (0, detect_js_1.templatesFor)(entries);
|
|
365
375
|
if (!keys.length)
|
|
366
376
|
return exports.STARTER_IGNORE;
|
|
367
377
|
const parts = [exports.STARTER_MARKER];
|
|
@@ -377,6 +387,106 @@ async function starterFor(root) {
|
|
|
377
387
|
parts.push("", exports.CODEROOK_TAIL_HEADING, exports.CODEROOK_TAIL);
|
|
378
388
|
return `${parts.join("\n")}\n`;
|
|
379
389
|
}
|
|
390
|
+
/**
|
|
391
|
+
* Move one path onto the ignore list, or onto the keep list.
|
|
392
|
+
*
|
|
393
|
+
* Both are the same act — writing a line into the project's own rules — which
|
|
394
|
+
* is why they are one function. Keeping means a `!` rule, and a `!` rule only
|
|
395
|
+
* means anything if it comes after whatever would have taken the file, so the
|
|
396
|
+
* line goes at the end under its own heading.
|
|
397
|
+
*
|
|
398
|
+
* Lifted out of the window's event handler so it can be tested. It could not
|
|
399
|
+
* be before, which is how a folder came to be written as a rule that also
|
|
400
|
+
* matched a file of the same name.
|
|
401
|
+
*/
|
|
402
|
+
async function moveRule(root, file, keep) {
|
|
403
|
+
/*
|
|
404
|
+
The rules travel with the project. A .gitignore that excluded itself would
|
|
405
|
+
never reach the website, the command line, or another machine.
|
|
406
|
+
*/
|
|
407
|
+
if (file === exports.IGNORE_FILE) {
|
|
408
|
+
throw new Error("The ignore rules have to travel with the project");
|
|
409
|
+
}
|
|
410
|
+
const rules = await readRules(root);
|
|
411
|
+
/*
|
|
412
|
+
A folder is written as a folder rule.
|
|
413
|
+
|
|
414
|
+
The path arrives without a trailing slash, and writing it as-is makes a
|
|
415
|
+
rule that also matches a *file* of that name — not what anybody meant by
|
|
416
|
+
excluding a directory, and a disagreement with what the command line
|
|
417
|
+
prints for the same folder. Any slash already there is taken off first, so
|
|
418
|
+
a caller passing a rule rather than a path cannot produce `name//`.
|
|
419
|
+
*/
|
|
420
|
+
const bare = file.replace(/\/+$/, "");
|
|
421
|
+
const directory = await (0, promises_1.stat)(node_path_1.default.join(root, bare))
|
|
422
|
+
.then((entry) => entry.isDirectory())
|
|
423
|
+
.catch(() => file.endsWith("/"));
|
|
424
|
+
const target = directory ? `${bare}/` : bare;
|
|
425
|
+
const line = keep ? `!${target}` : target;
|
|
426
|
+
/* 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;
|
|
430
|
+
const heading = keep ? exports.KEEP_HEADING : exports.IGNORE_HEADING;
|
|
431
|
+
let body = rules.shared.replace(/\s*$/, "");
|
|
432
|
+
/*
|
|
433
|
+
A keep inside an excluded folder needs the folder opened first.
|
|
434
|
+
|
|
435
|
+
Rules cannot re-include a file whose directory is excluded — nothing ever
|
|
436
|
+
looks inside a directory it has already decided to skip, so `!Library/x`
|
|
437
|
+
under `Library/` does precisely nothing. That is most of what this button
|
|
438
|
+
is for: rescuing one file out of a cache.
|
|
439
|
+
|
|
440
|
+
The way through is the documented one. `Library/` becomes `Library/**`,
|
|
441
|
+
which leaves the directory itself includable, and every directory on the
|
|
442
|
+
way down to the file is named back in. Written here rather than asked of
|
|
443
|
+
the person, because the alternative is a button that looks like it worked.
|
|
444
|
+
*/
|
|
445
|
+
if (keep) {
|
|
446
|
+
/*
|
|
447
|
+
Which rule is in the way is a question for the matcher, not for string
|
|
448
|
+
comparison. The line is `/[Ll]ibrary/` — a pattern — and no amount of
|
|
449
|
+
comparing it against `Library/PackageCache/...` will ever match, which
|
|
450
|
+
is exactly how the first version of this wrote a negation that did
|
|
451
|
+
nothing at all.
|
|
452
|
+
*/
|
|
453
|
+
const parts = target.split("/");
|
|
454
|
+
for (let depth = 1; depth < parts.length; depth += 1) {
|
|
455
|
+
const ancestor = parts.slice(0, depth).join("/");
|
|
456
|
+
const layers = [{ base: "", rules: (0, rules_js_1.parseRules)(body) }];
|
|
457
|
+
const blocking = (0, rules_js_1.decide)(ancestor, true, layers);
|
|
458
|
+
if (!blocking || blocking.negated || !blocking.directoryOnly)
|
|
459
|
+
continue;
|
|
460
|
+
/*
|
|
461
|
+
Replaced line by line rather than by pattern. The thing being found
|
|
462
|
+
is itself a pattern — `/[Ll]ibrary/` — so building a regular
|
|
463
|
+
expression out of it means escaping a string that is mostly regular
|
|
464
|
+
expression already, for no gain over comparing the text.
|
|
465
|
+
*/
|
|
466
|
+
const was = (blocking.anchored ? "/" : "") + blocking.pattern + "/";
|
|
467
|
+
const opened = (blocking.anchored ? "/" : "") + blocking.pattern + "/**";
|
|
468
|
+
body = body
|
|
469
|
+
.split(NEWLINE)
|
|
470
|
+
.map((one) => (one.trim() === was ? opened : one))
|
|
471
|
+
.join(NEWLINE);
|
|
472
|
+
/* Then name every directory between it and the file back in. */
|
|
473
|
+
for (let at = depth; at < parts.length - 1; at += 1) {
|
|
474
|
+
const open = "!" + parts.slice(0, at + 1).join("/") + "/";
|
|
475
|
+
if (!ruleLines(body).includes(open))
|
|
476
|
+
body += NEWLINE + open;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
const shared = body.includes(heading)
|
|
481
|
+
? body + NEWLINE + line + NEWLINE
|
|
482
|
+
: body + NEWLINE + NEWLINE + heading + NEWLINE + line + NEWLINE;
|
|
483
|
+
const next = { ...rules, shared };
|
|
484
|
+
await writeRules(root, next);
|
|
485
|
+
return next;
|
|
486
|
+
}
|
|
487
|
+
/** The two headings a move writes under, so a person can find their own edits. */
|
|
488
|
+
exports.KEEP_HEADING = "# Always include";
|
|
489
|
+
exports.IGNORE_HEADING = "# Left out from the filter screen";
|
|
380
490
|
async function readRules(root) {
|
|
381
491
|
const shared = await readIfPresent(node_path_1.default.join(root, exports.IGNORE_FILE));
|
|
382
492
|
const local = await readIfPresent(node_path_1.default.join(root, exports.LOCAL_EXCLUDE_FILE));
|
|
@@ -763,56 +873,85 @@ async function surveyFiles(root, rules,
|
|
|
763
873
|
onFound) {
|
|
764
874
|
const layers = await collectLayers(root, rules);
|
|
765
875
|
const found = [];
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
876
|
+
/*
|
|
877
|
+
A level at a time, eight in flight.
|
|
878
|
+
|
|
879
|
+
Asking the disk for one thing and waiting for the answer before asking
|
|
880
|
+
for the next is most of what this cost: the same walk with eight lanes
|
|
881
|
+
open is about four times quicker, and a ninth buys nothing. The order the
|
|
882
|
+
answers arrive in does not matter because the list is sorted at the end.
|
|
883
|
+
*/
|
|
884
|
+
let level = [root];
|
|
885
|
+
while (level.length) {
|
|
886
|
+
const next = [];
|
|
887
|
+
let at = 0;
|
|
888
|
+
const lane = async () => {
|
|
889
|
+
while (at < level.length) {
|
|
890
|
+
const directory = level[at];
|
|
891
|
+
at += 1;
|
|
892
|
+
let entries;
|
|
893
|
+
try {
|
|
894
|
+
entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
786
897
|
continue;
|
|
787
898
|
}
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
899
|
+
const here = [];
|
|
900
|
+
for (const entry of entries) {
|
|
901
|
+
const full = node_path_1.default.join(directory, entry.name);
|
|
902
|
+
if (entry.isSymbolicLink())
|
|
903
|
+
continue;
|
|
904
|
+
if ((0, rules_js_1.isUncounted)(entry.name))
|
|
905
|
+
continue;
|
|
906
|
+
const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
|
|
907
|
+
if (entry.isDirectory()) {
|
|
908
|
+
if ((0, rules_js_1.excludes)(relative, true, layers) &&
|
|
909
|
+
!(0, rules_js_1.negationReachesInto)(relative, layers)) {
|
|
910
|
+
continue;
|
|
911
|
+
}
|
|
912
|
+
next.push(full);
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
if (!entry.isFile())
|
|
916
|
+
continue;
|
|
917
|
+
if ((0, rules_js_1.excludes)(relative, false, layers))
|
|
918
|
+
continue;
|
|
919
|
+
here.push({ full, relative });
|
|
920
|
+
}
|
|
921
|
+
for (let from = 0; from < here.length; from += WALK_LANES) {
|
|
922
|
+
const sized = await Promise.all(here.slice(from, from + WALK_LANES).map(async (one) => {
|
|
923
|
+
try {
|
|
924
|
+
return { path: one.relative, size: (await (0, promises_1.stat)(one.full)).size };
|
|
925
|
+
}
|
|
926
|
+
catch {
|
|
927
|
+
/* Gone or unreadable since the walk began; the upload says so. */
|
|
928
|
+
return null;
|
|
929
|
+
}
|
|
930
|
+
}));
|
|
931
|
+
for (const one of sized) {
|
|
932
|
+
if (!one)
|
|
933
|
+
continue;
|
|
934
|
+
found.push(one);
|
|
935
|
+
if (onFound && found.length % 500 === 0)
|
|
936
|
+
onFound(found.length);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
802
939
|
}
|
|
803
|
-
}
|
|
940
|
+
};
|
|
941
|
+
await Promise.all(Array.from({ length: Math.min(WALK_LANES, level.length) }, () => lane()));
|
|
942
|
+
level = next;
|
|
804
943
|
}
|
|
805
944
|
return found.sort((left, right) => left.path.localeCompare(right.path));
|
|
806
945
|
}
|
|
807
946
|
async function totalSize(root, files) {
|
|
808
947
|
let total = 0;
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
948
|
+
/* Same reason as the walk: these are all waiting, so let them wait together. */
|
|
949
|
+
for (let from = 0; from < files.length; from += WALK_LANES) {
|
|
950
|
+
const sizes = await Promise.all(files.slice(from, from + WALK_LANES).map((file) => (0, promises_1.stat)(node_path_1.default.join(root, file.path)).then((info) => info.size,
|
|
951
|
+
/* deleted since the scan; it contributes nothing */
|
|
952
|
+
() => 0)));
|
|
953
|
+
for (const size of sizes)
|
|
954
|
+
total += size;
|
|
816
955
|
}
|
|
817
956
|
return total;
|
|
818
957
|
}
|
|
@@ -910,71 +1049,168 @@ async function fileDiff(root, file, ignoreWhitespace = false) {
|
|
|
910
1049
|
* than by writing glob patterns. Sizes are rolled up, because the only way
|
|
911
1050
|
* to decide whether to keep a folder is to know what it costs.
|
|
912
1051
|
*/
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
1052
|
+
/**
|
|
1053
|
+
* The folder as a tree, built from one walk rather than another descent.
|
|
1054
|
+
*
|
|
1055
|
+
* It used to recurse the disk itself, in step with an evaluation doing the
|
|
1056
|
+
* same descent alongside it — two serial passes over a folder that on a real
|
|
1057
|
+
* Unity project holds 47,387 files. Now both are worked out from a single
|
|
1058
|
+
* concurrent walk, so the filter screen reads the disk once.
|
|
1059
|
+
*/
|
|
1060
|
+
async function projectTree(root, limitOrWalked = exports.EVALUATION_FILE_LIMIT) {
|
|
1061
|
+
const walked = typeof limitOrWalked === "number"
|
|
1062
|
+
? await walkProject(root, { limit: limitOrWalked })
|
|
1063
|
+
: limitOrWalked;
|
|
1064
|
+
return treeFrom(walked);
|
|
1065
|
+
}
|
|
1066
|
+
/** Fold the walk into nodes, carrying sizes and counts up as it goes. */
|
|
1067
|
+
function treeFrom(walked) {
|
|
1068
|
+
const root = {
|
|
1069
|
+
path: "",
|
|
1070
|
+
name: ".",
|
|
1071
|
+
directory: true,
|
|
1072
|
+
size: 0,
|
|
1073
|
+
files: 0,
|
|
1074
|
+
children: [],
|
|
1075
|
+
...(walked.truncated ? { truncated: true } : {}),
|
|
1076
|
+
};
|
|
1077
|
+
const folders = new Map([["", root]]);
|
|
1078
|
+
const folderAt = (relative) => {
|
|
1079
|
+
const held = folders.get(relative);
|
|
1080
|
+
if (held)
|
|
1081
|
+
return held;
|
|
1082
|
+
const cut = relative.lastIndexOf("/");
|
|
1083
|
+
const parent = folderAt(cut < 0 ? "" : relative.slice(0, cut));
|
|
916
1084
|
const node = {
|
|
917
1085
|
path: relative,
|
|
918
|
-
name: relative
|
|
1086
|
+
name: relative.slice(cut + 1),
|
|
919
1087
|
directory: true,
|
|
920
1088
|
size: 0,
|
|
921
1089
|
files: 0,
|
|
922
1090
|
children: [],
|
|
923
1091
|
};
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
if (seen > limit) {
|
|
949
|
-
node.truncated = true;
|
|
950
|
-
break;
|
|
951
|
-
}
|
|
952
|
-
let size = 0;
|
|
953
|
-
try {
|
|
954
|
-
size = (await (0, promises_1.stat)(full)).size;
|
|
955
|
-
}
|
|
956
|
-
catch {
|
|
957
|
-
continue;
|
|
958
|
-
}
|
|
959
|
-
node.children.push({
|
|
960
|
-
path: childPath,
|
|
961
|
-
name: entry.name,
|
|
962
|
-
directory: false,
|
|
963
|
-
size,
|
|
964
|
-
files: 1,
|
|
965
|
-
});
|
|
966
|
-
node.size += size;
|
|
967
|
-
node.files += 1;
|
|
1092
|
+
parent.children.push(node);
|
|
1093
|
+
folders.set(relative, node);
|
|
1094
|
+
return node;
|
|
1095
|
+
};
|
|
1096
|
+
/* Empty folders are folders, and a tree that hides them is a different
|
|
1097
|
+
folder from the one on disk. */
|
|
1098
|
+
for (const directory of walked.directories)
|
|
1099
|
+
folderAt(directory);
|
|
1100
|
+
for (const file of walked.files) {
|
|
1101
|
+
const cut = file.path.lastIndexOf("/");
|
|
1102
|
+
const parent = folderAt(cut < 0 ? "" : file.path.slice(0, cut));
|
|
1103
|
+
parent.children.push({
|
|
1104
|
+
path: file.path,
|
|
1105
|
+
name: file.path.slice(cut + 1),
|
|
1106
|
+
directory: false,
|
|
1107
|
+
size: file.size,
|
|
1108
|
+
files: 1,
|
|
1109
|
+
});
|
|
1110
|
+
/* Up the chain, so a folder can be judged without opening it. */
|
|
1111
|
+
for (let at = parent; at;) {
|
|
1112
|
+
at.size += file.size;
|
|
1113
|
+
at.files += 1;
|
|
1114
|
+
const cutAt = at.path.lastIndexOf("/");
|
|
1115
|
+
at = at.path === "" ? undefined : folders.get(cutAt < 0 ? "" : at.path.slice(0, cutAt));
|
|
968
1116
|
}
|
|
969
|
-
|
|
1117
|
+
}
|
|
1118
|
+
// Folders first, then files, each alphabetically — the familiar order.
|
|
1119
|
+
for (const node of folders.values()) {
|
|
970
1120
|
node.children.sort((left, right) => left.directory === right.directory
|
|
971
1121
|
? left.name.localeCompare(right.name)
|
|
972
1122
|
: left.directory
|
|
973
1123
|
? -1
|
|
974
1124
|
: 1);
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1125
|
+
}
|
|
1126
|
+
return root;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Every file under a folder, with its size, walked eight at a time.
|
|
1130
|
+
*
|
|
1131
|
+
* The filter screen used to walk the same folder twice over, once to build
|
|
1132
|
+
* the tree and once to apply the rules, and both walks asked the disk for one
|
|
1133
|
+
* thing at a time. On a 47,387-file Unity project that is 47,387 serial stat
|
|
1134
|
+
* calls, and a stat is almost entirely waiting: the same walk with eight in
|
|
1135
|
+
* flight is 2,483ms down to 638ms, and more lanes than eight buy nothing.
|
|
1136
|
+
*
|
|
1137
|
+
* So there is one walk, it is concurrent, and the tree and the evaluation are
|
|
1138
|
+
* both worked out from what it returns rather than from the disk.
|
|
1139
|
+
*/
|
|
1140
|
+
const WALK_LANES = 8;
|
|
1141
|
+
async function walkProject(root, options = {}) {
|
|
1142
|
+
const limit = options.limit ?? exports.EVALUATION_FILE_LIMIT;
|
|
1143
|
+
const files = [];
|
|
1144
|
+
const directories = [];
|
|
1145
|
+
let truncated = false;
|
|
1146
|
+
let stopped = false;
|
|
1147
|
+
/* Breadth-first, a level at a time, so the lanes always have work. */
|
|
1148
|
+
let level = [root];
|
|
1149
|
+
while (level.length && !stopped) {
|
|
1150
|
+
const next = [];
|
|
1151
|
+
let at = 0;
|
|
1152
|
+
const lane = async () => {
|
|
1153
|
+
while (at < level.length) {
|
|
1154
|
+
if (stopped)
|
|
1155
|
+
return;
|
|
1156
|
+
const directory = level[at];
|
|
1157
|
+
at += 1;
|
|
1158
|
+
if (options.shouldStop?.()) {
|
|
1159
|
+
stopped = true;
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
let entries;
|
|
1163
|
+
try {
|
|
1164
|
+
entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
|
|
1165
|
+
}
|
|
1166
|
+
catch {
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
const here = [];
|
|
1170
|
+
for (const entry of entries) {
|
|
1171
|
+
if (entry.isSymbolicLink())
|
|
1172
|
+
continue;
|
|
1173
|
+
if ((0, rules_js_1.isUncounted)(entry.name))
|
|
1174
|
+
continue;
|
|
1175
|
+
const full = node_path_1.default.join(directory, entry.name);
|
|
1176
|
+
if (entry.isDirectory()) {
|
|
1177
|
+
next.push(full);
|
|
1178
|
+
directories.push(relativeTo(root, full));
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
if (entry.isFile())
|
|
1182
|
+
here.push(full);
|
|
1183
|
+
}
|
|
1184
|
+
for (let from = 0; from < here.length; from += WALK_LANES) {
|
|
1185
|
+
const sized = await Promise.all(here.slice(from, from + WALK_LANES).map(async (full) => {
|
|
1186
|
+
try {
|
|
1187
|
+
return { full, size: (await (0, promises_1.stat)(full)).size };
|
|
1188
|
+
}
|
|
1189
|
+
catch {
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
}));
|
|
1193
|
+
for (const one of sized) {
|
|
1194
|
+
if (!one)
|
|
1195
|
+
continue;
|
|
1196
|
+
if (files.length >= limit) {
|
|
1197
|
+
truncated = true;
|
|
1198
|
+
stopped = true;
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
files.push({ path: relativeTo(root, one.full), size: one.size });
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
};
|
|
1206
|
+
await Promise.all(Array.from({ length: Math.min(WALK_LANES, level.length) }, () => lane()));
|
|
1207
|
+
level = next;
|
|
1208
|
+
}
|
|
1209
|
+
return { files, directories, truncated };
|
|
1210
|
+
}
|
|
1211
|
+
/** A path under the root, written the way the rules are. */
|
|
1212
|
+
function relativeTo(root, full) {
|
|
1213
|
+
return node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
|
|
978
1214
|
}
|
|
979
1215
|
/**
|
|
980
1216
|
* Measure what the supplied rules would upload, skip and force-keep.
|
|
@@ -984,7 +1220,6 @@ async function projectTree(root, limit = exports.EVALUATION_FILE_LIMIT) {
|
|
|
984
1220
|
*/
|
|
985
1221
|
async function evaluateRules(root, rules, options = {}) {
|
|
986
1222
|
const layers = await collectLayers(root, rules);
|
|
987
|
-
const limit = options.limit ?? exports.EVALUATION_FILE_LIMIT;
|
|
988
1223
|
const impactBytes = new Map();
|
|
989
1224
|
const impactFiles = new Map();
|
|
990
1225
|
const excluded = [];
|
|
@@ -994,89 +1229,65 @@ async function evaluateRules(root, rules, options = {}) {
|
|
|
994
1229
|
let skippedBytes = 0;
|
|
995
1230
|
let keptFiles = 0;
|
|
996
1231
|
let keptBytes = 0;
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
if (entry.isDirectory()) {
|
|
1020
|
-
pending.push(full);
|
|
1021
|
-
continue;
|
|
1022
|
-
}
|
|
1023
|
-
if (!entry.isFile())
|
|
1024
|
-
continue;
|
|
1025
|
-
seen += 1;
|
|
1026
|
-
if (seen > limit) {
|
|
1027
|
-
truncated = true;
|
|
1028
|
-
pending.length = 0;
|
|
1029
|
-
break;
|
|
1030
|
-
}
|
|
1031
|
-
let size;
|
|
1032
|
-
try {
|
|
1033
|
-
size = (await (0, promises_1.stat)(full)).size;
|
|
1034
|
-
}
|
|
1035
|
-
catch {
|
|
1036
|
-
continue;
|
|
1037
|
-
}
|
|
1038
|
-
const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
|
|
1039
|
-
// The last rule to speak wins, across every .gitignore that governs
|
|
1040
|
-
// this path. A `!` rule brings the file back and is worth reporting,
|
|
1041
|
-
// because it is how "always include" is expressed.
|
|
1042
|
-
/*
|
|
1043
|
-
The directories above it count. Asking `decide` about the file alone
|
|
1044
|
-
is what made this screen report a Unity project's whole Library as
|
|
1045
|
-
going up: `/[Ll]ibrary/` speaks about the directory, so no rule
|
|
1046
|
-
matched the file and every one of them looked included — while the
|
|
1047
|
-
save, which has always walked the ancestors, left them all behind.
|
|
1048
|
-
*/
|
|
1049
|
-
const rule = (0, rules_js_1.decidingRule)(relative, false, layers);
|
|
1050
|
-
if (rule?.negated) {
|
|
1051
|
-
keptFiles += 1;
|
|
1052
|
-
keptBytes += size;
|
|
1053
|
-
uploadFiles += 1;
|
|
1054
|
-
uploadBytes += size;
|
|
1055
|
-
excluded.push({
|
|
1056
|
-
path: relative,
|
|
1057
|
-
bytes: size,
|
|
1058
|
-
rule: `!${rule.pattern}`,
|
|
1059
|
-
forceKept: true,
|
|
1060
|
-
});
|
|
1061
|
-
continue;
|
|
1062
|
-
}
|
|
1063
|
-
if (rule) {
|
|
1064
|
-
skippedFiles += 1;
|
|
1065
|
-
skippedBytes += size;
|
|
1066
|
-
impactBytes.set(rule.pattern, (impactBytes.get(rule.pattern) ?? 0) + size);
|
|
1067
|
-
impactFiles.set(rule.pattern, (impactFiles.get(rule.pattern) ?? 0) + 1);
|
|
1068
|
-
excluded.push({
|
|
1069
|
-
path: relative,
|
|
1070
|
-
bytes: size,
|
|
1071
|
-
rule: rule.pattern,
|
|
1072
|
-
forceKept: false,
|
|
1073
|
-
});
|
|
1074
|
-
continue;
|
|
1075
|
-
}
|
|
1232
|
+
const walked = options.walked ?? (await walkProject(root, options));
|
|
1233
|
+
if (options.shouldStop?.())
|
|
1234
|
+
return emptyEvaluation(true);
|
|
1235
|
+
const truncated = walked.truncated;
|
|
1236
|
+
/* Kept for this evaluation only, so no edit to the rules can outlive it. */
|
|
1237
|
+
const folderAnswers = new Map();
|
|
1238
|
+
for (const file of walked.files) {
|
|
1239
|
+
const { path: relative, size } = file;
|
|
1240
|
+
// The last rule to speak wins, across every .gitignore that governs
|
|
1241
|
+
// this path. A `!` rule brings the file back and is worth reporting,
|
|
1242
|
+
// because it is how "always include" is expressed.
|
|
1243
|
+
/*
|
|
1244
|
+
The directories above it count. Asking `decide` about the file alone
|
|
1245
|
+
is what made this screen report a Unity project's whole Library as
|
|
1246
|
+
going up: `/[Ll]ibrary/` speaks about the directory, so no rule
|
|
1247
|
+
matched the file and every one of them looked included — while the
|
|
1248
|
+
save, which has always walked the ancestors, left them all behind.
|
|
1249
|
+
*/
|
|
1250
|
+
const rule = (0, rules_js_1.decidingRule)(relative, false, layers, folderAnswers);
|
|
1251
|
+
if (rule?.negated) {
|
|
1252
|
+
keptFiles += 1;
|
|
1253
|
+
keptBytes += size;
|
|
1076
1254
|
uploadFiles += 1;
|
|
1077
1255
|
uploadBytes += size;
|
|
1256
|
+
excluded.push({
|
|
1257
|
+
path: relative,
|
|
1258
|
+
bytes: size,
|
|
1259
|
+
rule: `!${rule.pattern}`,
|
|
1260
|
+
forceKept: true,
|
|
1261
|
+
});
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
if (rule) {
|
|
1265
|
+
skippedFiles += 1;
|
|
1266
|
+
skippedBytes += size;
|
|
1267
|
+
impactBytes.set(rule.pattern, (impactBytes.get(rule.pattern) ?? 0) + size);
|
|
1268
|
+
impactFiles.set(rule.pattern, (impactFiles.get(rule.pattern) ?? 0) + 1);
|
|
1269
|
+
excluded.push({
|
|
1270
|
+
path: relative,
|
|
1271
|
+
bytes: size,
|
|
1272
|
+
rule: rule.pattern,
|
|
1273
|
+
forceKept: false,
|
|
1274
|
+
});
|
|
1275
|
+
continue;
|
|
1078
1276
|
}
|
|
1277
|
+
uploadFiles += 1;
|
|
1278
|
+
uploadBytes += size;
|
|
1079
1279
|
}
|
|
1280
|
+
/*
|
|
1281
|
+
Sorted, and all of it.
|
|
1282
|
+
|
|
1283
|
+
This used to hand back the four hundred largest, which was right for a
|
|
1284
|
+
flat list somebody scrolls and wrong for what reads it now: the screen
|
|
1285
|
+
folds these into folders, so a Unity project's Library — 45,739 files —
|
|
1286
|
+
was drawn from four hundred of them and labelled "395 files" beside a
|
|
1287
|
+
header that said 45,739. The grouping happens here, in this process, and
|
|
1288
|
+
only the folded tree crosses to the window, so keeping all of them costs
|
|
1289
|
+
an array nobody sends.
|
|
1290
|
+
*/
|
|
1080
1291
|
excluded.sort((left, right) => right.bytes - left.bytes);
|
|
1081
1292
|
const impacts = [...impactBytes.entries()]
|
|
1082
1293
|
.map(([pattern, bytes]) => ({
|
|
@@ -1093,7 +1304,7 @@ async function evaluateRules(root, rules, options = {}) {
|
|
|
1093
1304
|
keptFiles,
|
|
1094
1305
|
keptBytes,
|
|
1095
1306
|
impacts,
|
|
1096
|
-
excluded
|
|
1307
|
+
excluded,
|
|
1097
1308
|
truncated,
|
|
1098
1309
|
};
|
|
1099
1310
|
}
|
|
@@ -1388,39 +1599,60 @@ function isCredentialByName(relativePath) {
|
|
|
1388
1599
|
}
|
|
1389
1600
|
async function detectSecrets(root) {
|
|
1390
1601
|
const found = [];
|
|
1391
|
-
const pending = [root];
|
|
1392
1602
|
let seen = 0;
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1603
|
+
let enough = false;
|
|
1604
|
+
/*
|
|
1605
|
+
Every directory, eight at a time. This reads names and nothing else, so
|
|
1606
|
+
it is pure waiting, and it was waiting one directory at a time over all
|
|
1607
|
+
two and a half thousand of them on a real project.
|
|
1608
|
+
|
|
1609
|
+
It still looks everywhere the rules do not. A credential inside a folder
|
|
1610
|
+
that is currently ignored is one rule edit away from being sent, and this
|
|
1611
|
+
is the check that is supposed to notice before that happens.
|
|
1612
|
+
*/
|
|
1613
|
+
let level = [root];
|
|
1614
|
+
while (level.length && !enough) {
|
|
1615
|
+
const next = [];
|
|
1616
|
+
let at = 0;
|
|
1617
|
+
const lane = async () => {
|
|
1618
|
+
while (at < level.length && !enough) {
|
|
1619
|
+
const directory = level[at];
|
|
1620
|
+
at += 1;
|
|
1621
|
+
let entries;
|
|
1622
|
+
try {
|
|
1623
|
+
entries = await (0, promises_1.readdir)(directory, { withFileTypes: true });
|
|
1624
|
+
}
|
|
1625
|
+
catch {
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
for (const entry of entries) {
|
|
1629
|
+
if (found.length >= 50 || seen > 40_000) {
|
|
1630
|
+
enough = true;
|
|
1631
|
+
return;
|
|
1632
|
+
}
|
|
1633
|
+
const full = node_path_1.default.join(directory, entry.name);
|
|
1634
|
+
if ((0, rules_js_1.isUncounted)(entry.name))
|
|
1635
|
+
continue;
|
|
1636
|
+
if (entry.isDirectory()) {
|
|
1637
|
+
if (entry.name !== "node_modules")
|
|
1638
|
+
next.push(full);
|
|
1639
|
+
continue;
|
|
1640
|
+
}
|
|
1641
|
+
seen += 1;
|
|
1642
|
+
/*
|
|
1643
|
+
Asked of the one function rather than repeated here. These were
|
|
1644
|
+
two copies of the same rule, which is how `.env.example` came to
|
|
1645
|
+
be refused by the command line long after the service had learned
|
|
1646
|
+
that templates are not credentials.
|
|
1647
|
+
*/
|
|
1648
|
+
const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
|
|
1649
|
+
if (isCredentialByName(relative))
|
|
1650
|
+
found.push(relative);
|
|
1651
|
+
}
|
|
1412
1652
|
}
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
copies of the same rule, which is how `.env.example` came to be
|
|
1417
|
-
refused by the command line long after the service had learned that
|
|
1418
|
-
templates are not credentials.
|
|
1419
|
-
*/
|
|
1420
|
-
const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
|
|
1421
|
-
if (isCredentialByName(relative))
|
|
1422
|
-
found.push(relative);
|
|
1423
|
-
}
|
|
1653
|
+
};
|
|
1654
|
+
await Promise.all(Array.from({ length: Math.min(WALK_LANES, level.length) }, () => lane()));
|
|
1655
|
+
level = next;
|
|
1424
1656
|
}
|
|
1425
1657
|
return found;
|
|
1426
1658
|
}
|