@coderook/cli 0.31.1 → 0.31.3
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.
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "coderook",
|
|
3
3
|
"displayName": "CodeRook",
|
|
4
4
|
"description": "Save, browse and restore whole-snapshot versions of a project on CodeRook, from Claude Code.",
|
|
5
|
-
"version": "0.31.
|
|
5
|
+
"version": "0.31.3",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "ACCA Gaming Productions",
|
|
8
8
|
"url": "https://coderook.com"
|
package/dist/cli/src/cli.js
CHANGED
|
@@ -18,6 +18,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
18
18
|
const promises_1 = require("node:readline/promises");
|
|
19
19
|
const node_os_1 = __importDefault(require("node:os"));
|
|
20
20
|
const promises_2 = require("node:fs/promises");
|
|
21
|
+
const node_fs_1 = require("node:fs");
|
|
21
22
|
const node_path_1 = __importDefault(require("node:path"));
|
|
22
23
|
const node_process_1 = __importDefault(require("node:process"));
|
|
23
24
|
const api_js_1 = require("./api.js");
|
|
@@ -385,6 +386,22 @@ async function commandStatus(parsed) {
|
|
|
385
386
|
`— run ${accent("cbx get")} to bring ${stale.length === 1 ? "it" : "them"} down.`));
|
|
386
387
|
for (const [path] of stale.slice(0, 10))
|
|
387
388
|
console.log(dim(` behind ${path}`));
|
|
389
|
+
/*
|
|
390
|
+
Behind, and not here at all. Usually somebody else's file that has not
|
|
391
|
+
been fetched — but it is also what a file deleted here looked like after
|
|
392
|
+
a save that did not send the deletion, because until 0.31.2 that save
|
|
393
|
+
dropped it from this folder's record. Told plainly, since the one advice
|
|
394
|
+
above would bring back a file the person meant to be rid of.
|
|
395
|
+
*/
|
|
396
|
+
const absent = stale.filter(([file]) => !(0, node_fs_1.existsSync)(node_path_1.default.join(folder, ...file.split("/"))));
|
|
397
|
+
if (absent.length) {
|
|
398
|
+
const one = absent.length === 1;
|
|
399
|
+
const which = absent.length === stale.length
|
|
400
|
+
? one ? "It is not" : "They are not"
|
|
401
|
+
: `${absent.length} of them ${one ? "is" : "are"} not`;
|
|
402
|
+
console.log(dim(`${which} in this folder at all. If you deleted ${one ? "it" : "them"} yourself, ` +
|
|
403
|
+
`run ${accent("cbx get")}, delete ${one ? "it" : "them"} again, and save with ${accent("--sync")}.`));
|
|
404
|
+
}
|
|
388
405
|
}
|
|
389
406
|
/** The deletions, and what a plain save will do about them. */
|
|
390
407
|
/*
|
|
@@ -22,6 +22,8 @@ exports.stamp = stamp;
|
|
|
22
22
|
exports.importRef = importRef;
|
|
23
23
|
exports.quotePath = quotePath;
|
|
24
24
|
exports.refPlacements = refPlacements;
|
|
25
|
+
exports.withinDepth = withinDepth;
|
|
26
|
+
exports.historyLeftOut = historyLeftOut;
|
|
25
27
|
/**
|
|
26
28
|
* Git's constant hash for the empty tree.
|
|
27
29
|
*
|
|
@@ -262,3 +264,59 @@ function refPlacements(wanted, markOfVersion) {
|
|
|
262
264
|
}
|
|
263
265
|
return { placed, unplaceable };
|
|
264
266
|
}
|
|
267
|
+
/**
|
|
268
|
+
* The versions within `depth` generations of these heads: 1 is the heads
|
|
269
|
+
* alone, 2 adds their parents, and so on.
|
|
270
|
+
*
|
|
271
|
+
* What `git clone --depth` means here. The oldest version taken is imported
|
|
272
|
+
* with no parent, so it becomes a root commit — which also means its commit id
|
|
273
|
+
* differs from the same version's id in a full clone, because a commit's id
|
|
274
|
+
* covers its parents. That is the same trade git's own shallow clones make.
|
|
275
|
+
*/
|
|
276
|
+
function withinDepth(heads, all, depth) {
|
|
277
|
+
const byId = new Map(all.map((version) => [version.id, version]));
|
|
278
|
+
const kept = new Set();
|
|
279
|
+
let level = heads.filter((id) => byId.has(id));
|
|
280
|
+
for (let generation = 0; generation < depth && level.length; generation += 1) {
|
|
281
|
+
const next = [];
|
|
282
|
+
for (const id of level) {
|
|
283
|
+
if (kept.has(id))
|
|
284
|
+
continue;
|
|
285
|
+
kept.add(id);
|
|
286
|
+
for (const parent of byId.get(id).parentVersionIds) {
|
|
287
|
+
if (byId.has(parent) && !kept.has(parent))
|
|
288
|
+
next.push(parent);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
level = next;
|
|
292
|
+
}
|
|
293
|
+
return kept;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* History an earlier shallow clone chose not to take.
|
|
297
|
+
*
|
|
298
|
+
* Every ancestor of something already imported that was not itself imported.
|
|
299
|
+
* A full clone imported every ancestor, so for it this is always empty; for a
|
|
300
|
+
* shallow one it is everything below the cut. Without it the first ordinary
|
|
301
|
+
* `git fetch` after a `--depth 1` clone would import the whole history anyway,
|
|
302
|
+
* as a second, disconnected line of commits nobody asked for.
|
|
303
|
+
*/
|
|
304
|
+
function historyLeftOut(all, imported) {
|
|
305
|
+
const byId = new Map(all.map((version) => [version.id, version]));
|
|
306
|
+
const have = new Set(imported);
|
|
307
|
+
const left = new Set();
|
|
308
|
+
const pending = [];
|
|
309
|
+
for (const id of have) {
|
|
310
|
+
for (const parent of byId.get(id)?.parentVersionIds ?? [])
|
|
311
|
+
pending.push(parent);
|
|
312
|
+
}
|
|
313
|
+
while (pending.length) {
|
|
314
|
+
const id = pending.pop();
|
|
315
|
+
if (have.has(id) || left.has(id) || !byId.has(id))
|
|
316
|
+
continue;
|
|
317
|
+
left.add(id);
|
|
318
|
+
for (const parent of byId.get(id).parentVersionIds)
|
|
319
|
+
pending.push(parent);
|
|
320
|
+
}
|
|
321
|
+
return left;
|
|
322
|
+
}
|
|
@@ -544,8 +544,14 @@ async function recordPushedCommits(marks) {
|
|
|
544
544
|
first and an empty window always accepts one more.
|
|
545
545
|
*/
|
|
546
546
|
const BLOB_LANES = 8;
|
|
547
|
+
/** Fewer new files than this, and one request for them each is quicker. */
|
|
548
|
+
const ARCHIVE_MIN_FILES = 64;
|
|
549
|
+
/** The share of a version's bytes that must be new before it is fetched whole. */
|
|
550
|
+
const ARCHIVE_MIN_SHARE = 0.5;
|
|
547
551
|
const BLOB_WINDOW_BYTES = 64 * 1024 * 1024;
|
|
548
|
-
async function doImport(refs, url
|
|
552
|
+
async function doImport(refs, url,
|
|
553
|
+
/** From `git clone --depth N`; only honoured on a first import. */
|
|
554
|
+
depth = null) {
|
|
549
555
|
const { owner, slug } = (0, git_history_js_1.parseRemoteUrl)(url);
|
|
550
556
|
const project = await (0, api_js_1.findProject)(owner ? `${owner}/${slug}` : slug);
|
|
551
557
|
if (!project) {
|
|
@@ -593,6 +599,26 @@ async function doImport(refs, url) {
|
|
|
593
599
|
let nextMark = 1;
|
|
594
600
|
for (const mark of commitMark.values())
|
|
595
601
|
nextMark = Math.max(nextMark, mark + 1);
|
|
602
|
+
/*
|
|
603
|
+
What this import takes, out of everything the heads can reach.
|
|
604
|
+
|
|
605
|
+
Never history an earlier shallow clone left out: its first ordinary fetch
|
|
606
|
+
would otherwise bring the lot in as a second line of commits. And on a
|
|
607
|
+
first import with `--depth`, only that many generations of each line.
|
|
608
|
+
*/
|
|
609
|
+
const leftOut = (0, git_history_js_1.historyLeftOut)(all, commitMark.keys());
|
|
610
|
+
let chosen = order.filter((version) => !leftOut.has(version.id));
|
|
611
|
+
if (depth && depth > 0) {
|
|
612
|
+
if (commitMark.size) {
|
|
613
|
+
say(` --depth applies to a new clone; fetching everything newer than what is here.`);
|
|
614
|
+
}
|
|
615
|
+
else {
|
|
616
|
+
const keep = (0, git_history_js_1.withinDepth)([...wanted.values()], all, depth);
|
|
617
|
+
chosen = chosen.filter((version) => keep.has(version.id));
|
|
618
|
+
say(`Taking the newest ${depth} version${depth === 1 ? "" : "s"} of each line ` +
|
|
619
|
+
`(--depth ${depth}); ${order.length - chosen.length} older left out.`);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
596
622
|
/*
|
|
597
623
|
Point every ref git asked for at the commit its line's head became.
|
|
598
624
|
|
|
@@ -610,7 +636,7 @@ async function doImport(refs, url) {
|
|
|
610
636
|
say(` could not place ${ref}: no commit for its head version`);
|
|
611
637
|
}
|
|
612
638
|
};
|
|
613
|
-
const fresh =
|
|
639
|
+
const fresh = chosen.filter((version) => !commitMark.has(version.id));
|
|
614
640
|
if (!fresh.length) {
|
|
615
641
|
say(`Already up to date with ${slug}.`);
|
|
616
642
|
/*
|
|
@@ -631,8 +657,8 @@ async function doImport(refs, url) {
|
|
|
631
657
|
return;
|
|
632
658
|
}
|
|
633
659
|
say(`Importing ${fresh.length} version${fresh.length === 1 ? "" : "s"} from ${slug}` +
|
|
634
|
-
(
|
|
635
|
-
? ` (${
|
|
660
|
+
(chosen.length > fresh.length
|
|
661
|
+
? ` (${chosen.length - fresh.length} already here)`
|
|
636
662
|
: "") +
|
|
637
663
|
".");
|
|
638
664
|
const { Downloader } = await Promise.resolve().then(() => __importStar(require("../../desktop-app/src/main/download.js")));
|
|
@@ -667,41 +693,7 @@ async function doImport(refs, url) {
|
|
|
667
693
|
bytes together, because sixteen source files and sixteen video files
|
|
668
694
|
are not the same amount to be holding at once.
|
|
669
695
|
*/
|
|
670
|
-
/*
|
|
671
|
-
The whole version in one request, where that works.
|
|
672
|
-
|
|
673
|
-
Fetching per file is one round trip each: 21,071 of them on a real
|
|
674
|
-
Unity project, which is four hours in series and about half an hour
|
|
675
|
-
even eight at a time. The archive is the same bytes in one request.
|
|
676
|
-
|
|
677
|
-
Anything unexpected about it — an entry this cannot read, a short
|
|
678
|
-
download, a service that does not offer the route — falls through to
|
|
679
|
-
the per-file path below, which is slow and known to work. A clone
|
|
680
|
-
that is slow is a nuisance; a clone that writes the wrong bytes is
|
|
681
|
-
not recoverable by trying again.
|
|
682
|
-
*/
|
|
683
|
-
const fromArchive = new Map();
|
|
684
|
-
try {
|
|
685
|
-
const packed = await downloader.archive(repositoryId, version.id);
|
|
686
|
-
for (const entry of (0, stored_zip_js_1.readStoredZip)(packed)) {
|
|
687
|
-
fromArchive.set(entry.path, entry.bytes);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
catch (error) {
|
|
691
|
-
/*
|
|
692
|
-
Said out loud, not swallowed.
|
|
693
|
-
|
|
694
|
-
This fell back silently, and the silence cost an hour: a deadline
|
|
695
|
-
meant for ordinary requests was aborting the archive, the import
|
|
696
|
-
dropped to fetching every file on its own, and from the outside it
|
|
697
|
-
looked exactly like the slow clone the archive was added to fix.
|
|
698
|
-
A fallback nobody can see is a fallback nobody can diagnose.
|
|
699
|
-
*/
|
|
700
|
-
say(` the archive for v${version.sequence} could not be used ` +
|
|
701
|
-
`(${error instanceof Error ? error.message : String(error)}); ` +
|
|
702
|
-
`fetching its files one by one`);
|
|
703
|
-
fromArchive.clear();
|
|
704
|
-
}
|
|
696
|
+
/* Only content not already imported: a blob is written once, by digest. */
|
|
705
697
|
const seenBlob = new Set();
|
|
706
698
|
const wantedBlobs = files.filter((file) => {
|
|
707
699
|
if (blobMark.has(file.sha256) || seenBlob.has(file.sha256))
|
|
@@ -709,6 +701,57 @@ async function doImport(refs, url) {
|
|
|
709
701
|
seenBlob.add(file.sha256);
|
|
710
702
|
return true;
|
|
711
703
|
});
|
|
704
|
+
/*
|
|
705
|
+
The whole version in one request — but only when most of it is new.
|
|
706
|
+
|
|
707
|
+
The archive is every file the version holds, and this used to fetch it
|
|
708
|
+
for every version and then keep only the files not already imported.
|
|
709
|
+
After the first, that is nearly none of them: a five-version clone of
|
|
710
|
+
a large project downloaded its whole snapshot five times over to use
|
|
711
|
+
the changed files out of each, which is why an archive added to speed
|
|
712
|
+
clones up left one taking eighty-two minutes.
|
|
713
|
+
|
|
714
|
+
So it is weighed per version: where the files still wanted are at
|
|
715
|
+
least half of the version's bytes, one request for the lot wins; where
|
|
716
|
+
they are a handful, fetching just those does. Below a few dozen files
|
|
717
|
+
the round trips are not worth avoiding at all.
|
|
718
|
+
|
|
719
|
+
Where it is worth it, fetching per file would be one round trip each:
|
|
720
|
+
21,071 of them on a real Unity project, four hours in series and about
|
|
721
|
+
half an hour even eight at a time.
|
|
722
|
+
|
|
723
|
+
Anything unexpected about the archive — an entry this cannot read, a
|
|
724
|
+
short download, a service that does not offer the route — falls
|
|
725
|
+
through to the per-file path below, which is slow and known to work.
|
|
726
|
+
A clone that is slow is a nuisance; a clone that writes the wrong bytes
|
|
727
|
+
is not recoverable by trying again.
|
|
728
|
+
*/
|
|
729
|
+
const bytesOf = (list) => list.reduce((sum, file) => sum + Number(file.sourceSize ?? 0), 0);
|
|
730
|
+
const archiveWorthIt = wantedBlobs.length >= ARCHIVE_MIN_FILES &&
|
|
731
|
+
bytesOf(wantedBlobs) >= bytesOf(files) * ARCHIVE_MIN_SHARE;
|
|
732
|
+
const fromArchive = new Map();
|
|
733
|
+
if (archiveWorthIt)
|
|
734
|
+
try {
|
|
735
|
+
const packed = await downloader.archive(repositoryId, version.id);
|
|
736
|
+
for (const entry of (0, stored_zip_js_1.readStoredZip)(packed)) {
|
|
737
|
+
fromArchive.set(entry.path, entry.bytes);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
catch (error) {
|
|
741
|
+
/*
|
|
742
|
+
Said out loud, not swallowed.
|
|
743
|
+
|
|
744
|
+
This fell back silently, and the silence cost an hour: a deadline
|
|
745
|
+
meant for ordinary requests was aborting the archive, the import
|
|
746
|
+
dropped to fetching every file on its own, and from the outside it
|
|
747
|
+
looked exactly like the slow clone the archive was added to fix.
|
|
748
|
+
A fallback nobody can see is a fallback nobody can diagnose.
|
|
749
|
+
*/
|
|
750
|
+
say(` the archive for v${version.sequence} could not be used ` +
|
|
751
|
+
`(${error instanceof Error ? error.message : String(error)}); ` +
|
|
752
|
+
`fetching its files one by one`);
|
|
753
|
+
fromArchive.clear();
|
|
754
|
+
}
|
|
712
755
|
for (let at = 0; at < wantedBlobs.length;) {
|
|
713
756
|
const window = [];
|
|
714
757
|
let held = 0;
|
|
@@ -1393,6 +1436,8 @@ async function main(argv) {
|
|
|
1393
1436
|
clone of a project you cannot see left an empty folder and exited zero.
|
|
1394
1437
|
*/
|
|
1395
1438
|
let unservable = false;
|
|
1439
|
+
/** `git clone --depth N`, when git passed one. */
|
|
1440
|
+
let depth = null;
|
|
1396
1441
|
for await (const line of lines()) {
|
|
1397
1442
|
const command = line.trim();
|
|
1398
1443
|
if (command === "capabilities") {
|
|
@@ -1406,9 +1451,19 @@ async function main(argv) {
|
|
|
1406
1451
|
continue;
|
|
1407
1452
|
}
|
|
1408
1453
|
if (command.startsWith("option ")) {
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1454
|
+
/*
|
|
1455
|
+
Only what is actually acted on is accepted: answering "ok" to anything
|
|
1456
|
+
else would make git believe a setting took effect. Depth is honoured
|
|
1457
|
+
by importing fewer versions; git passes it for `clone --depth`.
|
|
1458
|
+
*/
|
|
1459
|
+
const [, name, value] = command.split(" ");
|
|
1460
|
+
if (name === "depth" && /^[1-9][0-9]*$/.test(value ?? "")) {
|
|
1461
|
+
depth = Number(value);
|
|
1462
|
+
send("ok");
|
|
1463
|
+
}
|
|
1464
|
+
else {
|
|
1465
|
+
send("unsupported");
|
|
1466
|
+
}
|
|
1412
1467
|
continue;
|
|
1413
1468
|
}
|
|
1414
1469
|
if (command === "list" || command === "list for-push") {
|
|
@@ -1521,7 +1576,7 @@ async function main(argv) {
|
|
|
1521
1576
|
if (importing.length) {
|
|
1522
1577
|
const batch = importing.splice(0, importing.length);
|
|
1523
1578
|
try {
|
|
1524
|
-
await doImport(batch, url);
|
|
1579
|
+
await doImport(batch, url, depth);
|
|
1525
1580
|
}
|
|
1526
1581
|
catch (error) {
|
|
1527
1582
|
/*
|
|
@@ -42,11 +42,14 @@ function readStoredZip(archive) {
|
|
|
42
42
|
const names = new TextDecoder("utf-8", { fatal: false });
|
|
43
43
|
const entries = [];
|
|
44
44
|
let at = 0;
|
|
45
|
+
let ended = false;
|
|
45
46
|
while (at + 4 <= archive.length) {
|
|
46
47
|
const signature = view.getUint32(at, true);
|
|
47
48
|
/* Either record marks the end of the entries. */
|
|
48
|
-
if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY)
|
|
49
|
+
if (signature === CENTRAL_HEADER || signature === END_OF_DIRECTORY) {
|
|
50
|
+
ended = true;
|
|
49
51
|
break;
|
|
52
|
+
}
|
|
50
53
|
if (signature !== LOCAL_HEADER) {
|
|
51
54
|
throw new Error(`Not a stored archive: unexpected record at byte ${at}`);
|
|
52
55
|
}
|
|
@@ -81,5 +84,42 @@ function readStoredZip(archive) {
|
|
|
81
84
|
});
|
|
82
85
|
at = end;
|
|
83
86
|
}
|
|
87
|
+
/*
|
|
88
|
+
A zip is only whole once its directory arrives.
|
|
89
|
+
|
|
90
|
+
Running out of bytes exactly between two entries used to read as the end
|
|
91
|
+
of the archive. It is also exactly what a download cut off at a file
|
|
92
|
+
boundary looks like — and the service's archive of a 9,010-file version
|
|
93
|
+
was being cut off, by a Worker limit, after 4,978 of them. Every one of
|
|
94
|
+
those read back perfectly, so the clone took them and fetched the other
|
|
95
|
+
4,032 one at a time without a word, which is why it took half an hour.
|
|
96
|
+
Now the directory has to be there, and has to count what was read.
|
|
97
|
+
*/
|
|
98
|
+
if (!ended) {
|
|
99
|
+
throw new Error(`Archive was cut off after ${entries.length} entries: it has no directory`);
|
|
100
|
+
}
|
|
101
|
+
const counted = directoryCount(view);
|
|
102
|
+
if (counted === null) {
|
|
103
|
+
throw new Error("Archive has no end-of-directory record");
|
|
104
|
+
}
|
|
105
|
+
if (counted !== entries.length) {
|
|
106
|
+
throw new Error(`Archive says it holds ${counted} entries and ${entries.length} were read`);
|
|
107
|
+
}
|
|
84
108
|
return entries;
|
|
85
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* How many entries the archive's end-of-directory record says it holds.
|
|
112
|
+
*
|
|
113
|
+
* Searched for from the end, as the format intends: the record is the last
|
|
114
|
+
* thing in the file, followed only by a comment of up to 64 KB.
|
|
115
|
+
*/
|
|
116
|
+
function directoryCount(view) {
|
|
117
|
+
const last = view.byteLength - 22;
|
|
118
|
+
const first = Math.max(0, last - 0xffff);
|
|
119
|
+
for (let at = last; at >= first; at -= 1) {
|
|
120
|
+
if (view.getUint32(at, true) === END_OF_DIRECTORY) {
|
|
121
|
+
return view.getUint16(at + 10, true);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
@@ -915,6 +915,53 @@ class Uploader {
|
|
|
915
915
|
if (!completed.mergeTrack && !Object.keys(acceptedManifest).length) {
|
|
916
916
|
acceptedManifest = Object.fromEntries([...(await this.versionFiles(repositoryId, completed.version.id))].map(([file, descriptor]) => [file, descriptor.sha256]));
|
|
917
917
|
}
|
|
918
|
+
const manifest = Object.keys(acceptedManifest).length
|
|
919
|
+
? acceptedManifest
|
|
920
|
+
: {
|
|
921
|
+
...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
|
|
922
|
+
...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
|
|
923
|
+
};
|
|
924
|
+
/*
|
|
925
|
+
What this folder holds after the save. A file that was sent holds
|
|
926
|
+
the bytes that were sent. Anything else keeps whatever digest was
|
|
927
|
+
recorded before: an unticked edit therefore stays pending rather
|
|
928
|
+
than looking saved, and a copy left stale by somebody else's merge
|
|
929
|
+
stays recognisably stale rather than looking like a new edit.
|
|
930
|
+
|
|
931
|
+
Looked up in a map rather than by searching the declarations for each
|
|
932
|
+
file, which on a 47,000-file project was over a billion comparisons at
|
|
933
|
+
the very end of a save.
|
|
934
|
+
*/
|
|
935
|
+
const sentDigest = new Map(declarations.map((declaration) => [declaration.path, declaration.sha256]));
|
|
936
|
+
const onDisk = new Set(everything.map((file) => file.path));
|
|
937
|
+
const local = Object.fromEntries(everything.flatMap((file) => {
|
|
938
|
+
const sent = sentDigest.get(file.path);
|
|
939
|
+
if (sent)
|
|
940
|
+
return [[file.path, sent]];
|
|
941
|
+
const before = request.known?.[file.path] ?? prior.get(file.path)?.sha256;
|
|
942
|
+
return before ? [[file.path, before]] : [];
|
|
943
|
+
}));
|
|
944
|
+
/*
|
|
945
|
+
And a file deleted here that this save carried forward keeps its place.
|
|
946
|
+
|
|
947
|
+
A plain save adds and updates, so a file somebody deleted stays in the
|
|
948
|
+
version. It used to vanish from this record all the same, because the
|
|
949
|
+
record was built only from what is on disk — and the next status then
|
|
950
|
+
found the version holding a file this folder did not, called it
|
|
951
|
+
"behind", and offered to fetch it back. The person had deleted it; the
|
|
952
|
+
only thing that brought it back was being told to run `cbx get`.
|
|
953
|
+
|
|
954
|
+
Only where the record already had it. A file that was never here — one
|
|
955
|
+
somebody else's save merged into the version — has no entry to keep, so
|
|
956
|
+
it stays "behind", which is exactly what it is.
|
|
957
|
+
*/
|
|
958
|
+
for (const path of Object.keys(manifest)) {
|
|
959
|
+
if (onDisk.has(path))
|
|
960
|
+
continue;
|
|
961
|
+
const held = request.known?.[path];
|
|
962
|
+
if (held)
|
|
963
|
+
local[path] = held;
|
|
964
|
+
}
|
|
918
965
|
report({
|
|
919
966
|
stage: "done",
|
|
920
967
|
files: declarations.length,
|
|
@@ -940,29 +987,8 @@ class Uploader {
|
|
|
940
987
|
/** Selected, but the service already held the content. */
|
|
941
988
|
alreadyStoredFiles: progress.alreadyOnAccount,
|
|
942
989
|
telemetry: this.telemetry.snapshot(),
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
// time rather than looking as though it had been saved.
|
|
946
|
-
/*
|
|
947
|
-
What this folder holds after the save. A file that was sent holds
|
|
948
|
-
the bytes that were sent. Anything else keeps whatever digest was
|
|
949
|
-
recorded before: an unticked edit therefore stays pending rather
|
|
950
|
-
than looking saved, and a copy left stale by somebody else's merge
|
|
951
|
-
stays recognisably stale rather than looking like a new edit.
|
|
952
|
-
*/
|
|
953
|
-
local: Object.fromEntries(everything.flatMap((file) => {
|
|
954
|
-
const sent = declarations.find((one) => one.path === file.path);
|
|
955
|
-
if (sent)
|
|
956
|
-
return [[file.path, sent.sha256]];
|
|
957
|
-
const before = request.known?.[file.path] ?? prior.get(file.path)?.sha256;
|
|
958
|
-
return before ? [[file.path, before]] : [];
|
|
959
|
-
})),
|
|
960
|
-
manifest: Object.keys(acceptedManifest).length
|
|
961
|
-
? acceptedManifest
|
|
962
|
-
: {
|
|
963
|
-
...Object.fromEntries(reused.map((file) => [file.path, prior.get(file.path).sha256])),
|
|
964
|
-
...Object.fromEntries(declarations.map((declaration) => [declaration.path, declaration.sha256])),
|
|
965
|
-
},
|
|
990
|
+
local,
|
|
991
|
+
manifest,
|
|
966
992
|
};
|
|
967
993
|
}
|
|
968
994
|
/**
|
|
@@ -1452,7 +1478,50 @@ class Uploader {
|
|
|
1452
1478
|
Read once, here. Everything below works from the declarations rather
|
|
1453
1479
|
than from the disk.
|
|
1454
1480
|
*/
|
|
1455
|
-
const
|
|
1481
|
+
const hashed = await this.hashSelection(request, sending, deletions, readFailures, vanished, report);
|
|
1482
|
+
/*
|
|
1483
|
+
A file that is exactly what the last version held is kept, not sent.
|
|
1484
|
+
|
|
1485
|
+
Both callers select everything — the command line says so with an empty
|
|
1486
|
+
exclusion list, because its list of changes stops at twenty thousand —
|
|
1487
|
+
and a selected file used to be a file to send. Most of a project is
|
|
1488
|
+
small files, and small files are stored inside packs, so the service
|
|
1489
|
+
cannot recognise one as already held; the whole lot was packed again and
|
|
1490
|
+
sent again on every save. CodeBox's own saves sent 3.1 MB across 1,025
|
|
1491
|
+
files to record a change to eight.
|
|
1492
|
+
|
|
1493
|
+
The digest has just been taken, so the comparison is exact: same path,
|
|
1494
|
+
same bytes, and the previous version's object for them — a pack slice,
|
|
1495
|
+
its chunks, or a plain object — is named again instead.
|
|
1496
|
+
*/
|
|
1497
|
+
const keptAsItWas = (declaration) => {
|
|
1498
|
+
const was = prior.get(declaration.path);
|
|
1499
|
+
return (!!was &&
|
|
1500
|
+
was.sha256 === declaration.sha256 &&
|
|
1501
|
+
!deletions.has(declaration.path) &&
|
|
1502
|
+
!!(was.pack?.objectId || was.chunks?.length || was.objectId));
|
|
1503
|
+
};
|
|
1504
|
+
const declarations = hashed.declarations.filter((one) => !keptAsItWas(one));
|
|
1505
|
+
let totalBytes = hashed.totalBytes;
|
|
1506
|
+
for (const one of hashed.declarations) {
|
|
1507
|
+
if (!keptAsItWas(one))
|
|
1508
|
+
continue;
|
|
1509
|
+
totalBytes -= one.size;
|
|
1510
|
+
const held = prior.get(one.path);
|
|
1511
|
+
reused.push({
|
|
1512
|
+
path: one.path,
|
|
1513
|
+
sourceSize: held.sourceSize,
|
|
1514
|
+
storedSize: held.storedSize,
|
|
1515
|
+
mediaType: held.mediaType,
|
|
1516
|
+
added: 0,
|
|
1517
|
+
removed: 0,
|
|
1518
|
+
included: true,
|
|
1519
|
+
deleted: false,
|
|
1520
|
+
binary: false,
|
|
1521
|
+
lines: 0,
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
(0, profile_js_1.counted)("files kept as they were", hashed.declarations.length - declarations.length);
|
|
1456
1525
|
// 2. The project needs somewhere on the account to live.
|
|
1457
1526
|
const repositoryId = await this.ensureRepository(request);
|
|
1458
1527
|
// Learn the wire contract once, before the two section pipelines begin.
|