@coderook/cli 0.30.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/cli/src/api.js +24 -1
- package/dist/cli/src/cli.js +25 -1
- package/dist/cli/src/config.js +23 -0
- package/dist/cli/src/git_remote.js +105 -16
- package/dist/cli/src/publish.js +5 -0
- package/dist/cli/src/runner.js +7 -0
- package/dist/cli/src/stored_zip.js +85 -0
- package/dist/cli/src/transfer_command.js +5 -1
- package/dist/desktop-app/src/main/detect.js +9 -2
- package/dist/desktop-app/src/main/download.js +58 -2
- package/dist/desktop-app/src/main/rules.js +48 -35
- package/dist/desktop-app/src/main/upload.js +41 -1
- package/dist/desktop-app/src/main/worktree.js +330 -206
- package/package.json +1 -1
|
@@ -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");
|
|
@@ -136,6 +145,20 @@ class Uploader {
|
|
|
136
145
|
/** Whether this save has already said that packing was refused. */
|
|
137
146
|
packRefusalSaid = false;
|
|
138
147
|
plannedDigests = new Map();
|
|
148
|
+
/**
|
|
149
|
+
* Every digest this save actually took, for a caller that keeps a record.
|
|
150
|
+
*
|
|
151
|
+
* The desktop keeps one and hands it back through `knownDigests`, which is
|
|
152
|
+
* what stops an unchanged file being read twice. The command line had no
|
|
153
|
+
* way to keep one because this was private, so it re-read and re-hashed
|
|
154
|
+
* whole projects on every save.
|
|
155
|
+
*
|
|
156
|
+
* Only files that were read are in here — an entry served from the caller's
|
|
157
|
+
* own record was never taken and does not need writing down again.
|
|
158
|
+
*/
|
|
159
|
+
digestsTaken() {
|
|
160
|
+
return new Map(this.plannedDigests);
|
|
161
|
+
}
|
|
139
162
|
planningCreatedRepositoryId = null;
|
|
140
163
|
/** Attached to every object write after the server admits the exact plan. */
|
|
141
164
|
activeQuoteId = null;
|
|
@@ -220,7 +243,24 @@ class Uploader {
|
|
|
220
243
|
...(init.contentType ? { "content-type": init.contentType } : {}),
|
|
221
244
|
},
|
|
222
245
|
body: init.body,
|
|
223
|
-
|
|
246
|
+
/*
|
|
247
|
+
The upload's own cancellation, and a deadline.
|
|
248
|
+
|
|
249
|
+
This carried only the cancel signal, so a request the service never
|
|
250
|
+
answered waited for ever: a real save of a 38,913-file project stopped
|
|
251
|
+
dead with one open connection, no bytes moving either way and no CPU,
|
|
252
|
+
and nothing anywhere said so. `worthRetrying` has always named "a
|
|
253
|
+
request that timed out" as the case worth repeating — there was simply
|
|
254
|
+
no way for one to time out, so the branch could not be reached.
|
|
255
|
+
|
|
256
|
+
Generous on purpose. The service legitimately spends tens of seconds
|
|
257
|
+
on a large batch, and a deadline tight enough to cut that short would
|
|
258
|
+
turn slow saves into failing ones.
|
|
259
|
+
*/
|
|
260
|
+
signal: AbortSignal.any([
|
|
261
|
+
this.controller.signal,
|
|
262
|
+
AbortSignal.timeout(REQUEST_DEADLINE_MS),
|
|
263
|
+
]),
|
|
224
264
|
});
|
|
225
265
|
const text = await response.text();
|
|
226
266
|
this.telemetry.request(route.split("?")[0] ?? route, init.body, new TextEncoder().encode(text).byteLength, performance.now() - started);
|
|
@@ -21,6 +21,8 @@ exports.surveyFiles = surveyFiles;
|
|
|
21
21
|
exports.totalSize = totalSize;
|
|
22
22
|
exports.fileDiff = fileDiff;
|
|
23
23
|
exports.projectTree = projectTree;
|
|
24
|
+
exports.treeFrom = treeFrom;
|
|
25
|
+
exports.walkProject = walkProject;
|
|
24
26
|
exports.evaluateRules = evaluateRules;
|
|
25
27
|
exports.detectPrivateDirectories = detectPrivateDirectories;
|
|
26
28
|
exports.detectPastedCredentials = detectPastedCredentials;
|
|
@@ -871,56 +873,85 @@ async function surveyFiles(root, rules,
|
|
|
871
873
|
onFound) {
|
|
872
874
|
const layers = await collectLayers(root, rules);
|
|
873
875
|
const found = [];
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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 {
|
|
894
897
|
continue;
|
|
895
898
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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
|
+
}
|
|
910
939
|
}
|
|
911
|
-
}
|
|
940
|
+
};
|
|
941
|
+
await Promise.all(Array.from({ length: Math.min(WALK_LANES, level.length) }, () => lane()));
|
|
942
|
+
level = next;
|
|
912
943
|
}
|
|
913
944
|
return found.sort((left, right) => left.path.localeCompare(right.path));
|
|
914
945
|
}
|
|
915
946
|
async function totalSize(root, files) {
|
|
916
947
|
let total = 0;
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
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;
|
|
924
955
|
}
|
|
925
956
|
return total;
|
|
926
957
|
}
|
|
@@ -1018,71 +1049,168 @@ async function fileDiff(root, file, ignoreWhitespace = false) {
|
|
|
1018
1049
|
* than by writing glob patterns. Sizes are rolled up, because the only way
|
|
1019
1050
|
* to decide whether to keep a folder is to know what it costs.
|
|
1020
1051
|
*/
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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));
|
|
1024
1084
|
const node = {
|
|
1025
1085
|
path: relative,
|
|
1026
|
-
name: relative
|
|
1086
|
+
name: relative.slice(cut + 1),
|
|
1027
1087
|
directory: true,
|
|
1028
1088
|
size: 0,
|
|
1029
1089
|
files: 0,
|
|
1030
1090
|
children: [],
|
|
1031
1091
|
};
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
if (seen > limit) {
|
|
1057
|
-
node.truncated = true;
|
|
1058
|
-
break;
|
|
1059
|
-
}
|
|
1060
|
-
let size = 0;
|
|
1061
|
-
try {
|
|
1062
|
-
size = (await (0, promises_1.stat)(full)).size;
|
|
1063
|
-
}
|
|
1064
|
-
catch {
|
|
1065
|
-
continue;
|
|
1066
|
-
}
|
|
1067
|
-
node.children.push({
|
|
1068
|
-
path: childPath,
|
|
1069
|
-
name: entry.name,
|
|
1070
|
-
directory: false,
|
|
1071
|
-
size,
|
|
1072
|
-
files: 1,
|
|
1073
|
-
});
|
|
1074
|
-
node.size += size;
|
|
1075
|
-
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));
|
|
1076
1116
|
}
|
|
1077
|
-
|
|
1117
|
+
}
|
|
1118
|
+
// Folders first, then files, each alphabetically — the familiar order.
|
|
1119
|
+
for (const node of folders.values()) {
|
|
1078
1120
|
node.children.sort((left, right) => left.directory === right.directory
|
|
1079
1121
|
? left.name.localeCompare(right.name)
|
|
1080
1122
|
: left.directory
|
|
1081
1123
|
? -1
|
|
1082
1124
|
: 1);
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
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("/");
|
|
1086
1214
|
}
|
|
1087
1215
|
/**
|
|
1088
1216
|
* Measure what the supplied rules would upload, skip and force-keep.
|
|
@@ -1092,7 +1220,6 @@ async function projectTree(root, limit = exports.EVALUATION_FILE_LIMIT) {
|
|
|
1092
1220
|
*/
|
|
1093
1221
|
async function evaluateRules(root, rules, options = {}) {
|
|
1094
1222
|
const layers = await collectLayers(root, rules);
|
|
1095
|
-
const limit = options.limit ?? exports.EVALUATION_FILE_LIMIT;
|
|
1096
1223
|
const impactBytes = new Map();
|
|
1097
1224
|
const impactFiles = new Map();
|
|
1098
1225
|
const excluded = [];
|
|
@@ -1102,89 +1229,65 @@ async function evaluateRules(root, rules, options = {}) {
|
|
|
1102
1229
|
let skippedBytes = 0;
|
|
1103
1230
|
let keptFiles = 0;
|
|
1104
1231
|
let keptBytes = 0;
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
if (entry.isDirectory()) {
|
|
1128
|
-
pending.push(full);
|
|
1129
|
-
continue;
|
|
1130
|
-
}
|
|
1131
|
-
if (!entry.isFile())
|
|
1132
|
-
continue;
|
|
1133
|
-
seen += 1;
|
|
1134
|
-
if (seen > limit) {
|
|
1135
|
-
truncated = true;
|
|
1136
|
-
pending.length = 0;
|
|
1137
|
-
break;
|
|
1138
|
-
}
|
|
1139
|
-
let size;
|
|
1140
|
-
try {
|
|
1141
|
-
size = (await (0, promises_1.stat)(full)).size;
|
|
1142
|
-
}
|
|
1143
|
-
catch {
|
|
1144
|
-
continue;
|
|
1145
|
-
}
|
|
1146
|
-
const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
|
|
1147
|
-
// The last rule to speak wins, across every .gitignore that governs
|
|
1148
|
-
// this path. A `!` rule brings the file back and is worth reporting,
|
|
1149
|
-
// because it is how "always include" is expressed.
|
|
1150
|
-
/*
|
|
1151
|
-
The directories above it count. Asking `decide` about the file alone
|
|
1152
|
-
is what made this screen report a Unity project's whole Library as
|
|
1153
|
-
going up: `/[Ll]ibrary/` speaks about the directory, so no rule
|
|
1154
|
-
matched the file and every one of them looked included — while the
|
|
1155
|
-
save, which has always walked the ancestors, left them all behind.
|
|
1156
|
-
*/
|
|
1157
|
-
const rule = (0, rules_js_1.decidingRule)(relative, false, layers);
|
|
1158
|
-
if (rule?.negated) {
|
|
1159
|
-
keptFiles += 1;
|
|
1160
|
-
keptBytes += size;
|
|
1161
|
-
uploadFiles += 1;
|
|
1162
|
-
uploadBytes += size;
|
|
1163
|
-
excluded.push({
|
|
1164
|
-
path: relative,
|
|
1165
|
-
bytes: size,
|
|
1166
|
-
rule: `!${rule.pattern}`,
|
|
1167
|
-
forceKept: true,
|
|
1168
|
-
});
|
|
1169
|
-
continue;
|
|
1170
|
-
}
|
|
1171
|
-
if (rule) {
|
|
1172
|
-
skippedFiles += 1;
|
|
1173
|
-
skippedBytes += size;
|
|
1174
|
-
impactBytes.set(rule.pattern, (impactBytes.get(rule.pattern) ?? 0) + size);
|
|
1175
|
-
impactFiles.set(rule.pattern, (impactFiles.get(rule.pattern) ?? 0) + 1);
|
|
1176
|
-
excluded.push({
|
|
1177
|
-
path: relative,
|
|
1178
|
-
bytes: size,
|
|
1179
|
-
rule: rule.pattern,
|
|
1180
|
-
forceKept: false,
|
|
1181
|
-
});
|
|
1182
|
-
continue;
|
|
1183
|
-
}
|
|
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;
|
|
1184
1254
|
uploadFiles += 1;
|
|
1185
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;
|
|
1186
1276
|
}
|
|
1277
|
+
uploadFiles += 1;
|
|
1278
|
+
uploadBytes += size;
|
|
1187
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
|
+
*/
|
|
1188
1291
|
excluded.sort((left, right) => right.bytes - left.bytes);
|
|
1189
1292
|
const impacts = [...impactBytes.entries()]
|
|
1190
1293
|
.map(([pattern, bytes]) => ({
|
|
@@ -1201,7 +1304,7 @@ async function evaluateRules(root, rules, options = {}) {
|
|
|
1201
1304
|
keptFiles,
|
|
1202
1305
|
keptBytes,
|
|
1203
1306
|
impacts,
|
|
1204
|
-
excluded
|
|
1307
|
+
excluded,
|
|
1205
1308
|
truncated,
|
|
1206
1309
|
};
|
|
1207
1310
|
}
|
|
@@ -1496,39 +1599,60 @@ function isCredentialByName(relativePath) {
|
|
|
1496
1599
|
}
|
|
1497
1600
|
async function detectSecrets(root) {
|
|
1498
1601
|
const found = [];
|
|
1499
|
-
const pending = [root];
|
|
1500
1602
|
let seen = 0;
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
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
|
+
}
|
|
1520
1652
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
copies of the same rule, which is how `.env.example` came to be
|
|
1525
|
-
refused by the command line long after the service had learned that
|
|
1526
|
-
templates are not credentials.
|
|
1527
|
-
*/
|
|
1528
|
-
const relative = node_path_1.default.relative(root, full).split(node_path_1.default.sep).join("/");
|
|
1529
|
-
if (isCredentialByName(relative))
|
|
1530
|
-
found.push(relative);
|
|
1531
|
-
}
|
|
1653
|
+
};
|
|
1654
|
+
await Promise.all(Array.from({ length: Math.min(WALK_LANES, level.length) }, () => lane()));
|
|
1655
|
+
level = next;
|
|
1532
1656
|
}
|
|
1533
1657
|
return found;
|
|
1534
1658
|
}
|