@coderook/cli 0.3.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.
@@ -148,7 +148,13 @@ async function reconcile(folder, options = {}) {
148
148
  }
149
149
  return {
150
150
  link: known,
151
- baseline: new Map(Object.entries(known.manifest)),
151
+ /*
152
+ The comparison is against what this folder actually holds, not what
153
+ the version holds. After a merge the two differ, and comparing
154
+ against the version would read a stale copy as a fresh edit and
155
+ push it back over the change that replaced it.
156
+ */
157
+ baseline: new Map(Object.entries(known.local ?? known.manifest)),
152
158
  behind,
153
159
  };
154
160
  }
@@ -161,6 +167,8 @@ async function reconcile(folder, options = {}) {
161
167
  slug: project.slug,
162
168
  sequence: latest.sequence,
163
169
  versionId: latest.id,
170
+ // Adopted whole, so the folder is taken to hold what the version holds.
171
+ local: Object.fromEntries(baseline),
164
172
  manifest: Object.fromEntries(baseline),
165
173
  };
166
174
  await (0, config_js_1.writeLink)(folder, fresh);
@@ -229,8 +237,26 @@ async function commandStatus(parsed) {
229
237
  console.log(link
230
238
  ? `Account holds ${accent(`v${link.sequence}`)} · ${Object.keys(link.manifest).length} files ${dim(`(${link.slug})`)}`
231
239
  : dim("Not on your account yet — submitting will create it."));
240
+ /*
241
+ A folder can have nothing to send and still not hold the whole version:
242
+ when the service merges somebody else's save into yours, their files
243
+ join the version without ever arriving here. Saying so is the difference
244
+ between "you are level" and "you are level with your own work".
245
+ */
246
+ const stale = link?.local
247
+ ? Object.entries(link.manifest).filter(([path, digest]) => link.local[path] !== digest)
248
+ : [];
249
+ if (stale.length) {
250
+ console.log(dim(`${stale.length} file${stale.length === 1 ? "" : "s"} in the saved version ` +
251
+ `${stale.length === 1 ? "is" : "are"} newer than the cop${stale.length === 1 ? "y" : "ies"} here ` +
252
+ `— run ${accent("coderook get")} to bring ${stale.length === 1 ? "it" : "them"} down.`));
253
+ for (const [path] of stale.slice(0, 10))
254
+ console.log(dim(` behind ${path}`));
255
+ }
232
256
  if (!files.length) {
233
- console.log("Nothing to submit; this folder matches the saved version.");
257
+ console.log(stale.length
258
+ ? "Nothing to submit; your own work is all saved."
259
+ : "Nothing to submit; this folder matches the saved version.");
234
260
  return 0;
235
261
  }
236
262
  console.log(`\n${files.length} file${files.length === 1 ? "" : "s"} to submit:`);
@@ -248,7 +274,17 @@ async function commandSubmit(parsed) {
248
274
  const rules = await (0, worktree_js_1.readRules)(folder);
249
275
  const files = await (0, worktree_js_1.changedFiles)(folder, rules, baseline);
250
276
  if (!files.length) {
251
- console.log("Nothing to submit; this folder matches the saved version.");
277
+ // Having nothing to send is not the same as holding the whole version:
278
+ // a merge can leave this folder with an older copy of somebody else's
279
+ // file. Saying so is what stops "nothing to do" from reading as "level".
280
+ const behindOn = link?.local
281
+ ? Object.entries(link.manifest).filter(([path, digest]) => link.local[path] !== digest).length
282
+ : 0;
283
+ console.log(behindOn
284
+ ? `Nothing to submit; your own work is all saved. ${behindOn} file` +
285
+ `${behindOn === 1 ? "" : "s"} here ${behindOn === 1 ? "is" : "are"} ` +
286
+ `behind the saved version — run ${accent("coderook get")}.`
287
+ : "Nothing to submit; this folder matches the saved version.");
252
288
  return 0;
253
289
  }
254
290
  /*
@@ -298,6 +334,12 @@ async function commandSubmit(parsed) {
298
334
  nothing rather than guessing, and publishes as it always did.
299
335
  */
300
336
  ...(link?.versionId ? { expectedHeadVersionId: link.versionId } : {}),
337
+ /*
338
+ What this folder believed the project held. It is how the service
339
+ tells a file this person deleted from one they never had, and
340
+ without it somebody else's work disappears at the next save.
341
+ */
342
+ ...(link ? { known: link.local ?? link.manifest ?? {} } : {}),
301
343
  }, (progress) => {
302
344
  line(` ${String(progress.percent).padStart(3)}% ${progress.stage.padEnd(7)} ` +
303
345
  `${progress.files}/${progress.totalFiles} ${progress.path.slice(-48)}`);
@@ -306,6 +348,21 @@ async function commandSubmit(parsed) {
306
348
  catch (error) {
307
349
  done(line);
308
350
  const code = error.code;
351
+ const text = error instanceof Error ? error.message : String(error);
352
+ /*
353
+ A connection that fails part way through says nothing about whether
354
+ the work was done. It may well have been — the service records the
355
+ attempt, so running the same command again is answered with the
356
+ version it already made rather than a second one. Saying so is the
357
+ difference between a person retrying and a person wondering.
358
+ */
359
+ if (!code && /fetch failed|ECONNRESET|socket hang up|network|ETIMEDOUT/i.test(text)) {
360
+ console.error(red(`
361
+ The connection failed: ${text}`));
362
+ console.error(`Your work may already have been saved. Run the same command again —` +
363
+ ` it will not create a second version.`);
364
+ return 1;
365
+ }
309
366
  if (code === "merge_required" || code === "track_moved") {
310
367
  /*
311
368
  Somebody else saved to this project first. Everything that did not
@@ -344,8 +401,14 @@ async function commandSubmit(parsed) {
344
401
  sequence: result.sequence,
345
402
  // What this folder is now working from, so the next submit can say so.
346
403
  versionId: result.versionId,
404
+ local: result.local,
347
405
  manifest: result.manifest,
348
406
  });
407
+ if (result.repeated) {
408
+ console.log(`Already saved as ${accent(`v${result.sequence}`)} by an earlier attempt;` +
409
+ ` nothing was sent again.`);
410
+ return 0;
411
+ }
349
412
  console.log(`Saved ${accent(`v${result.sequence}`)} · sent ${bytes(result.sentBytes)} in ` +
350
413
  `${result.sentFiles} file${result.sentFiles === 1 ? "" : "s"}` +
351
414
  (result.reusedFiles ? `, ${result.reusedFiles} already stored` : "") +
@@ -359,7 +422,59 @@ async function commandGet(parsed) {
359
422
  console.error(red("This folder is not linked to a project on your account."));
360
423
  return 1;
361
424
  }
362
- return fetchInto(link.repositoryId, folder, link.slug);
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");
363
478
  }
364
479
  async function commandClone(parsed) {
365
480
  const reference = parsed.positional[0];
@@ -379,7 +494,14 @@ async function commandClone(parsed) {
379
494
  const destination = node_path_1.default.resolve(parsed.positional[1] ?? project.slug);
380
495
  return fetchInto(project.id, destination, project.slug);
381
496
  }
382
- async function fetchInto(repositoryId, destination, slug) {
497
+ async function fetchInto(repositoryId, destination, slug,
498
+ /*
499
+ What this folder received before, so a file the project has since dropped
500
+ is removed. Absent — a clone, or a folder whose record was lost — nothing
501
+ is removed, because "missing from the version" cannot then be told from
502
+ "never came from the version at all".
503
+ */
504
+ held, mode = "replace") {
383
505
  const downloader = new download_js_1.Downloader(config_js_1.credentials);
384
506
  const latest = (await downloader.versions(repositoryId))[0];
385
507
  if (!latest) {
@@ -390,7 +512,10 @@ async function fetchInto(repositoryId, destination, slug) {
390
512
  const result = await downloader.run(repositoryId, latest.id, destination, (progress) => {
391
513
  line(` ${String(progress.percent).padStart(3)}% ${progress.files}/${progress.totalFiles}` +
392
514
  ` ${progress.path.slice(-48)}`);
393
- });
515
+ },
516
+ // What this folder received last time, so a file the project has since
517
+ // dropped is removed while everything untracked is left alone.
518
+ held, mode);
394
519
  done(line);
395
520
  // Fetching is how a folder catches up, so this is what moves its base.
396
521
  await (0, config_js_1.writeLink)(destination, {
@@ -398,6 +523,9 @@ async function fetchInto(repositoryId, destination, slug) {
398
523
  slug,
399
524
  sequence: latest.sequence,
400
525
  versionId: latest.id,
526
+ // Fetching writes every file, so the folder now holds what the
527
+ // version holds and the two agree again.
528
+ local: result.manifest,
401
529
  manifest: result.manifest,
402
530
  });
403
531
  console.log(`${accent(`v${latest.sequence}`)} · ${result.files} files · ${bytes(result.bytes)} into ${destination}`);
@@ -503,7 +631,8 @@ ${bold("Getting started")}
503
631
  ${bold("Working with a folder")}
504
632
  coderook status [folder] What is here that is not saved yet
505
633
  coderook submit [folder] -m "…" Send the changes as a new version
506
- 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)
507
636
  coderook clone <project> [dir] Fetch a project into a new folder
508
637
  coderook rules [folder] Show the ignore rules (--init to start one)
509
638
 
@@ -9,6 +9,7 @@ exports.configDirectory = configDirectory;
9
9
  exports.storeToken = storeToken;
10
10
  exports.loadToken = loadToken;
11
11
  exports.clearToken = clearToken;
12
+ exports.keyFor = keyFor;
12
13
  exports.readLink = readLink;
13
14
  exports.writeLink = writeLink;
14
15
  /**
@@ -19,6 +20,7 @@ exports.writeLink = writeLink;
19
20
  * location follows each platform's convention rather than scattering dot
20
21
  * directories about.
21
22
  */
23
+ const node_fs_1 = require("node:fs");
22
24
  const promises_1 = require("node:fs/promises");
23
25
  const node_os_1 = require("node:os");
24
26
  const node_path_1 = __importDefault(require("node:path"));
@@ -75,9 +77,33 @@ exports.credentials = {
75
77
  origin: apiOrigin,
76
78
  token: loadToken,
77
79
  };
80
+ /**
81
+ * One name for one folder, whatever route was taken to reach it.
82
+ *
83
+ * A folder can be addressed as `C:\project`, through a junction, through a
84
+ * symlink, or through a mapped drive, and every one of those is a different
85
+ * string. Keying on the string gives the same folder four independent
86
+ * connections, four independent records of what it holds, and four chances
87
+ * for one to overwrite another's work. Resolving to the real path first is
88
+ * what makes them one workspace.
89
+ *
90
+ * A path that does not exist yet — a clone destination — cannot be resolved,
91
+ * so it falls back to the plain form. It becomes resolvable the moment the
92
+ * folder is created, which is before anything is ever recorded against it.
93
+ */
78
94
  function keyFor(localPath) {
79
- return node_path_1.default.resolve(localPath).toLowerCase();
95
+ const absolute = node_path_1.default.resolve(localPath);
96
+ try {
97
+ // Windows returns an extended-length path here; the prefix is an
98
+ // addressing detail, not part of the identity, so it comes back off.
99
+ return node_fs_1.realpathSync.native(absolute).replace(/^\\\\\?\\/, "").toLowerCase();
100
+ }
101
+ catch {
102
+ return absolute.toLowerCase();
103
+ }
80
104
  }
105
+ /** How the key used to be worked out, so existing links keep working. */
106
+ const legacyKeyFor = (localPath) => node_path_1.default.resolve(localPath).toLowerCase();
81
107
  async function readLinks() {
82
108
  try {
83
109
  return JSON.parse(await (0, promises_1.readFile)(linksFile(), "utf8"));
@@ -87,10 +113,14 @@ async function readLinks() {
87
113
  }
88
114
  }
89
115
  async function readLink(localPath) {
90
- return (await readLinks())[keyFor(localPath)] ?? null;
116
+ const links = await readLinks();
117
+ return links[keyFor(localPath)] ?? links[legacyKeyFor(localPath)] ?? null;
91
118
  }
92
119
  async function writeLink(localPath, link) {
93
120
  const links = await readLinks();
121
+ // A folder recorded under the old key moves to the new one rather than
122
+ // being left behind as a second connection to the same place.
123
+ delete links[legacyKeyFor(localPath)];
94
124
  links[keyFor(localPath)] = link;
95
125
  await writePrivate(linksFile(), JSON.stringify(links, null, 2));
96
126
  }
@@ -15,6 +15,45 @@ 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
+ }
36
+ /**
37
+ * Remove the directories a deletion just emptied, up to but never including
38
+ * the project folder itself. Left alone they accumulate as empty husks of
39
+ * directories the project no longer has; removed too eagerly they would take
40
+ * a directory holding ignored files with them, so this stops at the first
41
+ * one that still has something in it.
42
+ */
43
+ async function pruneEmpty(root, removed) {
44
+ let directory = node_path_1.default.dirname(removed);
45
+ while (directory.startsWith(root) && directory !== root) {
46
+ try {
47
+ if ((await (0, promises_1.readdir)(directory)).length)
48
+ return;
49
+ await (0, promises_1.rm)(directory, { recursive: false, force: true });
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ directory = node_path_1.default.dirname(directory);
55
+ }
56
+ }
18
57
  class DownloadCancelled extends Error {
19
58
  constructor() {
20
59
  super("Download cancelled");
@@ -81,11 +120,28 @@ class Downloader {
81
120
  }));
82
121
  }
83
122
  /**
84
- * Write a version into `destination`. Files land in a staging directory
85
- * and are moved into place only once every one has arrived and verified,
86
- * so a failed download never leaves a half-project behind.
123
+ * Write a version into `destination`.
124
+ *
125
+ * Files land in a staging directory and are put in place only once every
126
+ * one has arrived and verified, so a failed download never leaves a
127
+ * half-project behind.
128
+ *
129
+ * What is put in place is the version's files, one at a time — not the
130
+ * whole directory. Replacing the directory wholesale also removes
131
+ * everything the version deliberately does not contain: the dependencies,
132
+ * the local credentials, the unfinished work and the git repository
133
+ * itself. A file the version no longer holds is removed only when this
134
+ * folder is known to have received it, which `held` supplies.
87
135
  */
88
- async run(repositoryId, versionId, destination, report) {
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") {
89
145
  const files = await this.files(repositoryId, versionId);
90
146
  if (!files.length)
91
147
  throw new Error("That version has no files");
@@ -125,9 +181,56 @@ class Downloader {
125
181
  written += 1;
126
182
  bytes += body.length;
127
183
  }
128
- await (0, promises_1.mkdir)(node_path_1.default.dirname(destination), { recursive: true });
129
- await (0, promises_1.rm)(destination, { recursive: true, force: true });
130
- await (0, promises_1.rename)(staging, destination);
184
+ /*
185
+ Everything has arrived and verified, so it can go into place. Each
186
+ file is renamed over its destination individually: a rename within
187
+ one volume is atomic, so no file is ever seen half-written, and
188
+ anything the version does not mention is left exactly as it is.
189
+ */
190
+ await (0, promises_1.mkdir)(destination, { recursive: true });
191
+ for (const file of files) {
192
+ const parts = file.path.split("/").filter(Boolean);
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
+ }
206
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(target), { recursive: true });
207
+ await (0, promises_1.rm)(target, { recursive: true, force: true });
208
+ await (0, promises_1.rename)(node_path_1.default.join(staging, ...parts), target);
209
+ }
210
+ /*
211
+ A file this folder received but the version no longer holds is gone
212
+ deliberately, so it goes. One it never received is not this fetch's
213
+ to remove — and without a record of what it received, nothing is
214
+ removed at all, which is the safe reading of an unknown folder.
215
+ */
216
+ for (const [gone, digest] of Object.entries(held ?? {})) {
217
+ if (manifest[gone] !== undefined)
218
+ continue;
219
+ const parts = gone.split("/").filter(Boolean);
220
+ if (parts.some((part) => part === ".." || part.includes("\0")))
221
+ continue;
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);
232
+ }
233
+ await (0, promises_1.rm)(staging, { recursive: true, force: true });
131
234
  report({
132
235
  files: written,
133
236
  totalFiles: files.length,
@@ -0,0 +1,27 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.publishAttemptName = publishAttemptName;
4
+ /**
5
+ * A name for one publishing attempt, derived from what is being published.
6
+ *
7
+ * It has to be the same across retries of the same attempt and different for
8
+ * a genuinely new one — and it cannot be kept on disk, because the crash it
9
+ * exists to survive is exactly the kind that loses whatever was written just
10
+ * before it. Deriving it from the content solves both: the same files
11
+ * against the same head produce the same name, and anything else does not.
12
+ *
13
+ * Kept apart from the uploader because it depends on nothing, which is what
14
+ * lets it be tested without a network, a filesystem or an account.
15
+ */
16
+ const node_crypto_1 = require("node:crypto");
17
+ function publishAttemptName(input) {
18
+ const shape = [
19
+ input.repositoryId ?? "new",
20
+ input.expectedHeadVersionId ?? "none",
21
+ input.message,
22
+ // Sorted, so the order the scanner happened to walk the folder in
23
+ // cannot make the same attempt look like a different one.
24
+ ...[...input.files].map((file) => `${file.path}:${file.objectId}`).sort(),
25
+ ].join("\n");
26
+ return (0, node_crypto_1.createHash)("sha256").update(shape).digest("hex").slice(0, 40);
27
+ }
@@ -52,6 +52,19 @@ function globToSource(pattern) {
52
52
  let source = "";
53
53
  for (let index = 0; index < pattern.length; index += 1) {
54
54
  const character = pattern[index];
55
+ if (character === "\\" && index + 1 < pattern.length) {
56
+ /*
57
+ A backslash means the next character is a character, not syntax.
58
+ It is how a file genuinely called `#notes.txt` or `important!.md`
59
+ is written, and how a space is kept at the end of a name. Patterns
60
+ use forward slashes throughout, so a backslash is never a separator
61
+ here and always an escape.
62
+ */
63
+ const literal = pattern[index + 1];
64
+ source += literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
65
+ index += 1;
66
+ continue;
67
+ }
55
68
  if (character === "*") {
56
69
  if (pattern[index + 1] === "*") {
57
70
  source += ".*";
@@ -17,6 +17,7 @@ const node_fs_1 = require("node:fs");
17
17
  const promises_1 = require("node:fs/promises");
18
18
  const node_path_1 = __importDefault(require("node:path"));
19
19
  const worktree_js_1 = require("./worktree.js");
20
+ const publish_name_js_1 = require("./publish_name.js");
20
21
  /** Above this the API insists on a multipart session. */
21
22
  const DIRECT_LIMIT = 95 * 1024 * 1024;
22
23
  const PART_SIZE = 32 * 1024 * 1024;
@@ -123,7 +124,39 @@ class Uploader {
123
124
  // Ticked files are sent; everything else the project still has keeps the
124
125
  // copy already stored, and a file that is new but unticked is left out.
125
126
  const sending = everything.filter((file) => ticked.has(file.path));
126
- const reused = everything.filter((file) => !ticked.has(file.path) && prior.has(file.path));
127
+ const onDiskNow = new Set(everything.map((file) => file.path));
128
+ /*
129
+ Everything the version already holds is kept, except what this
130
+ workspace deliberately removed.
131
+
132
+ "Deliberately removed" means the workspace had the file and it is now
133
+ gone. A file it never had — because somebody else added it while this
134
+ person was working — is not theirs to delete, and must survive their
135
+ next save. Reading this from the disk scan alone is what silently lost
136
+ other people's files.
137
+ */
138
+ const knew = request.known ? new Set(Object.keys(request.known)) : null;
139
+ const reused = [...prior.entries()]
140
+ .filter(([path]) => {
141
+ if (ticked.has(path))
142
+ return false;
143
+ if (onDiskNow.has(path))
144
+ return true;
145
+ // Missing here. Only a deletion if this workspace ever had it.
146
+ return knew ? !knew.has(path) : true;
147
+ })
148
+ .map(([path, held]) => ({
149
+ path,
150
+ sourceSize: held.sourceSize,
151
+ storedSize: held.storedSize,
152
+ mediaType: held.mediaType,
153
+ added: 0,
154
+ removed: 0,
155
+ included: true,
156
+ deleted: false,
157
+ binary: false,
158
+ lines: 0,
159
+ }));
127
160
  /*
128
161
  A ticked path that the rescan cannot see is either a deletion or a file
129
162
  that has gone since the changes list was drawn. Deletions are meant to
@@ -300,6 +333,16 @@ class Uploader {
300
333
  ...(request.expectedHeadVersionId === undefined
301
334
  ? {}
302
335
  : { expectedHeadVersionId: request.expectedHeadVersionId }),
336
+ /*
337
+ Names this attempt so a retry after a lost connection is answered
338
+ with the version already made, rather than making a second one.
339
+ */
340
+ idempotencyKey: (0, publish_name_js_1.publishAttemptName)({
341
+ repositoryId,
342
+ expectedHeadVersionId: request.expectedHeadVersionId,
343
+ message: request.message,
344
+ files: contents,
345
+ }),
303
346
  sourceSize: sourceBytes,
304
347
  storedSize: storedBytes,
305
348
  files: contents.map((item) => ({
@@ -327,6 +370,7 @@ class Uploader {
327
370
  versionId: completed.version.id,
328
371
  sequence: completed.version.sequence,
329
372
  ...(completed.mergeTrack ? { mergeTrack: completed.mergeTrack } : {}),
373
+ ...(completed.repeated ? { repeated: true } : {}),
330
374
  sourceBytes,
331
375
  storedBytes,
332
376
  sentBytes,
@@ -335,6 +379,20 @@ class Uploader {
335
379
  // A file that kept its old object records the digest of *that* copy,
336
380
  // not of the file on disk, so an unticked edit is still pending next
337
381
  // time rather than looking as though it had been saved.
382
+ /*
383
+ What this folder holds after the save. A file that was sent holds
384
+ the bytes that were sent. Anything else keeps whatever digest was
385
+ recorded before: an unticked edit therefore stays pending rather
386
+ than looking saved, and a copy left stale by somebody else's merge
387
+ stays recognisably stale rather than looking like a new edit.
388
+ */
389
+ local: Object.fromEntries(everything.flatMap((file) => {
390
+ const sent = declarations.find((one) => one.path === file.path);
391
+ if (sent)
392
+ return [[file.path, sent.sha256]];
393
+ const before = request.known?.[file.path] ?? prior.get(file.path)?.sha256;
394
+ return before ? [[file.path, before]] : [];
395
+ })),
338
396
  manifest: {
339
397
  ...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
340
398
  ...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coderook/cli",
3
- "version": "0.3.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",
@@ -27,7 +27,13 @@
27
27
  "check": "tsc -p tsconfig.json --noEmit",
28
28
  "test": "tsc -p tsconfig.json && node --test --experimental-strip-types test/*.test.ts",
29
29
  "start": "node dist/cli/src/cli.js",
30
- "prepublishOnly": "npm run build"
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/attempt-identity.mjs --allow-production",
36
+ "test:getedits": "node test/get-protects-edits.mjs"
31
37
  },
32
38
  "devDependencies": {
33
39
  "@types/node": "24.10.1",