@coderook/cli 0.4.0 → 0.6.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.
@@ -270,9 +270,36 @@ async function commandStatus(parsed) {
270
270
  async function commandSubmit(parsed) {
271
271
  const folder = folderFor(parsed);
272
272
  const message = flagText(parsed, "m", "message") ?? "";
273
- const { link, baseline } = await reconcile(folder);
273
+ const { link, baseline, behind } = await reconcile(folder);
274
274
  const rules = await (0, worktree_js_1.readRules)(folder);
275
275
  const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
276
+ /*
277
+ An upgraded folder with no materialisation record and something that
278
+ differs from its recorded version is ambiguous in the one way that
279
+ matters: the difference is either work to send, or a copy left behind by
280
+ somebody's merge. Sending the second reverts their change.
281
+
282
+ While the folder is level with the project the question does not arise —
283
+ nothing can have merged underneath it — so only a folder that is behind
284
+ is stopped, and only until somebody says which it is.
285
+ */
286
+ if (link && !link.local && files.length && behind) {
287
+ if (await establishMaterialisation(folder, link)) {
288
+ link.local = { ...link.manifest };
289
+ await (0, config_js_1.writeLink)(folder, link);
290
+ }
291
+ else if (!hasFlag(parsed, "f", "force")) {
292
+ console.error(red("This folder was connected by an older version of CodeRook,"));
293
+ console.error("which did not record what it had received — and somebody");
294
+ console.error(`has saved since (the project is on v${behind.sequence}).\n`);
295
+ console.error(`${files.length} file${files.length === 1 ? " differs" : "s differ"} here:`);
296
+ for (const file of files.slice(0, 20))
297
+ console.error(` ${file.path}`);
298
+ console.error(`\nIf that is your work, send it with ${accent("coderook submit --force")}.` +
299
+ `\nIf it is an old copy, take theirs with ${accent("coderook get --replace")}.`);
300
+ return 1;
301
+ }
302
+ }
276
303
  if (!files.length) {
277
304
  // Having nothing to send is not the same as holding the whole version:
278
305
  // a merge can leave this folder with an older copy of somebody else's
@@ -415,6 +442,20 @@ The connection failed: ${text}`));
415
442
  ` · version holds ${bytes(result.sourceBytes)}`);
416
443
  return 0;
417
444
  }
445
+ /**
446
+ * Work out what an upgraded folder actually holds, or admit that it cannot.
447
+ *
448
+ * Returns the materialisation when the folder matches its recorded version
449
+ * exactly — which proves the manifest describes what is here — and null when
450
+ * anything differs, because a difference is either somebody's unsaved edit or
451
+ * a copy their merge left stale, and nothing on disk says which.
452
+ */
453
+ async function establishMaterialisation(folder, link) {
454
+ const rules = await (0, worktree_js_1.readRules)(folder);
455
+ const against = new Map(Object.entries(link.manifest));
456
+ const differing = await (0, worktree_js_1.changedFiles)(folder, rules, against);
457
+ return differing.length ? null : { ...link.manifest };
458
+ }
418
459
  async function commandGet(parsed) {
419
460
  const folder = folderFor(parsed);
420
461
  const { link } = await reconcile(folder);
@@ -422,8 +463,87 @@ async function commandGet(parsed) {
422
463
  console.error(red("This folder is not linked to a project on your account."));
423
464
  return 1;
424
465
  }
425
- // Only what this folder received may be removed by catching up.
426
- return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null);
466
+ /*
467
+ Fetching writes over what is here. That is the point of it — but a file
468
+ somebody has edited and not yet saved is not the same as a file sitting
469
+ where the last fetch left it, and overwriting the first loses work that
470
+ exists nowhere else.
471
+
472
+ Telling them apart needs the materialisation record: disk differing from
473
+ what was placed here is an edit, and disk matching it is simply an old
474
+ copy. Only the edits are worth stopping for, and only when the incoming
475
+ version would actually change those files.
476
+ */
477
+ const replacing = hasFlag(parsed, "replace", "f", "force");
478
+ /*
479
+ A folder connected by a client older than the materialisation record has
480
+ only the Version manifest, which says what the project held — not what
481
+ was placed here. The two are the same only until somebody's merge makes
482
+ them differ, so assuming it would be the very guess that lost work in the
483
+ first place.
484
+
485
+ It can be established rather than assumed: if the folder matches its own
486
+ recorded version exactly, then it demonstrably holds that version and the
487
+ manifest is the materialisation. If it does not match, an edit cannot be
488
+ told from a stale copy, and fetching is refused until somebody says which
489
+ it is.
490
+ */
491
+ if (!link.local) {
492
+ const settled = await establishMaterialisation(folder, link);
493
+ if (settled) {
494
+ link.local = settled;
495
+ await (0, config_js_1.writeLink)(folder, link);
496
+ }
497
+ else if (!replacing) {
498
+ console.error(red("This folder was connected by an older version of CodeRook,"));
499
+ console.error("which did not record what it had received, so changed files here");
500
+ console.error("cannot be told apart from copies left behind by a merge.\n");
501
+ console.error(`Save them with ${accent("coderook submit")} if they are your work, or ` +
502
+ `take the\nsaved version exactly with ${accent("coderook get --replace")}.`);
503
+ return 1;
504
+ }
505
+ }
506
+ if (link.local && !replacing) {
507
+ const rules = await (0, worktree_js_1.readRules)(folder);
508
+ const edited = await (0, worktree_js_1.changedFiles)(folder, rules, new Map(Object.entries(link.local)));
509
+ if (edited.length) {
510
+ const downloader = new download_js_1.Downloader(config_js_1.credentials);
511
+ const latest = (await downloader.versions(link.repositoryId))[0];
512
+ const incoming = new Map(latest
513
+ ? (await downloader.files(link.repositoryId, latest.id)).map((file) => [
514
+ file.path,
515
+ file.sha256,
516
+ ])
517
+ : []);
518
+ /*
519
+ Only a genuine collision stops the fetch: this folder changed the
520
+ file and so did the incoming version. An edit to a file the version
521
+ left alone is simply kept — fetching has nothing to say about it —
522
+ and stopping for those would make the warning something people learn
523
+ to click past.
524
+ */
525
+ const atRisk = edited.filter((file) => !file.deleted &&
526
+ incoming.has(file.path) &&
527
+ incoming.get(file.path) !== link.local[file.path]);
528
+ if (atRisk.length) {
529
+ console.error(red(`${atRisk.length} file${atRisk.length === 1 ? "" : "s"} here ` +
530
+ `${atRisk.length === 1 ? "has" : "have"} changes that fetching would overwrite:`));
531
+ for (const file of atRisk.slice(0, 20))
532
+ console.error(` ${file.path}`);
533
+ if (atRisk.length > 20)
534
+ console.error(dim(` …and ${atRisk.length - 20} more`));
535
+ console.error(`\nSave them first with ${accent("coderook submit")}, or discard them ` +
536
+ `with ${accent("coderook get --replace")}.`);
537
+ return 1;
538
+ }
539
+ }
540
+ }
541
+ /*
542
+ Only what this folder received may be removed by catching up, and only
543
+ what the version actually changed is written over — unless somebody asks
544
+ for an exact copy, which is how a damaged folder is repaired.
545
+ */
546
+ return fetchInto(link.repositoryId, folder, link.slug, link.local ?? null, replacing ? "replace" : "reconcile");
427
547
  }
428
548
  async function commandClone(parsed) {
429
549
  const reference = parsed.positional[0];
@@ -450,7 +570,7 @@ async function fetchInto(repositoryId, destination, slug,
450
570
  is removed, because "missing from the version" cannot then be told from
451
571
  "never came from the version at all".
452
572
  */
453
- held) {
573
+ held, mode = "replace") {
454
574
  const downloader = new download_js_1.Downloader(config_js_1.credentials);
455
575
  const latest = (await downloader.versions(repositoryId))[0];
456
576
  if (!latest) {
@@ -464,7 +584,7 @@ held) {
464
584
  },
465
585
  // What this folder received last time, so a file the project has since
466
586
  // dropped is removed while everything untracked is left alone.
467
- held);
587
+ held, mode);
468
588
  done(line);
469
589
  // Fetching is how a folder catches up, so this is what moves its base.
470
590
  await (0, config_js_1.writeLink)(destination, {
@@ -580,7 +700,8 @@ ${bold("Getting started")}
580
700
  ${bold("Working with a folder")}
581
701
  coderook status [folder] What is here that is not saved yet
582
702
  coderook submit [folder] -m "…" Send the changes as a new version
583
- coderook get [folder] Fetch the latest version over this folder
703
+ coderook get [folder] Bring this folder up to date
704
+ (--replace for an exact copy)
584
705
  coderook clone <project> [dir] Fetch a project into a new folder
585
706
  coderook rules [folder] Show the ignore rules (--init to start one)
586
707
 
@@ -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,40 +1,43 @@
1
- {
2
- "name": "@coderook/cli",
3
- "version": "0.4.0",
4
- "description": "CodeRook from the command line, on any operating system",
5
- "license": "SEE LICENSE IN LICENSE.txt",
6
- "homepage": "https://coderook.com",
7
- "bugs": {
8
- "url": "https://coderook.com/contact"
9
- },
10
- "keywords": [
11
- "coderook",
12
- "versioning",
13
- "backup",
14
- "cbx"
15
- ],
16
- "engines": {
17
- "node": ">=20.11.0"
18
- },
19
- "bin": {
20
- "coderook": "dist/cli/src/cli.js"
21
- },
22
- "files": [
23
- "dist"
24
- ],
25
- "scripts": {
26
- "build": "tsc -p tsconfig.json",
27
- "check": "tsc -p tsconfig.json --noEmit",
28
- "test": "tsc -p tsconfig.json && node --test --experimental-strip-types test/*.test.ts",
29
- "start": "node dist/cli/src/cli.js",
30
- "prepublishOnly": "npm run build",
31
- "test:e2e": "node test/e2e.mjs",
32
- "test:matrix": "node test/state-matrix.mjs",
33
- "test:attempt": "node test/attempt-identity.mjs",
34
- "test:get": "node test/get-safety.mjs"
35
- },
36
- "devDependencies": {
37
- "@types/node": "24.10.1",
38
- "typescript": "5.9.3"
39
- }
40
- }
1
+ {
2
+ "name": "@coderook/cli",
3
+ "version": "0.6.0",
4
+ "description": "CodeRook from the command line, on any operating system",
5
+ "license": "SEE LICENSE IN LICENSE.txt",
6
+ "homepage": "https://coderook.com",
7
+ "bugs": {
8
+ "url": "https://coderook.com/contact"
9
+ },
10
+ "keywords": [
11
+ "coderook",
12
+ "versioning",
13
+ "backup",
14
+ "cbx"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20.11.0"
18
+ },
19
+ "bin": {
20
+ "coderook": "dist/cli/src/cli.js"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsc -p tsconfig.json",
27
+ "check": "tsc -p tsconfig.json --noEmit",
28
+ "test": "tsc -p tsconfig.json && node --test --experimental-strip-types test/*.test.ts",
29
+ "start": "node dist/cli/src/cli.js",
30
+ "prepublishOnly": "npm run build",
31
+ "test:e2e": "node test/e2e.mjs",
32
+ "test:matrix": "node test/state-matrix.mjs",
33
+ "test:attempt": "node test/attempt-identity.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/upgrade-migration.mjs --allow-production && node test/attempt-identity.mjs --allow-production",
36
+ "test:getedits": "node test/get-protects-edits.mjs",
37
+ "test:upgrade": "node test/upgrade-migration.mjs"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "24.10.1",
41
+ "typescript": "5.9.3"
42
+ }
43
+ }