@coderook/cli 0.31.13 → 0.32.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.
Files changed (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +43 -1
  3. package/dist/cbx/LICENSE +202 -0
  4. package/dist/cbx/NOTICE +8 -0
  5. package/dist/{desktop-app/src/main → cbx/src/core}/cbx.js +31 -0
  6. package/dist/{desktop-app/src/main → cbx/src/core}/download.js +62 -1
  7. package/dist/{desktop-app/src/main → cbx/src/core}/rules.js +2 -0
  8. package/dist/cbx/src/local/errors.js +23 -0
  9. package/dist/cbx/src/local/history.js +522 -0
  10. package/dist/cbx/src/local/merge.js +277 -0
  11. package/dist/cbx/src/local/merge_text.js +148 -0
  12. package/dist/cbx/src/local/remote.js +437 -0
  13. package/dist/cbx/src/local/repository.js +324 -0
  14. package/dist/cbx/src/local/snapshot.js +338 -0
  15. package/dist/cbx/src/local/store.js +258 -0
  16. package/dist/{desktop-app → cbx}/src/shared/safe_path.js +12 -0
  17. package/dist/cli/src/api.js +1 -1
  18. package/dist/cli/src/cli.js +164 -22
  19. package/dist/cli/src/diff_command.js +2 -2
  20. package/dist/cli/src/git_remote.js +6 -6
  21. package/dist/cli/src/licence_commands.js +3 -3
  22. package/dist/cli/src/local_commands.js +506 -0
  23. package/dist/cli/src/mcp.js +2 -2
  24. package/dist/cli/src/pages_command.js +2 -2
  25. package/dist/cli/src/registry.js +1 -0
  26. package/dist/cli/src/runner.js +2 -2
  27. package/dist/cli/src/track_commands.js +1 -1
  28. package/package.json +2 -2
  29. package/skills/coderook/SKILL.md +24 -0
  30. /package/dist/{desktop-app/src/main → cbx/src/core}/compress.js +0 -0
  31. /package/dist/{desktop-app/src/main → cbx/src/core}/credentials.js +0 -0
  32. /package/dist/{desktop-app/src/main → cbx/src/core}/detect.js +0 -0
  33. /package/dist/{desktop-app/src/main → cbx/src/core}/faults.js +0 -0
  34. /package/dist/{desktop-app/src/main → cbx/src/core}/identify.js +0 -0
  35. /package/dist/{desktop-app/src/main → cbx/src/core}/ignore_templates.js +0 -0
  36. /package/dist/{desktop-app/src/main → cbx/src/core}/licence_texts.js +0 -0
  37. /package/dist/{desktop-app/src/main → cbx/src/core}/licences.js +0 -0
  38. /package/dist/{desktop-app/src/main → cbx/src/core}/profile.js +0 -0
  39. /package/dist/{desktop-app/src/main → cbx/src/core}/publish_name.js +0 -0
  40. /package/dist/{desktop-app/src/main → cbx/src/core}/retry.js +0 -0
  41. /package/dist/{desktop-app/src/main → cbx/src/core}/secret_patterns.js +0 -0
  42. /package/dist/{desktop-app/src/main → cbx/src/core}/solid.js +0 -0
  43. /package/dist/{desktop-app/src/main → cbx/src/core}/staging.js +0 -0
  44. /package/dist/{desktop-app/src/main → cbx/src/core}/tracks.js +0 -0
  45. /package/dist/{desktop-app/src/main → cbx/src/core}/transfer_progress.js +0 -0
  46. /package/dist/{desktop-app/src/main → cbx/src/core}/upload.js +0 -0
  47. /package/dist/{desktop-app/src/main → cbx/src/core}/worktree.js +0 -0
  48. /package/dist/{desktop-app → cbx}/src/shared/chunking.js +0 -0
  49. /package/dist/{desktop-app → cbx}/src/shared/compression_policy.js +0 -0
  50. /package/dist/{desktop-app → cbx}/src/shared/delta.js +0 -0
  51. /package/dist/{desktop-app → cbx}/src/shared/format.js +0 -0
  52. /package/dist/{desktop-app → cbx}/src/shared/merge_diff.js +0 -0
  53. /package/dist/{desktop-app → cbx}/src/shared/publication.js +0 -0
  54. /package/dist/{desktop-app → cbx}/src/shared/telemetry.js +0 -0
  55. /package/dist/{desktop-app → cbx}/src/shared/types.js +0 -0
@@ -0,0 +1,324 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Repository = exports.DEFAULT_LINE = exports.FORMAT_LINE = exports.STORE_DIRECTORY = void 0;
7
+ exports.lineNameProblem = lineNameProblem;
8
+ /**
9
+ * A local history: the `.cbx/` folder at the top of a project.
10
+ *
11
+ * .cbx/format "cbx-local 1", so a later layout can tell an older one
12
+ * .cbx/objects/ the object store (see store.ts)
13
+ * .cbx/lines/<name> the id of the newest save on each line of work
14
+ * .cbx/state.json which line the folder is on
15
+ * .cbx/config.json the author name saves carry, and later the remotes
16
+ * .cbx/index.json what each file looked like when it was last read
17
+ * .cbx/lock present while something is writing
18
+ *
19
+ * A save is a JSON object naming a tree, its parents and who made it; its id
20
+ * is the digest of those bytes. A tree lists every file with its size, digest,
21
+ * whether it runs, and the objects its bytes are split into. Nothing in a save
22
+ * or a tree depends on the machine it was made on, so two people who make the
23
+ * same save from the same files get the same id.
24
+ */
25
+ const promises_1 = require("node:fs/promises");
26
+ const node_os_1 = __importDefault(require("node:os"));
27
+ const node_path_1 = __importDefault(require("node:path"));
28
+ const store_js_1 = require("./store.js");
29
+ exports.STORE_DIRECTORY = ".cbx";
30
+ exports.FORMAT_LINE = "cbx-local 1";
31
+ exports.DEFAULT_LINE = "main";
32
+ /**
33
+ * Line names are file names under `.cbx/lines`, so they are held to what is
34
+ * safe as one on every platform: letters, digits, `.`, `_` and `-`, not
35
+ * starting with a dot, and not something Windows reserves.
36
+ */
37
+ function lineNameProblem(name) {
38
+ if (!name)
39
+ return "a line needs a name";
40
+ if (name.length > 100)
41
+ return "that name is longer than 100 characters";
42
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)) {
43
+ return "use letters, digits, '.', '_' and '-', starting with a letter or digit";
44
+ }
45
+ if (name.endsWith(".") || name.includes(".."))
46
+ return "that name has a stray dot";
47
+ if (/^(con|prn|aux|nul|com\d|lpt\d)(\.|$)/i.test(name)) {
48
+ return "Windows reserves that name";
49
+ }
50
+ if (name.endsWith(".partial"))
51
+ return "names ending .partial are how cbx marks unfinished writes";
52
+ return null;
53
+ }
54
+ async function writeAtomically(target, contents) {
55
+ const partial = `${target}.partial`;
56
+ await (0, promises_1.writeFile)(partial, contents);
57
+ await (0, promises_1.rename)(partial, target);
58
+ }
59
+ async function exists(where) {
60
+ try {
61
+ await (0, promises_1.stat)(where);
62
+ return true;
63
+ }
64
+ catch {
65
+ return false;
66
+ }
67
+ }
68
+ class Repository {
69
+ root;
70
+ directory;
71
+ objects;
72
+ constructor(root) {
73
+ this.root = root;
74
+ this.directory = node_path_1.default.join(root, exports.STORE_DIRECTORY);
75
+ this.objects = new store_js_1.ObjectStore(node_path_1.default.join(this.directory, "objects"));
76
+ }
77
+ /** Start a history in `root`. Refuses a folder that already has one. */
78
+ static async init(root, line = exports.DEFAULT_LINE) {
79
+ const problem = lineNameProblem(line);
80
+ if (problem)
81
+ throw new Error(`Cannot name a line "${line}": ${problem}.`);
82
+ const full = node_path_1.default.resolve(root);
83
+ const directory = node_path_1.default.join(full, exports.STORE_DIRECTORY);
84
+ if (await exists(directory)) {
85
+ throw new Error(`${full} already has a history (${exports.STORE_DIRECTORY}/).`);
86
+ }
87
+ await (0, promises_1.mkdir)(node_path_1.default.join(directory, "objects"), { recursive: true });
88
+ await (0, promises_1.mkdir)(node_path_1.default.join(directory, "lines"), { recursive: true });
89
+ await (0, promises_1.writeFile)(node_path_1.default.join(directory, "format"), `${exports.FORMAT_LINE}\n`);
90
+ await (0, promises_1.writeFile)(node_path_1.default.join(directory, "state.json"), JSON.stringify({ line }));
91
+ await (0, promises_1.writeFile)(node_path_1.default.join(directory, "config.json"), "{}");
92
+ return new Repository(full);
93
+ }
94
+ /** The history `from` is inside, looking upward; null when there is none. */
95
+ static async find(from) {
96
+ let at = node_path_1.default.resolve(from);
97
+ for (;;) {
98
+ const directory = node_path_1.default.join(at, exports.STORE_DIRECTORY);
99
+ if (await exists(node_path_1.default.join(directory, "format"))) {
100
+ const format = (await (0, promises_1.readFile)(node_path_1.default.join(directory, "format"), "utf8")).trim();
101
+ if (format !== exports.FORMAT_LINE) {
102
+ throw new Error(`${directory} is in a format this cbx does not read (${format}). Update cbx.`);
103
+ }
104
+ return new Repository(at);
105
+ }
106
+ const parent = node_path_1.default.dirname(at);
107
+ if (parent === at)
108
+ return null;
109
+ at = parent;
110
+ }
111
+ }
112
+ static async open(from) {
113
+ const found = await Repository.find(from);
114
+ if (!found) {
115
+ throw new Error(`No history here. Run \`cbx init\` in the project folder to start one.`);
116
+ }
117
+ return found;
118
+ }
119
+ /* ---- lines and state ------------------------------------------------ */
120
+ async currentLine() {
121
+ const state = JSON.parse(await (0, promises_1.readFile)(node_path_1.default.join(this.directory, "state.json"), "utf8"));
122
+ return state.line;
123
+ }
124
+ async setCurrentLine(line) {
125
+ await writeAtomically(node_path_1.default.join(this.directory, "state.json"), JSON.stringify({ line }));
126
+ }
127
+ /** The newest save on `line`, or null for a line with nothing saved yet. */
128
+ async lineTip(line) {
129
+ if (lineNameProblem(line))
130
+ return null;
131
+ try {
132
+ const id = (await (0, promises_1.readFile)(node_path_1.default.join(this.directory, "lines", line), "utf8")).trim();
133
+ return store_js_1.DIGEST.test(id) ? id : null;
134
+ }
135
+ catch {
136
+ return null;
137
+ }
138
+ }
139
+ async setLineTip(line, id) {
140
+ const problem = lineNameProblem(line);
141
+ if (problem)
142
+ throw new Error(`Cannot name a line "${line}": ${problem}.`);
143
+ await writeAtomically(node_path_1.default.join(this.directory, "lines", line), `${id}\n`);
144
+ }
145
+ async lineExists(line) {
146
+ return !lineNameProblem(line) && (await exists(node_path_1.default.join(this.directory, "lines", line)));
147
+ }
148
+ async lines() {
149
+ const names = await (0, promises_1.readdir)(node_path_1.default.join(this.directory, "lines")).catch(() => []);
150
+ const found = [];
151
+ for (const name of names.sort()) {
152
+ const tip = await this.lineTip(name);
153
+ if (tip)
154
+ found.push({ name, tip });
155
+ }
156
+ return found;
157
+ }
158
+ /** The save the folder was last saved or switched to, or null before the first. */
159
+ async head() {
160
+ return this.lineTip(await this.currentLine());
161
+ }
162
+ /* ---- config --------------------------------------------------------- */
163
+ async config() {
164
+ try {
165
+ return JSON.parse(await (0, promises_1.readFile)(node_path_1.default.join(this.directory, "config.json"), "utf8"));
166
+ }
167
+ catch {
168
+ return {};
169
+ }
170
+ }
171
+ async setConfig(change) {
172
+ const next = { ...(await this.config()), ...change };
173
+ await writeAtomically(node_path_1.default.join(this.directory, "config.json"), JSON.stringify(next, null, 2));
174
+ }
175
+ /**
176
+ * Who a save says made it: the history's own setting, then CBX_AUTHOR,
177
+ * then the account name on this machine. Only ever written into the save.
178
+ */
179
+ async author() {
180
+ const configured = (await this.config()).author?.trim();
181
+ if (configured)
182
+ return configured;
183
+ const fromEnvironment = process.env.CBX_AUTHOR?.trim();
184
+ if (fromEnvironment)
185
+ return fromEnvironment;
186
+ try {
187
+ return node_os_1.default.userInfo().username || "unknown";
188
+ }
189
+ catch {
190
+ return "unknown";
191
+ }
192
+ }
193
+ /* ---- saves and trees ------------------------------------------------ */
194
+ async readSave(id) {
195
+ const record = await this.objects.getJson(id);
196
+ if (record.format !== "cbx-save")
197
+ throw new Error(`${id.slice(0, 10)} is not a save.`);
198
+ return { ...record, id };
199
+ }
200
+ async readTree(id) {
201
+ const tree = await this.objects.getJson(id);
202
+ if (tree.format !== "cbx-tree")
203
+ throw new Error(`${id.slice(0, 10)} is not a tree.`);
204
+ return tree;
205
+ }
206
+ async writeSave(record) {
207
+ /* Keys in a fixed order, so the same save is the same bytes everywhere. */
208
+ const canonical = {
209
+ format: "cbx-save",
210
+ version: 1,
211
+ tree: record.tree,
212
+ parents: record.parents,
213
+ line: record.line,
214
+ message: record.message,
215
+ author: record.author,
216
+ created: record.created,
217
+ };
218
+ const id = await this.objects.putJson(canonical);
219
+ return { ...canonical, id };
220
+ }
221
+ async writeTree(files) {
222
+ const sorted = [...files].sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
223
+ const tree = {
224
+ format: "cbx-tree",
225
+ version: 1,
226
+ files: sorted.map((file) => ({
227
+ path: file.path,
228
+ size: file.size,
229
+ sha256: file.sha256,
230
+ executable: file.executable,
231
+ chunks: file.chunks,
232
+ })),
233
+ };
234
+ return this.objects.putJson(tree);
235
+ }
236
+ /**
237
+ * The save a name refers to.
238
+ *
239
+ * A line name, a line name followed by `~N` for N saves back along first
240
+ * parents, or at least four characters of a save id. Ambiguous or unknown
241
+ * names are refused with the reason.
242
+ */
243
+ async resolve(name) {
244
+ const back = /^(.*?)~(\d*)$/.exec(name);
245
+ const base = back ? back[1] : name;
246
+ const steps = back ? Number(back[2] || "1") : 0;
247
+ let id = null;
248
+ if (base === "" || base === "HEAD") {
249
+ id = await this.head();
250
+ if (!id)
251
+ throw new Error("Nothing has been saved on this line yet.");
252
+ }
253
+ else if (await this.lineExists(base)) {
254
+ id = await this.lineTip(base);
255
+ }
256
+ else if (/^[0-9a-f]{4,64}$/.test(base)) {
257
+ const candidates = [];
258
+ for (const candidate of await this.objects.matching(base)) {
259
+ try {
260
+ await this.readSave(candidate);
261
+ candidates.push(candidate);
262
+ }
263
+ catch {
264
+ /* A tree or a piece of a file, not a save. */
265
+ }
266
+ }
267
+ if (candidates.length > 1) {
268
+ throw new Error(`"${base}" could be any of ${candidates.length} saves; give more of it.`);
269
+ }
270
+ id = candidates[0] ?? null;
271
+ }
272
+ if (!id)
273
+ throw new Error(`No line or save called "${base}".`);
274
+ let save = await this.readSave(id);
275
+ for (let step = 0; step < steps; step += 1) {
276
+ const parent = save.parents[0];
277
+ if (!parent)
278
+ throw new Error(`"${name}" goes back further than the line does.`);
279
+ save = await this.readSave(parent);
280
+ }
281
+ return save;
282
+ }
283
+ /* ---- the index ------------------------------------------------------ */
284
+ async readIndex() {
285
+ try {
286
+ const raw = JSON.parse(await (0, promises_1.readFile)(node_path_1.default.join(this.directory, "index.json"), "utf8"));
287
+ return new Map(Object.entries(raw));
288
+ }
289
+ catch {
290
+ return new Map();
291
+ }
292
+ }
293
+ async writeIndex(index) {
294
+ await writeAtomically(node_path_1.default.join(this.directory, "index.json"), JSON.stringify(Object.fromEntries(index)));
295
+ }
296
+ /* ---- the lock ------------------------------------------------------- */
297
+ /**
298
+ * Run `work` while holding `.cbx/lock`.
299
+ *
300
+ * Two saves at once would each move the line to their own save and one
301
+ * would be lost. A lock left behind by a crash is not cleared by guessing:
302
+ * the message names the file so a person can remove it.
303
+ */
304
+ async locked(work) {
305
+ const lock = node_path_1.default.join(this.directory, "lock");
306
+ let handle;
307
+ try {
308
+ handle = await (0, promises_1.open)(lock, "wx");
309
+ }
310
+ catch {
311
+ throw new Error(`Another cbx command is using this history. If none is running, delete ${lock} and try again.`);
312
+ }
313
+ await handle.write(`${process.pid}\n`);
314
+ await handle.close();
315
+ try {
316
+ return await work();
317
+ }
318
+ finally {
319
+ await this.objects.release();
320
+ await (0, promises_1.rm)(lock, { force: true });
321
+ }
322
+ }
323
+ }
324
+ exports.Repository = Repository;
@@ -0,0 +1,338 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.inLanes = inLanes;
7
+ exports.listFolder = listFolder;
8
+ exports.storeFile = storeFile;
9
+ exports.readFolder = readFolder;
10
+ exports.contentsOf = contentsOf;
11
+ exports.checkout = checkout;
12
+ exports.sameFile = sameFile;
13
+ exports.writeEntriesInto = writeEntriesInto;
14
+ /**
15
+ * Between a folder and a tree: reading one into the other, and back.
16
+ *
17
+ * Which files count is decided the way CodeRook decides it — the folder's
18
+ * ignore rules, read by `readRules` and walked by `surveyFiles` — so a local
19
+ * save and a save sent to CodeRook hold the same files.
20
+ */
21
+ const node_crypto_1 = require("node:crypto");
22
+ const node_fs_1 = require("node:fs");
23
+ const promises_1 = require("node:fs/promises");
24
+ const node_path_1 = __importDefault(require("node:path"));
25
+ const worktree_js_1 = require("../core/worktree.js");
26
+ const chunking_js_1 = require("../shared/chunking.js");
27
+ const safe_path_js_1 = require("../shared/safe_path.js");
28
+ const READ_SIZE = 8 * 1024 * 1024;
29
+ /*
30
+ How close to now a modification time has to be before it is not trusted.
31
+
32
+ A file written in the same instant the index records it can be written
33
+ again without its time moving, and the index would then vouch for bytes
34
+ that are no longer there. Git calls these racily clean. Anything this
35
+ recent is simply read again next time.
36
+ */
37
+ const RACY_WINDOW_MS = 2_000;
38
+ /*
39
+ How many files are worked on at once.
40
+
41
+ One at a time, a save of twenty thousand small files took seventy-seven
42
+ seconds, almost all of it waiting on the disk between one small request and
43
+ the next. Files over eight megabytes get far fewer lanes, because each one
44
+ holds up to forty megabytes while it is being cut.
45
+ */
46
+ const SMALL_LANES = 16;
47
+ const LARGE_LANES = 2;
48
+ /** Run `work` over `items`, `count` at a time. */
49
+ async function inLanes(items, count, work) {
50
+ let at = 0;
51
+ const lane = async () => {
52
+ while (at < items.length) {
53
+ const item = items[at];
54
+ at += 1;
55
+ await work(item);
56
+ }
57
+ };
58
+ await Promise.all(Array.from({ length: Math.min(count, items.length) }, () => lane()));
59
+ }
60
+ /** The files the rules would save, with what `stat` says about each. */
61
+ async function listFolder(root) {
62
+ const rules = await (0, worktree_js_1.readRules)(root);
63
+ const found = await (0, worktree_js_1.surveyFiles)(root, rules);
64
+ const files = [];
65
+ await inLanes(found, SMALL_LANES, async (one) => {
66
+ try {
67
+ const info = await (0, promises_1.stat)(node_path_1.default.join(root, one.path));
68
+ files.push({ path: one.path, size: info.size, mtimeMs: info.mtimeMs, mode: info.mode });
69
+ }
70
+ catch {
71
+ /* Gone since the walk; it is not in the folder any more. */
72
+ }
73
+ });
74
+ return files.sort((left, right) => (left.path < right.path ? -1 : left.path > right.path ? 1 : 0));
75
+ }
76
+ function executableFrom(mode, previous) {
77
+ /* Windows has no execute bit to read, so whatever was saved last stands. */
78
+ if (process.platform === "win32")
79
+ return previous?.executable ?? false;
80
+ return (mode & 0o100) !== 0;
81
+ }
82
+ /** A file's digest, streamed so a large one is never held whole. */
83
+ async function digestFile(full, size) {
84
+ /*
85
+ Read whole when small. A stream asks for its full window up front, and
86
+ an eight megabyte window per file turned hashing twenty thousand small
87
+ files into forty-five seconds of allocating memory nobody used.
88
+ */
89
+ if (size <= READ_SIZE)
90
+ return (0, node_crypto_1.createHash)("sha256").update(await (0, promises_1.readFile)(full)).digest("hex");
91
+ const hash = (0, node_crypto_1.createHash)("sha256");
92
+ for await (const block of (0, node_fs_1.createReadStream)(full, { highWaterMark: READ_SIZE })) {
93
+ hash.update(block);
94
+ }
95
+ return hash.digest("hex");
96
+ }
97
+ /**
98
+ * Read a file into the store: its digest, and the objects its bytes become.
99
+ *
100
+ * Under eight megabytes a file is one object. Above, it is cut where its
101
+ * content says to, with the same profile a CodeRook upload uses for a file
102
+ * that size, so an edit in the middle of a large asset stores the pieces
103
+ * around the edit and reuses the rest.
104
+ */
105
+ async function storeFile(repository, full, size) {
106
+ const profile = (0, chunking_js_1.profileForFileSize)(size);
107
+ if (!profile) {
108
+ const bytes = await (0, promises_1.readFile)(full);
109
+ const digest = await repository.objects.put(bytes);
110
+ return { sha256: digest, chunks: bytes.length ? [digest] : [] };
111
+ }
112
+ const whole = (0, node_crypto_1.createHash)("sha256");
113
+ const chunks = [];
114
+ let pending = Buffer.alloc(0);
115
+ for await (const piece of (0, node_fs_1.createReadStream)(full, { highWaterMark: READ_SIZE })) {
116
+ const block = piece;
117
+ whole.update(block);
118
+ pending = pending.length ? Buffer.concat([pending, block]) : Buffer.from(block);
119
+ let from = 0;
120
+ for (const cut of (0, chunking_js_1.cutPoints)(pending, profile)) {
121
+ chunks.push(await repository.objects.put(Buffer.from(pending.subarray(from, cut))));
122
+ from = cut;
123
+ }
124
+ pending = pending.subarray(from);
125
+ }
126
+ if (pending.length)
127
+ chunks.push(await repository.objects.put(Buffer.from(pending)));
128
+ return { sha256: whole.digest("hex"), chunks };
129
+ }
130
+ /**
131
+ * What the folder holds now.
132
+ *
133
+ * With `store` the bytes of every changed file go into the object store and
134
+ * each entry carries its objects, ready to become a tree. Without, files are
135
+ * only hashed, which is all a status or a diff needs, and nothing is written.
136
+ * Either way a file whose size and time match the index is not read at all.
137
+ */
138
+ async function readFolder(repository, options) {
139
+ const index = await repository.readIndex();
140
+ const previous = new Map(options.previous?.files.map((file) => [file.path, file]) ?? []);
141
+ const listed = await listFolder(repository.root);
142
+ const files = new Map();
143
+ const next = new Map();
144
+ 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);
148
+ const known = index.get(file.path);
149
+ let sha256;
150
+ let chunks = [];
151
+ const unchanged = known &&
152
+ known.size === file.size &&
153
+ 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);
164
+ }
165
+ files.set(file.path, {
166
+ path: file.path,
167
+ size: file.size,
168
+ sha256,
169
+ executable: executableFrom(file.mode, previous.get(file.path)),
170
+ chunks,
171
+ });
172
+ if (file.mtimeMs < racyBefore) {
173
+ next.set(file.path, { size: file.size, mtimeMs: file.mtimeMs, sha256, chunks });
174
+ }
175
+ done += 1;
176
+ options.progress?.(done, listed.length, file.path);
177
+ };
178
+ const large = (file) => (0, chunking_js_1.profileForFileSize)(file.size) !== null;
179
+ await Promise.all([
180
+ inLanes(listed.filter((file) => !large(file)), SMALL_LANES, one),
181
+ inLanes(listed.filter(large), LARGE_LANES, one),
182
+ ]);
183
+ /* Nothing may name an object that is not on disk yet. */
184
+ if (options.store)
185
+ await repository.objects.flush();
186
+ options.progress?.(listed.length, listed.length, "");
187
+ return { files, index: next };
188
+ }
189
+ /** A tree entry's bytes, put back together from the store and checked. */
190
+ async function contentsOf(repository, entry) {
191
+ const pieces = [];
192
+ for (const chunk of entry.chunks)
193
+ pieces.push(await repository.objects.get(chunk));
194
+ const contents = Buffer.concat(pieces);
195
+ if (contents.length !== entry.size ||
196
+ (0, node_crypto_1.createHash)("sha256").update(contents).digest("hex") !== entry.sha256) {
197
+ throw new Error(`${entry.path} in the history does not match its record.`);
198
+ }
199
+ return contents;
200
+ }
201
+ /**
202
+ * Write one tree entry into the folder.
203
+ *
204
+ * Assembled beside the file and renamed over it, so a failure part-way
205
+ * leaves the old file rather than half of the new one. Pieces are streamed to
206
+ * disk one at a time, and the digest is checked before the rename.
207
+ */
208
+ async function writeEntry(repository, root, entry, folders) {
209
+ /* Refuses `..`, absolute paths, `.git` and `.cbx`; see safe_path.ts. */
210
+ const full = (0, safe_path_js_1.inside)(root, entry.path);
211
+ /* Each folder made once, however many files land in it. */
212
+ const folder = node_path_1.default.dirname(full);
213
+ let making = folders.get(folder);
214
+ if (!making) {
215
+ making = (0, promises_1.mkdir)(folder, { recursive: true });
216
+ folders.set(folder, making);
217
+ }
218
+ await making;
219
+ const partial = `${full}.${(0, node_crypto_1.randomBytes)(6).toString("hex")}.cbx-partial`;
220
+ const finish = async () => {
221
+ if (process.platform !== "win32") {
222
+ await (0, promises_1.chmod)(partial, entry.executable ? 0o755 : 0o644);
223
+ }
224
+ /* Replaces what is there in one step; removing it first left a gap. */
225
+ await (0, promises_1.rename)(partial, full);
226
+ };
227
+ if (entry.chunks.length <= 1) {
228
+ const raw = entry.chunks.length ? await repository.objects.get(entry.chunks[0]) : Buffer.alloc(0);
229
+ if (raw.length !== entry.size || (0, node_crypto_1.createHash)("sha256").update(raw).digest("hex") !== entry.sha256) {
230
+ throw new Error(`${entry.path} in the history does not match its record.`);
231
+ }
232
+ /*
233
+ Nothing there yet, so nothing to leave half-replaced: written straight
234
+ to its name, which is a third of the cost of writing beside it and
235
+ renaming. An interrupted write leaves a file that no longer matches,
236
+ which the next status reports and a restore puts right.
237
+ */
238
+ try {
239
+ await (0, promises_1.writeFile)(full, raw, { flag: "wx", mode: entry.executable ? 0o755 : 0o644 });
240
+ return;
241
+ }
242
+ catch (error) {
243
+ if (error.code !== "EEXIST")
244
+ throw error;
245
+ }
246
+ try {
247
+ await (0, promises_1.writeFile)(partial, raw);
248
+ await finish();
249
+ }
250
+ catch (error) {
251
+ await (0, promises_1.rm)(partial, { force: true });
252
+ throw error;
253
+ }
254
+ return;
255
+ }
256
+ const handle = await (0, promises_1.open)(partial, "w");
257
+ const hash = (0, node_crypto_1.createHash)("sha256");
258
+ let written = 0;
259
+ try {
260
+ for (const chunk of entry.chunks) {
261
+ const raw = await repository.objects.get(chunk);
262
+ hash.update(raw);
263
+ await handle.write(raw, 0, raw.length, written);
264
+ written += raw.length;
265
+ }
266
+ await handle.close();
267
+ if (written !== entry.size || hash.digest("hex") !== entry.sha256) {
268
+ throw new Error(`${entry.path} in the history does not match its record.`);
269
+ }
270
+ await finish();
271
+ }
272
+ catch (error) {
273
+ await handle.close().catch(() => undefined);
274
+ await (0, promises_1.rm)(partial, { force: true });
275
+ throw error;
276
+ }
277
+ }
278
+ /** Remove folders this left empty, stopping at the root or the first one in use. */
279
+ async function pruneEmpty(root, from) {
280
+ let at = node_path_1.default.dirname(from);
281
+ const base = node_path_1.default.resolve(root);
282
+ while (at.startsWith(base + node_path_1.default.sep)) {
283
+ try {
284
+ if ((await (0, promises_1.readdir)(at)).length)
285
+ return;
286
+ await (0, promises_1.rmdir)(at);
287
+ }
288
+ catch {
289
+ return;
290
+ }
291
+ at = node_path_1.default.dirname(at);
292
+ }
293
+ }
294
+ /**
295
+ * Make the folder's files match `to`, where they are currently `from`.
296
+ *
297
+ * Only what differs is touched: a file identical in both is left exactly as
298
+ * it is, and a file in neither — anything the history does not hold — is
299
+ * never looked at. `only`, when given, limits the change to those paths.
300
+ */
301
+ async function checkout(repository, from, to, only) {
302
+ const target = new Map(to.files.map((file) => [file.path, file]));
303
+ const written = [];
304
+ const removed = [];
305
+ const changed = to.files.filter((entry) => {
306
+ if (only && !only(entry.path))
307
+ return false;
308
+ return !sameFile(from.get(entry.path), entry);
309
+ });
310
+ const folders = new Map();
311
+ await inLanes(changed, SMALL_LANES, async (entry) => {
312
+ await writeEntry(repository, repository.root, entry, folders);
313
+ written.push(entry.path);
314
+ });
315
+ written.sort();
316
+ for (const file of from.keys()) {
317
+ if (target.has(file) || (only && !only(file)))
318
+ continue;
319
+ const full = (0, safe_path_js_1.inside)(repository.root, file);
320
+ await (0, promises_1.rm)(full, { force: true });
321
+ await pruneEmpty(repository.root, full);
322
+ removed.push(file);
323
+ }
324
+ return { written, removed };
325
+ }
326
+ /** Whether two entries hold the same file, bytes and execute bit alike. */
327
+ function sameFile(left, right) {
328
+ return (!!left && !!right && left.sha256 === right.sha256 && left.executable === right.executable);
329
+ }
330
+ /**
331
+ * Write entries into a folder other than the project: the scratch tree a
332
+ * push sends from. Nothing there is checked against unsaved work, because
333
+ * nothing there is anybody's work.
334
+ */
335
+ async function writeEntriesInto(repository, root, entries) {
336
+ const folders = new Map();
337
+ await inLanes(entries, SMALL_LANES, (entry) => writeEntry(repository, root, entry, folders));
338
+ }