@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.
package/dist/cli/src/cli.js
CHANGED
|
@@ -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
|
-
|
|
426
|
-
|
|
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]
|
|
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
|
|
179
|
-
if (manifest[
|
|
216
|
+
for (const [gone, digest] of Object.entries(held ?? {})) {
|
|
217
|
+
if (manifest[gone] !== undefined)
|
|
180
218
|
continue;
|
|
181
|
-
const parts =
|
|
219
|
+
const parts = gone.split("/").filter(Boolean);
|
|
182
220
|
if (parts.some((part) => part === ".." || part.includes("\0")))
|
|
183
221
|
continue;
|
|
184
|
-
|
|
185
|
-
|
|
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.
|
|
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",
|