@coderook/cli 0.4.0 → 0.5.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.
@@ -422,8 +422,59 @@ async function commandGet(parsed) {
422
422
  console.error(red("This folder is not linked to a project on your account."));
423
423
  return 1;
424
424
  }
425
- // Only what this folder received may be removed by catching up.
426
- return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null);
425
+ /*
426
+ Fetching writes over what is here. That is the point of it — but a file
427
+ somebody has edited and not yet saved is not the same as a file sitting
428
+ where the last fetch left it, and overwriting the first loses work that
429
+ exists nowhere else.
430
+
431
+ Telling them apart needs the materialisation record: disk differing from
432
+ what was placed here is an edit, and disk matching it is simply an old
433
+ copy. Only the edits are worth stopping for, and only when the incoming
434
+ version would actually change those files.
435
+ */
436
+ const replacing = hasFlag(parsed, "replace", "f", "force");
437
+ if (link.local && !replacing) {
438
+ const rules = await (0, worktree_js_1.readRules)(folder);
439
+ const edited = await (0, worktree_js_1.changedFiles)(folder, rules, new Map(Object.entries(link.local)));
440
+ if (edited.length) {
441
+ const downloader = new download_js_1.Downloader(config_js_1.credentials);
442
+ const latest = (await downloader.versions(link.repositoryId))[0];
443
+ const incoming = new Map(latest
444
+ ? (await downloader.files(link.repositoryId, latest.id)).map((file) => [
445
+ file.path,
446
+ file.sha256,
447
+ ])
448
+ : []);
449
+ /*
450
+ Only a genuine collision stops the fetch: this folder changed the
451
+ file and so did the incoming version. An edit to a file the version
452
+ left alone is simply kept — fetching has nothing to say about it —
453
+ and stopping for those would make the warning something people learn
454
+ to click past.
455
+ */
456
+ const atRisk = edited.filter((file) => !file.deleted &&
457
+ incoming.has(file.path) &&
458
+ incoming.get(file.path) !== link.local[file.path]);
459
+ if (atRisk.length) {
460
+ console.error(red(`${atRisk.length} file${atRisk.length === 1 ? "" : "s"} here ` +
461
+ `${atRisk.length === 1 ? "has" : "have"} changes that fetching would overwrite:`));
462
+ for (const file of atRisk.slice(0, 20))
463
+ console.error(` ${file.path}`);
464
+ if (atRisk.length > 20)
465
+ console.error(dim(` …and ${atRisk.length - 20} more`));
466
+ console.error(`\nSave them first with ${accent("coderook submit")}, or discard them ` +
467
+ `with ${accent("coderook get --replace")}.`);
468
+ return 1;
469
+ }
470
+ }
471
+ }
472
+ /*
473
+ Only what this folder received may be removed by catching up, and only
474
+ what the version actually changed is written over — unless somebody asks
475
+ for an exact copy, which is how a damaged folder is repaired.
476
+ */
477
+ return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, replacing ? "replace" : "reconcile");
427
478
  }
428
479
  async function commandClone(parsed) {
429
480
  const reference = parsed.positional[0];
@@ -450,7 +501,7 @@ async function fetchInto(repositoryId, destination, slug,
450
501
  is removed, because "missing from the version" cannot then be told from
451
502
  "never came from the version at all".
452
503
  */
453
- held) {
504
+ held, mode = "replace") {
454
505
  const downloader = new download_js_1.Downloader(config_js_1.credentials);
455
506
  const latest = (await downloader.versions(repositoryId))[0];
456
507
  if (!latest) {
@@ -464,7 +515,7 @@ held) {
464
515
  },
465
516
  // What this folder received last time, so a file the project has since
466
517
  // dropped is removed while everything untracked is left alone.
467
- held);
518
+ held, mode);
468
519
  done(line);
469
520
  // Fetching is how a folder catches up, so this is what moves its base.
