@coderook/cli 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
  "name": "coderook",
3
3
  "displayName": "CodeRook",
4
4
  "description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, with cbx or plain git, from Claude Code.",
5
- "version": "0.45.0",
5
+ "version": "0.46.0",
6
6
  "author": {
7
7
  "name": "ACCA Gaming Productions",
8
8
  "url": "https://coderook.com"
@@ -20,6 +20,7 @@ const faults_js_1 = require("./faults.js");
20
20
  const identify_js_1 = require("./identify.js");
21
21
  const worktree_js_1 = require("./worktree.js");
22
22
  const rules_js_1 = require("./rules.js");
23
+ const native_engine_js_1 = require("../shared/native_engine.js");
23
24
  const publish_name_js_1 = require("./publish_name.js");
24
25
  const compress_js_1 = require("./compress.js");
25
26
  const compression_policy_js_1 = require("../shared/compression_policy.js");
@@ -696,6 +697,33 @@ class Uploader {
696
697
  bytesPerSecond: 0,
697
698
  });
698
699
  };
700
+ /*
701
+ Hashed ahead by the native engine, on every core, when there is enough
702
+ to read to be worth a process. Each answer carries the size and time the
703
+ file had while it was read, and is used below only when a fresh stat
704
+ still says the same — so it never vouches for bytes that have moved.
705
+ Anything it does not answer is hashed here as before.
706
+ */
707
+ const ahead = new Map();
708
+ {
709
+ const unknown = sending.filter((file) => {
710
+ const remembered = this.plannedDigests.get(file.path) ?? request.knownDigests?.get(file.path);
711
+ return !(remembered && remembered.size === file.size);
712
+ });
713
+ const bytes = unknown.reduce((sum, file) => sum + file.size, 0);
714
+ if ((0, native_engine_js_1.worthTheEngine)(unknown.length, bytes)) {
715
+ const answered = await (0, profile_js_1.timed)("hash files", () => (0, native_engine_js_1.engineMeasure)(request.localPath, unknown.map((file) => ({ path: file.path, count: false })), (done, _total, file) => {
716
+ hashed = done;
717
+ tell(file);
718
+ }, this.controller.signal));
719
+ this.check();
720
+ hashed = 0;
721
+ for (const [file, one] of answered ?? []) {
722
+ if (one.sha256 && !one.error && typeof one.mtimeMs === "number")
723
+ ahead.set(file, one);
724
+ }
725
+ }
726
+ }
699
727
  const lane = async () => {
700
728
  for (;;) {
701
729
  const index = cursor;
@@ -757,6 +785,18 @@ class Uploader {
757
785
  continue;
758
786
  }
759
787
  let digest;
788
+ const read = ahead.get(file.path);
789
+ if (read && read.size === size && read.mtimeMs === mtimeMs) {
790
+ digest = read.sha256;
791
+ if (fromPlan && fromPlan.sha256 !== digest)
792
+ changedSincePlan.push(file.path);
793
+ this.plannedDigests.set(file.path, { size, mtimeMs, sha256: digest });
794
+ measured[index] = { path: file.path, full, size, sha256: digest, mediaType: mediaTypeOf(file.path) };
795
+ hashed += 1;
796
+ readDone += file.size;
797
+ tell(file.path, hashed === sending.length);
798
+ continue;
799
+ }
760
800
  try {
761
801
  digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full, (read) => {
762
802
  readingNow.set(index, read);
@@ -46,6 +46,7 @@ const detect_js_1 = require("./detect.js");
46
46
  const ignore_templates_js_1 = require("./ignore_templates.js");
47
47
  const rules_js_1 = require("./rules.js");
48
48
  const secret_patterns_js_1 = require("./secret_patterns.js");
49
+ const native_engine_js_1 = require("../shared/native_engine.js");
49
50
  const run = (0, node_util_1.promisify)(node_child_process_1.execFile);
50
51
  /* A code point, so nothing between here and disk can eat the escape. */
51
52
  const NEWLINE = String.fromCharCode(10);
@@ -799,6 +800,8 @@ async function branchName(root) {
799
800
  const LINE_COUNT_LIMIT = 4 * 1024 * 1024;
800
801
  /** Enough rows for any real project; past it the rail would be unusable. */
801
802
  const LISTED_FILE_LIMIT = 20_000;
803
+ /** How many files the native engine is handed at once while a scan reads ahead. */
804
+ const READ_AHEAD_FILES = 4_096;
802
805
  /**
803
806
  * The digest of a file, without holding it.
804
807
  *
@@ -927,10 +930,18 @@ async function changedFiles(root, rules, baseline = null, mode = "add-and-update
927
930
  */
928
931
  stats,
929
932
  /** Filled with how much of the folder the cap allowed through. */
930
- outcome) {
931
- const layers = await collectLayers(root, rules);
933
+ outcome,
934
+ /** False for a read-only report: the listing's sizes and times may stand in for a stat (see engineScan). */
935
+ exact = true) {
932
936
  /* What git already tracks goes up whatever a rule says; see gitTracked. */
933
937
  const tracked = await gitTracked(root);
938
+ /*
939
+ The native engine walks the folder itself when there is one, deciding every
940
+ rule as this file does and in the same order (engine/compat/rules-cross.mjs
941
+ compares the two), with one stat per file. Otherwise both passes run here.
942
+ */
943
+ const native = await (0, native_engine_js_1.engineScan)(root, rules, tracked, baseline?.size ?? stats?.size, exact);
944
+ const layers = native ? [] : await collectLayers(root, rules);
934
945
  const files = [];
935
946
  const present = new Set();
936
947
  const pending = [root];
@@ -951,7 +962,12 @@ outcome) {
951
962
  costs, so reading is what the cap should govern, and it can only be spent
952
963
  well once the whole folder is known.
953
964
  */
954
- for (let next = 0; next < pending.length; next++) {
965
+ for (const one of native ?? []) {
966
+ present.add(one.path);
967
+ outcome?.sizes.push({ path: one.path, size: one.size });
968
+ found.push({ full: node_path_1.default.join(root, ...one.path.split("/")), relative: one.path, size: one.size, mtimeMs: one.mtimeMs });
969
+ }
970
+ for (let next = 0; !native && next < pending.length; next++) {
955
971
  const directory = pending[next];
956
972
  let entries;
957
973
  try {
@@ -1024,15 +1040,58 @@ outcome) {
1024
1040
  the list is full rather than over a fixed slice — a folder where nothing
1025
1041
  has changed still has to be walked through to find that out.
1026
1042
  */
1027
- for (const one of spreadAcrossDirectories(found)) {
1043
+ const order = spreadAcrossDirectories(found);
1044
+ /*
1045
+ Read ahead by the native engine, a slice at a time in the same order, when
1046
+ there is enough to read to be worth a process. A slice rather than all of
1047
+ it, so the cap still stops the reading where it stops the list. Whatever
1048
+ the engine does not answer is measured here as before, with the same rules:
1049
+ an unchanged file is not read, nor a large one nothing could match.
1050
+ */
1051
+ const readAhead = new Map();
1052
+ const needsReading = (one) => {
1053
+ const cached = stats?.get(one.relative);
1054
+ if (cached && cached.size === one.size && cached.mtimeMs === one.mtimeMs)
1055
+ return false;
1056
+ return one.size <= LINE_COUNT_LIMIT || (baseline?.has(one.relative) ?? false);
1057
+ };
1058
+ let readAheadTo = 0;
1059
+ for (let at = 0; at < order.length; at += 1) {
1028
1060
  if (files.length >= limit)
1029
1061
  break;
1062
+ if (at >= readAheadTo) {
1063
+ const slice = [];
1064
+ let bytes = 0;
1065
+ let next = at;
1066
+ while (next < order.length && slice.length < READ_AHEAD_FILES) {
1067
+ const candidate = order[next];
1068
+ if (needsReading(candidate)) {
1069
+ slice.push(candidate);
1070
+ bytes += candidate.size;
1071
+ }
1072
+ next += 1;
1073
+ }
1074
+ readAheadTo = next;
1075
+ if ((0, native_engine_js_1.worthTheEngine)(slice.length, bytes)) {
1076
+ const answered = await (0, native_engine_js_1.engineMeasure)(root, slice.map((one) => ({ path: one.relative, count: one.size <= LINE_COUNT_LIMIT })));
1077
+ for (const one of slice) {
1078
+ const got = answered?.get(one.relative);
1079
+ /* Not as listed means it is being written; measured again below. */
1080
+ if (!got?.sha256 || got.error || got.size !== one.size || got.mtimeMs !== one.mtimeMs)
1081
+ continue;
1082
+ readAhead.set(one.relative, { lines: got.lines ?? 0, binary: got.binary ?? true, hash: got.sha256 });
1083
+ }
1084
+ }
1085
+ }
1086
+ const one = order[at];
1030
1087
  const { full, relative, size, mtimeMs } = one;
1031
- const measured = await measure(full, size, {
1032
- mtimeMs,
1033
- cached: stats?.get(relative),
1034
- recorded: baseline?.has(relative) ?? false,
1035
- });
1088
+ const measured = readAhead.get(relative) ??
1089
+ (await measure(full, size, {
1090
+ mtimeMs,
1091
+ cached: stats?.get(relative),
1092
+ recorded: baseline?.has(relative) ?? false,
1093
+ }));
1094
+ readAhead.delete(relative);
1036
1095
  /*
1037
1096
  Remembered whether or not it changed: the next scan wants to skip
1038
1097
  reading this file again, and that is just as true of one that was
@@ -1107,8 +1166,16 @@ async function surveyFiles(root, rules,
1107
1166
  difference between waiting and wondering whether it has hung.
1108
1167
  */
1109
1168
  onFound) {
1110
- const layers = await collectLayers(root, rules);
1111
1169
  const tracked = await gitTracked(root);
1170
+ /* The native engine's walk, when there is one: the same files, decided the same way. */
1171
+ const native = await (0, native_engine_js_1.engineScan)(root, rules, tracked);
1172
+ if (native) {
1173
+ onFound?.(native.length);
1174
+ return native
1175
+ .map((one) => ({ path: one.path, size: one.size }))
1176
+ .sort((left, right) => left.path.localeCompare(right.path));
1177
+ }
1178
+ const layers = await collectLayers(root, rules);
1112
1179
  const found = [];
1113
1180
  /*
1114
1181
  Every entry asks about each folder above it; those answers do not change
@@ -182,13 +182,16 @@ async function log(repository, options = {}) {
182
182
  }
183
183
  async function status(repository) {
184
184
  const { save: head, tree } = await headTree(repository);
185
- const reading = await (0, snapshot_js_1.readFolder)(repository, { store: false, previous: tree });
185
+ /* A report, not a save: the listing's sizes and times may stand in for a stat each. */
186
+ const reading = await (0, snapshot_js_1.readFolder)(repository, { store: false, previous: tree, exact: false });
186
187
  /*
187
188
  Kept, so the next status does not hash the same files again. The index
188
189
  is only a cache: an entry this writes without objects makes a save read
189
- that file once more, never trust something it has not stored.
190
+ that file once more, never trust something it has not stored. Written
191
+ only when it changed; rewriting it whole was a good part of a quiet status.
190
192
  */
191
- await repository.writeIndex(reading.index);
193
+ if (reading.indexChanged)
194
+ await repository.writeIndex(reading.index);
192
195
  const merging = await (0, merge_js_1.readMergeState)(repository);
193
196
  return {
194
197
  line: await repository.currentLine(),
@@ -24,6 +24,7 @@ const promises_1 = require("node:fs/promises");
24
24
  const node_path_1 = __importDefault(require("node:path"));
25
25
  const worktree_js_1 = require("../core/worktree.js");
26
26
  const chunking_js_1 = require("../shared/chunking.js");
27
+ const native_engine_js_1 = require("../shared/native_engine.js");
27
28
  const safe_path_js_1 = require("../shared/safe_path.js");
28
29
  const READ_SIZE = 8 * 1024 * 1024;
29
30
  /*
@@ -58,8 +59,15 @@ async function inLanes(items, count, work) {
58
59
  await Promise.all(Array.from({ length: Math.min(count, items.length) }, () => lane()));
59
60
  }
60
61
  /** The files the rules would save, with what `stat` says about each. */
61
- async function listFolder(root) {
62
+ async function listFolder(root, expectedFiles, exact = true) {
62
63
  const rules = await (0, worktree_js_1.readRules)(root);
64
+ /* The native engine walks and stats in one pass when there is one; otherwise the walk below and a stat each. */
65
+ const native = await (0, native_engine_js_1.engineScan)(root, rules, await (0, worktree_js_1.gitTracked)(root), expectedFiles, exact);
66
+ if (native) {
67
+ return native
68
+ .map((one) => ({ path: one.path, size: one.size, mtimeMs: one.mtimeMs, mode: one.mode }))
69
+ .sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
70
+ }
63
71
  const found = await (0, worktree_js_1.surveyFiles)(root, rules);
64
72
  const files = [];
65
73
  await inLanes(found, SMALL_LANES, async (one) => {
@@ -138,30 +146,61 @@ async function storeFile(repository, full, size) {
138
146
  async function readFolder(repository, options) {
139
147
  const index = await repository.readIndex();
140
148
  const previous = new Map(options.previous?.files.map((file) => [file.path, file]) ?? []);
141
- const listed = await listFolder(repository.root);
149
+ const listed = await listFolder(repository.root, index.size || undefined, options.store || options.exact !== false);
142
150
  const files = new Map();
143
151
  const next = new Map();
144
152
  const racyBefore = Date.now() - RACY_WINDOW_MS;
145
- let done = 0;
146
- const one = async (file) => {
147
- const full = node_path_1.default.join(repository.root, file.path);
153
+ const unchangedSince = (file) => {
148
154
  const known = index.get(file.path);
149
- let sha256;
150
- let chunks = [];
151
- const unchanged = known &&
155
+ return known &&
152
156
  known.size === file.size &&
153
157
  known.mtimeMs === file.mtimeMs &&
154
- (!options.store || known.chunks.length > 0 || file.size === 0);
155
- if (unchanged) {
156
- sha256 = known.sha256;
157
- chunks = known.chunks;
158
- }
159
- else if (options.store) {
160
- ({ sha256, chunks } = await storeFile(repository, full, file.size));
161
- }
162
- else {
163
- sha256 = await digestFile(full, file.size);
158
+ (!options.store || known.chunks.length > 0 || file.size === 0)
159
+ ? known
160
+ : null;
161
+ };
162
+ /*
163
+ The files that have to be read, handed to the native engine in one go when
164
+ there are enough of them to be worth a process. It reads, hashes, cuts and
165
+ compresses them on every core and gives the same names and chunks storeFile
166
+ would; anything it does not answer is read here as before.
167
+ */
168
+ const toRead = listed.filter((file) => !unchangedSince(file));
169
+ let native = null;
170
+ let done = listed.length - toRead.length;
171
+ const readBytes = toRead.reduce((sum, file) => sum + file.size, 0);
172
+ if ((0, native_engine_js_1.worthTheEngine)(toRead.length, readBytes)) {
173
+ const report = options.progress
174
+ ? (count, _total, file) => options.progress(done + count, listed.length, file)
175
+ : undefined;
176
+ const wanted = toRead.map((file) => ({ path: file.path, size: file.size }));
177
+ native = options.store
178
+ ? await (0, native_engine_js_1.engineStore)(repository.objects.directory, repository.root, wanted, report)
179
+ : await (0, native_engine_js_1.engineMeasure)(repository.root, wanted.map((file) => ({ path: file.path, count: false })), report);
180
+ /* The engine wrote packs this store has not read yet. */
181
+ if (native && options.store)
182
+ repository.objects.rescan();
183
+ if (native)
184
+ done += toRead.filter((file) => native.get(file.path)?.sha256).length;
185
+ }
186
+ /* What is already known about a file without reading it: from the index, or from the engine. */
187
+ const answerFor = (file) => {
188
+ const known = unchangedSince(file);
189
+ if (known)
190
+ return { sha256: known.sha256, chunks: known.chunks, settled: true, fromEngine: false };
191
+ const answered = native?.get(file.path);
192
+ if (answered && !answered.error && answered.sha256 && (!options.store || answered.chunks)) {
193
+ return {
194
+ sha256: answered.sha256,
195
+ chunks: options.store ? answered.chunks : [],
196
+ /* Whether what was read is what was listed; a file still being written is not trusted next time. */
197
+ settled: answered.size === file.size && (options.store || answered.mtimeMs === file.mtimeMs),
198
+ fromEngine: true,
199
+ };
164
200
  }
201
+ return null;
202
+ };
203
+ const record = (file, sha256, chunks, settled) => {
165
204
  files.set(file.path, {
166
205
  path: file.path,
167
206
  size: file.size,
@@ -169,22 +208,60 @@ async function readFolder(repository, options) {
169
208
  executable: executableFrom(file.mode, previous.get(file.path)),
170
209
  chunks,
171
210
  });
172
- if (file.mtimeMs < racyBefore) {
211
+ if (file.mtimeMs < racyBefore && settled) {
173
212
  next.set(file.path, { size: file.size, mtimeMs: file.mtimeMs, sha256, chunks });
174
213
  }
214
+ };
215
+ /*
216
+ Everything already answered is recorded straight away; only files that
217
+ still have to be read go through the lanes. A quiet status on a folder of
218
+ eight thousand files spent fifty milliseconds queueing files that needed
219
+ nothing done to them.
220
+ */
221
+ const toReadHere = [];
222
+ for (const file of listed) {
223
+ const answer = answerFor(file);
224
+ if (!answer) {
225
+ toReadHere.push(file);
226
+ continue;
227
+ }
228
+ record(file, answer.sha256, answer.chunks, answer.settled);
229
+ if (!answer.fromEngine) {
230
+ done += 1;
231
+ options.progress?.(done, listed.length, file.path);
232
+ }
233
+ }
234
+ const one = async (file) => {
235
+ const full = node_path_1.default.join(repository.root, file.path);
236
+ let sha256;
237
+ let chunks = [];
238
+ if (options.store) {
239
+ ({ sha256, chunks } = await storeFile(repository, full, file.size));
240
+ }
241
+ else {
242
+ sha256 = await digestFile(full, file.size);
243
+ }
244
+ record(file, sha256, chunks, true);
175
245
  done += 1;
176
246
  options.progress?.(done, listed.length, file.path);
177
247
  };
178
248
  const large = (file) => (0, chunking_js_1.profileForFileSize)(file.size) !== null;
179
249
  await Promise.all([
180
- inLanes(listed.filter((file) => !large(file)), SMALL_LANES, one),
181
- inLanes(listed.filter(large), LARGE_LANES, one),
250
+ inLanes(toReadHere.filter((file) => !large(file)), SMALL_LANES, one),
251
+ inLanes(toReadHere.filter(large), LARGE_LANES, one),
182
252
  ]);
183
253
  /* Nothing may name an object that is not on disk yet. */
184
254
  if (options.store)
185
255
  await repository.objects.flush();
186
256
  options.progress?.(listed.length, listed.length, "");
187
- return { files, index: next };
257
+ const sameEntry = (left, right) => Boolean(right) &&
258
+ left.size === right.size &&
259
+ left.mtimeMs === right.mtimeMs &&
260
+ left.sha256 === right.sha256 &&
261
+ left.chunks.length === right.chunks.length &&
262
+ left.chunks.every((chunk, at) => chunk === right.chunks[at]);
263
+ const indexChanged = next.size !== index.size || [...next].some(([file, entry]) => !sameEntry(entry, index.get(file)));
264
+ return { files, index: next, indexChanged };
188
265
  }
189
266
  /** A tree entry's bytes, put back together from the store and checked. */
190
267
  async function contentsOf(repository, entry) {
@@ -213,6 +213,15 @@ class ObjectStore {
213
213
  }
214
214
  return (0, cbx_js_1.openChunkFrame)(frame, digest);
215
215
  }
216
+ /**
217
+ * Read the pack indexes again on next use.
218
+ *
219
+ * For after something else has written packs into this store — the native
220
+ * engine does, during a save — since the list is read once and then kept.
221
+ */
222
+ rescan() {
223
+ this.packed = null;
224
+ }
216
225
  /** Close any packs held open for reading. Safe to call at any time. */
217
226
  async release() {
218
227
  const open = [...this.handles.values()];
@@ -231,6 +240,23 @@ class ObjectStore {
231
240
  return this.put(Buffer.from(JSON.stringify(value), "utf8"), { loose: true });
232
241
  }
233
242
  async getJson(digest) {
243
+ /*
244
+ Saves and trees are written on their own (putJson), so they are looked
245
+ for there first. Going through get() read every pack's index just to
246
+ find them, which grew with the history: the same object either way,
247
+ checked the same way.
248
+ */
249
+ if (!this.pending.has(digest) && !this.writing.has(digest)) {
250
+ let frame = null;
251
+ try {
252
+ frame = await (0, promises_1.readFile)(this.where(digest));
253
+ }
254
+ catch {
255
+ /* Not on its own; packed, perhaps. */
256
+ }
257
+ if (frame)
258
+ return JSON.parse((0, cbx_js_1.openChunkFrame)(frame, digest).toString("utf8"));
259
+ }
234
260
  return JSON.parse((await this.get(digest)).toString("utf8"));
235
261
  }
236
262
  /** Every object whose name starts with `prefix` (at least four characters). */