470
521
  await (0, config_js_1.writeLink)(destination, {
@@ -580,7 +631,8 @@ ${bold("Getting started")}
580
631
  ${bold("Working with a folder")}
581
632
  coderook status [folder] What is here that is not saved yet
582
633
  coderook submit [folder] -m "…" Send the changes as a new version
583
- coderook get [folder] Fetch the latest version over this folder
634
+ coderook get [folder] Bring this folder up to date
635
+ (--replace for an exact copy)
584
636
  coderook clone <project> [dir] Fetch a project into a new folder
585
637
  coderook rules [folder] Show the ignore rules (--init to start one)
586
638
 
@@ -15,6 +15,24 @@ exports.Downloader = exports.DownloadCancelled = void 0;
15
15
  const node_crypto_1 = require("node:crypto");
16
16
  const promises_1 = require("node:fs/promises");
17
17
  const node_path_1 = __importDefault(require("node:path"));
18
+ async function exists(target) {
19
+ try {
20
+ await (0, promises_1.stat)(target);
21
+ return true;
22
+ }
23
+ catch {
24
+ return false;
25
+ }
26
+ }
27
+ /** The digest of what is on disk, or null when nothing is there to read. */
28
+ async function digestOf(target) {
29
+ try {
30
+ return (0, node_crypto_1.createHash)("sha256").update(await (0, promises_1.readFile)(target)).digest("hex");
31
+ }
32
+ catch {
33
+ return null;
34
+ }
35
+ }
18
36
  /**
19
37
  * Remove the directories a deletion just emptied, up to but never including
20
38
  * the project folder itself. Left alone they accumulate as empty husks of
@@ -115,7 +133,15 @@ class Downloader {
115
133
  * itself. A file the version no longer holds is removed only when this
116
134
  * folder is known to have received it, which `held` supplies.
117
135
  */
118
- async run(repositoryId, versionId, destination, report, held) {
136
+ async run(repositoryId, versionId, destination, report, held,
137
+ /*
138
+ `reconcile` brings the folder up to date without disturbing work in
139
+ progress: a file the incoming version did not change is left exactly as
140
+ it is, edits and all. `replace` makes the folder an exact copy of the
141
+ version, which is what repairs a damaged one — and what discards
142
+ anything unsaved.
143
+ */
144
+ mode = "replace") {
119
145
  const files = await this.files(repositoryId, versionId);
120
146
  if (!files.length)
121
147
  throw new Error("That version has no files");
@@ -165,6 +191,18 @@ class Downloader {
165
191
  for (const file of files) {
166
192
  const parts = file.path.split("/").filter(Boolean);
167
193
  const target = node_path_1.default.join(destination, ...parts);
194
+ /*
195
+ Reconciling leaves alone whatever the incoming version did not
196
+ change. Writing it anyway would replace an edit in progress with a
197
+ copy of what the folder already had — destroying work to achieve
198
+ nothing, which is the least defensible way to lose someone's file.
199
+ */
200
+ if (mode === "reconcile" &&
201
+ held &&
202
+ held[file.path] === manifest[file.path] &&
203
+ (await exists(target))) {
204
+ continue;
205
+ }
168
206
  await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
169
207
  await (0, promises_1.rm)(target, { recursive: true, force: true });
170
208
  await (0, promises_1.rename)(node_path_1.default.join(staging, ...parts), target);
@@ -175,14 +213,22 @@ class Downloader {
175
213
  to remove — and without a record of what it received, nothing is
176
214
  removed at all, which is the safe reading of an unknown folder.
177
215
  */
178
- for (const path_ of Object.keys(held ?? {})) {
179
- if (manifest[path_] !== undefined)
216
+ for (const [gone, digest] of Object.entries(held ?? {})) {
217
+ if (manifest[gone] !== undefined)
180
218
  continue;
181
- const parts = path_.split("/").filter(Boolean);
219
+ const parts = gone.split("/").filter(Boolean);
182
220
  if (parts.some((part) => part === ".." || part.includes("\0")))
183
221
  continue;
184
- await (0, promises_1.rm)(node_path_1.default.join(destination, ...parts), { force: true });
185
- await pruneEmpty(destination, node_path_1.default.join(destination, ...parts));
222
+ const target = node_path_1.default.join(destination, ...parts);
223
+ /*
224
+ The version dropped it — but if the bytes here are no longer the
225
+ ones this folder received, somebody has been working on it, and
226
+ somebody else's deletion is not permission to throw that away.
227
+ */
228
+ if (mode === "reconcile" && (await digestOf(target)) !== digest)
229
+ continue;
230
+ await (0, promises_1.rm)(target, { force: true });
231
+ await pruneEmpty(destination, target);
186
232
  }
187
233
  await (0, promises_1.rm)(staging, { recursive: true, force: true });
188
234
  report({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "CodeRook from the command line, on any operating system",
5
5
  "license": "SEE LICENSE IN LICENSE.txt",
6
6
  "homepage": "https://coderook.com",
@@ -31,7 +31,9 @@
31
31
  "test:e2e": "node test/e2e.mjs",
32
32
  "test:matrix": "node test/state-matrix.mjs",
33
33
  "test:attempt": "node test/attempt-identity.mjs",
34
- "test:get": "node test/get-safety.mjs"
34
+ "test:get": "node test/get-safety.mjs",
35
+ "test:live": "node test/e2e.mjs --allow-production && node test/state-matrix.mjs --allow-production && node test/get-safety.mjs --allow-production && node test/get-protects-edits.mjs --allow-production && node test/attempt-identity.mjs --allow-production",
36
+ "test:getedits": "node test/get-protects-edits.mjs"
35
37
  },
36
38
  "devDependencies": {
37
39
  "@types/node": "24.10.1",