@muhkoo/connect 0.11.0-alpha.0 → 0.12.0-alpha.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/browser/index.js +941 -17
- package/dist/browser/index.js.map +1 -1
- package/dist/connect.d.ts +421 -3
- package/dist/server/index.js +941 -17
- package/dist/server/index.js.map +1 -1
- package/package.json +1 -1
package/dist/server/index.js
CHANGED
|
@@ -3235,7 +3235,7 @@ function base64UrlDecode(s) {
|
|
|
3235
3235
|
* expect (and isn't serving a stale cached bundle). Keep this in sync with
|
|
3236
3236
|
* `package.json` `version`.
|
|
3237
3237
|
*/
|
|
3238
|
-
const VERSION = "0.
|
|
3238
|
+
const VERSION = "0.12.0-alpha.0";
|
|
3239
3239
|
|
|
3240
3240
|
/**
|
|
3241
3241
|
* `HttpClient` — the one place the unified {@link Client} talks to the
|
|
@@ -11655,6 +11655,16 @@ function newId() {
|
|
|
11655
11655
|
return toBase64(randomBytes(16)).replace(/[+/=]/g, "").slice(0, 22);
|
|
11656
11656
|
}
|
|
11657
11657
|
/** The root key, derived from the master seed. The only key not stored in a parent. */
|
|
11658
|
+
/**
|
|
11659
|
+
* The key a project's repository objects are sealed with.
|
|
11660
|
+
*
|
|
11661
|
+
* Per project, not one key for everything: commits, trees and refs describe a
|
|
11662
|
+
* single project's history, and deriving per slug keeps that blast radius small
|
|
11663
|
+
* if a key is ever shared to collaborate on one project.
|
|
11664
|
+
*/
|
|
11665
|
+
async function deriveRepoKey(seed, slug) {
|
|
11666
|
+
return deriveBitsHkdf(seed, `${VFS_ROOT_INFO}:vcs:${slug}`, AES_KEY_BYTES);
|
|
11667
|
+
}
|
|
11658
11668
|
async function deriveRootKey(seed) {
|
|
11659
11669
|
return deriveBitsHkdf(seed, VFS_ROOT_INFO, AES_KEY_BYTES);
|
|
11660
11670
|
}
|
|
@@ -11967,9 +11977,18 @@ class VfsNamespace {
|
|
|
11967
11977
|
return;
|
|
11968
11978
|
if (!frame.key.startsWith("vfs/"))
|
|
11969
11979
|
return; // someone else's data on the shared feed
|
|
11970
|
-
//
|
|
11971
|
-
//
|
|
11972
|
-
|
|
11980
|
+
// Drop only the record that changed.
|
|
11981
|
+
//
|
|
11982
|
+
// Clearing everything looked cheap and is not: our own writes echo
|
|
11983
|
+
// back, so a sync that writes a few hundred files emptied the cache
|
|
11984
|
+
// a few hundred times, and every path resolution in flight went back
|
|
11985
|
+
// to the network for the root and every directory below it. The key
|
|
11986
|
+
// names the record, so there is no need to guess.
|
|
11987
|
+
const id = frame.key.startsWith("vfs/d/") ? frame.key.slice("vfs/d/".length) : null;
|
|
11988
|
+
if (id)
|
|
11989
|
+
this.cache.delete(id);
|
|
11990
|
+
else
|
|
11991
|
+
this.cache.clear(); // a shape we do not recognise: be safe
|
|
11973
11992
|
handler();
|
|
11974
11993
|
});
|
|
11975
11994
|
}
|
|
@@ -12017,6 +12036,68 @@ class VfsNamespace {
|
|
|
12017
12036
|
async readText(path) {
|
|
12018
12037
|
return new TextDecoder().decode(await this.readFile(path));
|
|
12019
12038
|
}
|
|
12039
|
+
/**
|
|
12040
|
+
* A file's manifest — the handle itself, without reading the bytes.
|
|
12041
|
+
*
|
|
12042
|
+
* Version control records manifests rather than content, so a commit costs
|
|
12043
|
+
* a hash and a reference, not a copy.
|
|
12044
|
+
*/
|
|
12045
|
+
async statManifest(path) {
|
|
12046
|
+
const entry = await this.expectFile(path);
|
|
12047
|
+
return { manifest: entry.manifest, size: entry.size };
|
|
12048
|
+
}
|
|
12049
|
+
/**
|
|
12050
|
+
* Read content by its manifest, wherever it came from.
|
|
12051
|
+
*
|
|
12052
|
+
* A merge needs the COMMON ANCESTOR's version of a file, which by definition
|
|
12053
|
+
* is not in the working tree any more. The manifest is the whole capability,
|
|
12054
|
+
* so no path lookup is involved.
|
|
12055
|
+
*/
|
|
12056
|
+
async readByManifest(manifest) {
|
|
12057
|
+
return this.deps.content.read(manifest);
|
|
12058
|
+
}
|
|
12059
|
+
/**
|
|
12060
|
+
* Pin content that something other than the working tree depends on.
|
|
12061
|
+
*
|
|
12062
|
+
* A commit references a manifest, so it is an OWNER of that content — without
|
|
12063
|
+
* a reference of its own, deleting the file from the working tree would free
|
|
12064
|
+
* shards the history still needs, and checking out an older commit would find
|
|
12065
|
+
* the content gone. (It did, in test.)
|
|
12066
|
+
*/
|
|
12067
|
+
async retainManifest(manifest) {
|
|
12068
|
+
await this.deps.content.retain(manifest);
|
|
12069
|
+
}
|
|
12070
|
+
/**
|
|
12071
|
+
* Point a path at content that already exists.
|
|
12072
|
+
*
|
|
12073
|
+
* This is how a checkout restores a file: the bytes are in the shard store
|
|
12074
|
+
* already, so nothing is uploaded or downloaded — only the directory record
|
|
12075
|
+
* changes. Takes a reference, because a second path now depends on those
|
|
12076
|
+
* shards.
|
|
12077
|
+
*/
|
|
12078
|
+
async writeManifest(path, manifest, size) {
|
|
12079
|
+
const target = this.resolve(path);
|
|
12080
|
+
const name = basename(target);
|
|
12081
|
+
assertValidName(name);
|
|
12082
|
+
await this.mkdir(dirname(target), { recursive: true });
|
|
12083
|
+
const parent = await this.openDir(dirname(target));
|
|
12084
|
+
const prior = parent.node.entries[name];
|
|
12085
|
+
if (prior?.kind === "dir")
|
|
12086
|
+
throw new VfsConflictError(`${target} is a directory`);
|
|
12087
|
+
await this.deps.content.retain(manifest);
|
|
12088
|
+
const entry = {
|
|
12089
|
+
kind: "file",
|
|
12090
|
+
id: prior?.id ?? newId(),
|
|
12091
|
+
manifest,
|
|
12092
|
+
size,
|
|
12093
|
+
mtime: Date.now(),
|
|
12094
|
+
versions: prior?.versions ?? 0,
|
|
12095
|
+
};
|
|
12096
|
+
await this.mutate(parent, (node) => {
|
|
12097
|
+
node.entries[name] = entry;
|
|
12098
|
+
});
|
|
12099
|
+
return toStat(target, name, entry);
|
|
12100
|
+
}
|
|
12020
12101
|
// ---- writes -------------------------------------------------------------
|
|
12021
12102
|
/**
|
|
12022
12103
|
* Create a directory. `recursive` creates missing parents and tolerates an
|
|
@@ -12079,7 +12160,14 @@ class VfsNamespace {
|
|
|
12079
12160
|
});
|
|
12080
12161
|
return toStat(target, name, entry);
|
|
12081
12162
|
}
|
|
12082
|
-
/**
|
|
12163
|
+
/**
|
|
12164
|
+
* Delete a file, or a directory when `recursive`.
|
|
12165
|
+
*
|
|
12166
|
+
* `keepContent` removes the entry WITHOUT releasing its shards. A checkout
|
|
12167
|
+
* needs that: the file is leaving the working tree, but commits still
|
|
12168
|
+
* reference the same manifest, and releasing would free content history
|
|
12169
|
+
* depends on.
|
|
12170
|
+
*/
|
|
12083
12171
|
async delete(path, opts = {}) {
|
|
12084
12172
|
const target = this.resolve(path);
|
|
12085
12173
|
if (target === "/")
|
|
@@ -12096,6 +12184,9 @@ class VfsNamespace {
|
|
|
12096
12184
|
throw new VfsConflictError(`${target} is not empty`);
|
|
12097
12185
|
await this.purge(child);
|
|
12098
12186
|
}
|
|
12187
|
+
else if (opts.keepContent) {
|
|
12188
|
+
await this.deps.store.delete(historyKey(entry.id)).catch(() => { });
|
|
12189
|
+
}
|
|
12099
12190
|
else {
|
|
12100
12191
|
await this.releaseFile(parent, entry);
|
|
12101
12192
|
}
|
|
@@ -12320,25 +12411,41 @@ class VfsNamespace {
|
|
|
12320
12411
|
}
|
|
12321
12412
|
return dir;
|
|
12322
12413
|
}
|
|
12323
|
-
|
|
12414
|
+
openDirById(id, key) {
|
|
12324
12415
|
const hit = this.cache.get(id);
|
|
12325
12416
|
if (hit)
|
|
12326
12417
|
return hit;
|
|
12327
|
-
|
|
12328
|
-
//
|
|
12329
|
-
//
|
|
12330
|
-
//
|
|
12331
|
-
|
|
12332
|
-
|
|
12333
|
-
|
|
12334
|
-
|
|
12418
|
+
// The PROMISE goes in the cache, not the resolved value.
|
|
12419
|
+
//
|
|
12420
|
+
// Reading a project means resolving hundreds of paths that share the
|
|
12421
|
+
// same handful of directories. Caching only the settled value leaves
|
|
12422
|
+
// every concurrent reader to miss and fire its own request for the same
|
|
12423
|
+
// record — which is how loading one project turned into thousands of
|
|
12424
|
+
// identical `vfs/d/<id>` fetches, most of them in flight at once.
|
|
12425
|
+
const pending = (async () => {
|
|
12426
|
+
const raw = await this.deps.store.get(dirKey(id));
|
|
12427
|
+
// An absent record is an EMPTY directory, not an error: the root
|
|
12428
|
+
// exists before anything has been written to it, and a directory is
|
|
12429
|
+
// created by its parent naming it rather than by its own record
|
|
12430
|
+
// appearing.
|
|
12431
|
+
const node = (await unseal(key, raw)) ?? { v: 1, entries: {}, mtime: Date.now() };
|
|
12432
|
+
return { id, key, node };
|
|
12433
|
+
})().catch((err) => {
|
|
12434
|
+
// A failed read must not be remembered, or one blip poisons the
|
|
12435
|
+
// directory for the life of the session.
|
|
12436
|
+
if (this.cache.get(id) === pending)
|
|
12437
|
+
this.cache.delete(id);
|
|
12438
|
+
throw err;
|
|
12439
|
+
});
|
|
12440
|
+
this.cache.set(id, pending);
|
|
12441
|
+
return pending;
|
|
12335
12442
|
}
|
|
12336
12443
|
/** Apply an edit to a directory record and persist it. */
|
|
12337
12444
|
async mutate(dir, edit) {
|
|
12338
12445
|
edit(dir.node);
|
|
12339
12446
|
dir.node.mtime = Date.now();
|
|
12340
12447
|
await this.deps.store.put(dirKey(dir.id), await seal(dir.key, dir.node));
|
|
12341
|
-
this.cache.set(dir.id, dir);
|
|
12448
|
+
this.cache.set(dir.id, Promise.resolve(dir));
|
|
12342
12449
|
}
|
|
12343
12450
|
async createDir(parentPath, name) {
|
|
12344
12451
|
assertValidName(name);
|
|
@@ -12381,7 +12488,7 @@ class VfsNamespace {
|
|
|
12381
12488
|
}
|
|
12382
12489
|
const open = { id: clone.id, key: fromBase64(clone.key), node };
|
|
12383
12490
|
await this.deps.store.put(dirKey(open.id), await seal(open.key, node));
|
|
12384
|
-
this.cache.set(open.id, open);
|
|
12491
|
+
this.cache.set(open.id, Promise.resolve(open));
|
|
12385
12492
|
return clone;
|
|
12386
12493
|
}
|
|
12387
12494
|
/**
|
|
@@ -12433,6 +12540,812 @@ function byDirsThenName(a, b) {
|
|
|
12433
12540
|
return a.name.localeCompare(b.name);
|
|
12434
12541
|
}
|
|
12435
12542
|
|
|
12543
|
+
/**
|
|
12544
|
+
* Content addressing for repository objects.
|
|
12545
|
+
*
|
|
12546
|
+
* The hash must depend only on MEANING, never on serialisation accidents — two
|
|
12547
|
+
* clients that build the same tree have to agree on its hash, or history forks
|
|
12548
|
+
* for no reason. So keys are sorted and the encoding is fixed here rather than
|
|
12549
|
+
* left to `JSON.stringify`'s insertion order.
|
|
12550
|
+
*/
|
|
12551
|
+
/** Canonical JSON: object keys sorted, recursively. */
|
|
12552
|
+
function canonical(value) {
|
|
12553
|
+
if (value === null || typeof value !== "object")
|
|
12554
|
+
return JSON.stringify(value) ?? "null";
|
|
12555
|
+
if (Array.isArray(value))
|
|
12556
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
12557
|
+
const entries = Object.entries(value)
|
|
12558
|
+
.filter(([, v]) => v !== undefined)
|
|
12559
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
12560
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
12561
|
+
}
|
|
12562
|
+
/** SHA-256 of an object's canonical form, hex. */
|
|
12563
|
+
async function hashObject(value) {
|
|
12564
|
+
const bytes = new TextEncoder().encode(canonical(value));
|
|
12565
|
+
const digest = await crypto.subtle.digest("SHA-256", bytes);
|
|
12566
|
+
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
12567
|
+
}
|
|
12568
|
+
|
|
12569
|
+
/**
|
|
12570
|
+
* Three-way line merge.
|
|
12571
|
+
*
|
|
12572
|
+
* Given a common ancestor and two descendants, produce the combined text — or
|
|
12573
|
+
* report the regions where both sides changed the same lines differently.
|
|
12574
|
+
*
|
|
12575
|
+
* The server cannot do any of this: it holds ciphertext and cannot compare
|
|
12576
|
+
* versions, so merging is entirely a client concern and has to be exact here.
|
|
12577
|
+
*/
|
|
12578
|
+
/**
|
|
12579
|
+
* Longest common subsequence, as a list of matched index pairs.
|
|
12580
|
+
*
|
|
12581
|
+
* Classic dynamic programming. Fine for source files; a file with hundreds of
|
|
12582
|
+
* thousands of lines would want a smarter algorithm, and would also be a strange
|
|
12583
|
+
* thing to merge.
|
|
12584
|
+
*/
|
|
12585
|
+
function lcs(a, b) {
|
|
12586
|
+
const n = a.length;
|
|
12587
|
+
const m = b.length;
|
|
12588
|
+
const table = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
|
|
12589
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
12590
|
+
for (let j = m - 1; j >= 0; j--) {
|
|
12591
|
+
table[i][j] = a[i] === b[j] ? table[i + 1][j + 1] + 1 : Math.max(table[i + 1][j], table[i][j + 1]);
|
|
12592
|
+
}
|
|
12593
|
+
}
|
|
12594
|
+
const pairs = [];
|
|
12595
|
+
let i = 0;
|
|
12596
|
+
let j = 0;
|
|
12597
|
+
while (i < n && j < m) {
|
|
12598
|
+
if (a[i] === b[j]) {
|
|
12599
|
+
pairs.push([i, j]);
|
|
12600
|
+
i++;
|
|
12601
|
+
j++;
|
|
12602
|
+
}
|
|
12603
|
+
else if (table[i + 1][j] >= table[i][j + 1])
|
|
12604
|
+
i++;
|
|
12605
|
+
else
|
|
12606
|
+
j++;
|
|
12607
|
+
}
|
|
12608
|
+
return pairs;
|
|
12609
|
+
}
|
|
12610
|
+
/**
|
|
12611
|
+
* Lines of `base` that BOTH sides left untouched.
|
|
12612
|
+
*
|
|
12613
|
+
* These are the anchors the merge is built around: between two consecutive
|
|
12614
|
+
* anchors, each side has some (possibly empty) replacement, and comparing those
|
|
12615
|
+
* replacements is the entire decision.
|
|
12616
|
+
*/
|
|
12617
|
+
function stableAnchors(base, ours, theirs) {
|
|
12618
|
+
const ourMatch = new Map(lcs(base, ours));
|
|
12619
|
+
const theirMatch = new Map(lcs(base, theirs));
|
|
12620
|
+
const anchors = [];
|
|
12621
|
+
for (let b = 0; b < base.length; b++) {
|
|
12622
|
+
const o = ourMatch.get(b);
|
|
12623
|
+
const t = theirMatch.get(b);
|
|
12624
|
+
if (o !== undefined && t !== undefined)
|
|
12625
|
+
anchors.push([b, o, t]);
|
|
12626
|
+
}
|
|
12627
|
+
return anchors;
|
|
12628
|
+
}
|
|
12629
|
+
const same = (a, b) => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
12630
|
+
/**
|
|
12631
|
+
* Merge `ours` and `theirs` over their common ancestor `base`.
|
|
12632
|
+
*
|
|
12633
|
+
* A region where only one side changed takes that side. A region where both
|
|
12634
|
+
* made the SAME change takes it once — two people fixing the same typo is not a
|
|
12635
|
+
* conflict. Only genuinely divergent edits conflict.
|
|
12636
|
+
*/
|
|
12637
|
+
function merge3(base, ours, theirs) {
|
|
12638
|
+
const anchors = stableAnchors(base, ours, theirs);
|
|
12639
|
+
const out = [];
|
|
12640
|
+
const conflicts = [];
|
|
12641
|
+
let b = 0;
|
|
12642
|
+
let o = 0;
|
|
12643
|
+
let t = 0;
|
|
12644
|
+
const settle = (ourSlice, theirSlice, baseSlice) => {
|
|
12645
|
+
if (same(ourSlice, theirSlice)) {
|
|
12646
|
+
out.push(...ourSlice); // identical edits, or no edit
|
|
12647
|
+
}
|
|
12648
|
+
else if (same(ourSlice, baseSlice)) {
|
|
12649
|
+
out.push(...theirSlice); // only they changed it
|
|
12650
|
+
}
|
|
12651
|
+
else if (same(theirSlice, baseSlice)) {
|
|
12652
|
+
out.push(...ourSlice); // only we changed it
|
|
12653
|
+
}
|
|
12654
|
+
else {
|
|
12655
|
+
conflicts.push({ line: out.length + 1, ours: ourSlice, theirs: theirSlice });
|
|
12656
|
+
out.push(...ourSlice); // markers are added by the caller
|
|
12657
|
+
}
|
|
12658
|
+
};
|
|
12659
|
+
for (const [ab, ao, at] of anchors) {
|
|
12660
|
+
settle(ours.slice(o, ao), theirs.slice(t, at), base.slice(b, ab));
|
|
12661
|
+
out.push(base[ab]);
|
|
12662
|
+
b = ab + 1;
|
|
12663
|
+
o = ao + 1;
|
|
12664
|
+
t = at + 1;
|
|
12665
|
+
}
|
|
12666
|
+
settle(ours.slice(o), theirs.slice(t), base.slice(b));
|
|
12667
|
+
return { lines: out, conflicts };
|
|
12668
|
+
}
|
|
12669
|
+
/**
|
|
12670
|
+
* Merge to text, writing conflict markers where the sides diverged.
|
|
12671
|
+
*
|
|
12672
|
+
* Markers rather than a bespoke format: they are what every developer already
|
|
12673
|
+
* knows how to resolve, and resolving becomes ordinary editing.
|
|
12674
|
+
*/
|
|
12675
|
+
function merge3Text(base, ours, theirs, labels = { ours: "ours", theirs: "theirs" }) {
|
|
12676
|
+
const split = (s) => (s === "" ? [] : s.split("\n"));
|
|
12677
|
+
const anchors = stableAnchors(split(base), split(ours), split(theirs));
|
|
12678
|
+
const B = split(base);
|
|
12679
|
+
const O = split(ours);
|
|
12680
|
+
const T = split(theirs);
|
|
12681
|
+
const out = [];
|
|
12682
|
+
let conflicted = false;
|
|
12683
|
+
let b = 0;
|
|
12684
|
+
let o = 0;
|
|
12685
|
+
let t = 0;
|
|
12686
|
+
const settle = (ourSlice, theirSlice, baseSlice) => {
|
|
12687
|
+
if (same(ourSlice, theirSlice))
|
|
12688
|
+
out.push(...ourSlice);
|
|
12689
|
+
else if (same(ourSlice, baseSlice))
|
|
12690
|
+
out.push(...theirSlice);
|
|
12691
|
+
else if (same(theirSlice, baseSlice))
|
|
12692
|
+
out.push(...ourSlice);
|
|
12693
|
+
else {
|
|
12694
|
+
conflicted = true;
|
|
12695
|
+
out.push(`<<<<<<< ${labels.ours}`, ...ourSlice, "=======", ...theirSlice, `>>>>>>> ${labels.theirs}`);
|
|
12696
|
+
}
|
|
12697
|
+
};
|
|
12698
|
+
for (const [ab, ao, at] of anchors) {
|
|
12699
|
+
settle(O.slice(o, ao), T.slice(t, at), B.slice(b, ab));
|
|
12700
|
+
out.push(B[ab]);
|
|
12701
|
+
b = ab + 1;
|
|
12702
|
+
o = ao + 1;
|
|
12703
|
+
t = at + 1;
|
|
12704
|
+
}
|
|
12705
|
+
settle(O.slice(o), T.slice(t), B.slice(b));
|
|
12706
|
+
return { text: out.join("\n"), conflicted };
|
|
12707
|
+
}
|
|
12708
|
+
|
|
12709
|
+
/** Storage keys. Namespaced per project so repositories cannot collide. */
|
|
12710
|
+
const objectKey = (slug, hash) => `vcs/${slug}/obj/${hash}`;
|
|
12711
|
+
const refKey = (slug, name) => `vcs/${slug}/refs/${name}`;
|
|
12712
|
+
const headKey = (slug) => `vcs/${slug}/HEAD`;
|
|
12713
|
+
const refsIndexKey = (slug) => `vcs/${slug}/refs`;
|
|
12714
|
+
/** An in-progress merge: what to make the second parent when it is committed. */
|
|
12715
|
+
const mergeKey = (slug) => `vcs/${slug}/MERGE`;
|
|
12716
|
+
const DEFAULT_BRANCH = "main";
|
|
12717
|
+
class VcsError extends Error {
|
|
12718
|
+
constructor(message) {
|
|
12719
|
+
super(message);
|
|
12720
|
+
this.name = "VcsError";
|
|
12721
|
+
}
|
|
12722
|
+
}
|
|
12723
|
+
|
|
12724
|
+
/** A project's repository. One per `/apps/<slug>`. */
|
|
12725
|
+
/**
|
|
12726
|
+
* Types never worth attempting a three-way merge on.
|
|
12727
|
+
*
|
|
12728
|
+
* NOT application/octet-stream: that is the UNKNOWN case, which is what every
|
|
12729
|
+
* extensionless text file gets labelled, so it must reach the content sniff
|
|
12730
|
+
* rather than being rejected on the label.
|
|
12731
|
+
*/
|
|
12732
|
+
const BINARY_TYPE = /^(image|audio|video|font)\/|^application\/(wasm|zip|pdf|x-tar|gzip)/;
|
|
12733
|
+
/**
|
|
12734
|
+
* Does this look like text a person could resolve conflict markers in?
|
|
12735
|
+
*
|
|
12736
|
+
* A NUL byte is the classic tell and the one git uses — no textual encoding we
|
|
12737
|
+
* care about produces one. Invalid UTF-8 is the other: decoding it would
|
|
12738
|
+
* corrupt the file on write-back even if the merge itself succeeded.
|
|
12739
|
+
*/
|
|
12740
|
+
function looksTextual(bytes) {
|
|
12741
|
+
if (bytes.includes(0))
|
|
12742
|
+
return false;
|
|
12743
|
+
try {
|
|
12744
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
12745
|
+
return true;
|
|
12746
|
+
}
|
|
12747
|
+
catch {
|
|
12748
|
+
return false;
|
|
12749
|
+
}
|
|
12750
|
+
}
|
|
12751
|
+
/** Compare two path → entry maps into a change list. */
|
|
12752
|
+
function comparePaths(before, after) {
|
|
12753
|
+
const changes = [];
|
|
12754
|
+
for (const [path, entry] of after) {
|
|
12755
|
+
const was = before.get(path);
|
|
12756
|
+
if (!was)
|
|
12757
|
+
changes.push({ path, kind: "added" });
|
|
12758
|
+
else if (was.manifest.id !== entry.manifest.id)
|
|
12759
|
+
changes.push({ path, kind: "modified" });
|
|
12760
|
+
}
|
|
12761
|
+
for (const path of before.keys())
|
|
12762
|
+
if (!after.has(path))
|
|
12763
|
+
changes.push({ path, kind: "removed" });
|
|
12764
|
+
return changes.sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
12765
|
+
}
|
|
12766
|
+
class Repo {
|
|
12767
|
+
slug;
|
|
12768
|
+
deps;
|
|
12769
|
+
constructor(slug, deps) {
|
|
12770
|
+
this.slug = slug;
|
|
12771
|
+
this.deps = deps;
|
|
12772
|
+
}
|
|
12773
|
+
get root() {
|
|
12774
|
+
return `/apps/${this.slug}`;
|
|
12775
|
+
}
|
|
12776
|
+
// ---- objects ------------------------------------------------------------
|
|
12777
|
+
/** Store an object, returning its hash and whether it was new. */
|
|
12778
|
+
async put(value) {
|
|
12779
|
+
const hash = await hashObject(value);
|
|
12780
|
+
// Content-addressed, so re-writing an identical object is a no-op —
|
|
12781
|
+
// worth skipping because an unchanged directory is written on every
|
|
12782
|
+
// commit that touches any of its siblings.
|
|
12783
|
+
const existing = await this.deps.store.get(objectKey(this.slug, hash));
|
|
12784
|
+
if (existing)
|
|
12785
|
+
return { hash, created: false };
|
|
12786
|
+
await this.deps.store.put(objectKey(this.slug, hash), await seal(await this.deps.key(), value));
|
|
12787
|
+
return { hash, created: true };
|
|
12788
|
+
}
|
|
12789
|
+
async get(hash) {
|
|
12790
|
+
const raw = await this.deps.store.get(objectKey(this.slug, hash));
|
|
12791
|
+
const value = raw ? await unseal(await this.deps.key(), raw) : null;
|
|
12792
|
+
if (!value)
|
|
12793
|
+
throw new VcsError(`object ${hash.slice(0, 8)} is missing from this repository`);
|
|
12794
|
+
return value;
|
|
12795
|
+
}
|
|
12796
|
+
// ---- refs ---------------------------------------------------------------
|
|
12797
|
+
async head() {
|
|
12798
|
+
const raw = await this.deps.store.get(headKey(this.slug));
|
|
12799
|
+
const value = raw ? await unseal(await this.deps.key(), raw) : null;
|
|
12800
|
+
return value ?? { branch: DEFAULT_BRANCH };
|
|
12801
|
+
}
|
|
12802
|
+
async setHead(head) {
|
|
12803
|
+
await this.deps.store.put(headKey(this.slug), await seal(await this.deps.key(), head));
|
|
12804
|
+
}
|
|
12805
|
+
async readRef(name) {
|
|
12806
|
+
const raw = await this.deps.store.get(refKey(this.slug, name));
|
|
12807
|
+
return raw ? await unseal(await this.deps.key(), raw).then((v) => v?.commit ?? null) : null;
|
|
12808
|
+
}
|
|
12809
|
+
async writeRef(name, commit) {
|
|
12810
|
+
await this.deps.store.put(refKey(this.slug, name), await seal(await this.deps.key(), { commit }));
|
|
12811
|
+
// The personal space has no prefix listing, so branches are tracked in
|
|
12812
|
+
// an index. Kept append-only-ish: losing a name here loses the branch.
|
|
12813
|
+
const names = new Set(await this.branches());
|
|
12814
|
+
names.add(name);
|
|
12815
|
+
await this.deps.store.put(refsIndexKey(this.slug), await seal(await this.deps.key(), { names: [...names].sort() }));
|
|
12816
|
+
}
|
|
12817
|
+
/** Every branch name in this repository. */
|
|
12818
|
+
async branches() {
|
|
12819
|
+
const raw = await this.deps.store.get(refsIndexKey(this.slug));
|
|
12820
|
+
const value = raw ? await unseal(await this.deps.key(), raw) : null;
|
|
12821
|
+
return value?.names ?? [];
|
|
12822
|
+
}
|
|
12823
|
+
/** The commit HEAD resolves to, or null in an empty repository. */
|
|
12824
|
+
async current() {
|
|
12825
|
+
const head = await this.head();
|
|
12826
|
+
return "detached" in head ? head.detached : this.readRef(head.branch);
|
|
12827
|
+
}
|
|
12828
|
+
// ---- commit -------------------------------------------------------------
|
|
12829
|
+
/**
|
|
12830
|
+
* Freeze the working tree as a commit.
|
|
12831
|
+
*
|
|
12832
|
+
* Trees are written bottom-up so a directory's hash is known before its
|
|
12833
|
+
* parent references it. Nothing is written to the shard store: file entries
|
|
12834
|
+
* carry the manifest the VFS already holds.
|
|
12835
|
+
*/
|
|
12836
|
+
async commit(message, parents) {
|
|
12837
|
+
if (!message.trim())
|
|
12838
|
+
throw new VcsError("a commit needs a message");
|
|
12839
|
+
const tree = await this.writeTree(this.root);
|
|
12840
|
+
const parent = await this.current();
|
|
12841
|
+
// Finish an interrupted merge: the recorded other side becomes the
|
|
12842
|
+
// second parent. Losing this would produce a single-parent commit, and
|
|
12843
|
+
// the merge would look as though it never happened — the branches would
|
|
12844
|
+
// still appear diverged.
|
|
12845
|
+
const pending = parents ? null : await this.pendingMerge();
|
|
12846
|
+
const resolved = parents ?? (pending ? [parent, pending.theirs] : parent ? [parent] : []);
|
|
12847
|
+
if (!parents && !pending && parent) {
|
|
12848
|
+
const previous = await this.get(parent);
|
|
12849
|
+
// An empty commit is almost always a mistake — the user believes
|
|
12850
|
+
// they saved something they did not.
|
|
12851
|
+
if (previous.tree === tree)
|
|
12852
|
+
throw new VcsError("nothing to commit — the project is unchanged");
|
|
12853
|
+
}
|
|
12854
|
+
const commit = {
|
|
12855
|
+
v: 1,
|
|
12856
|
+
tree,
|
|
12857
|
+
parents: resolved,
|
|
12858
|
+
message: message.trim(),
|
|
12859
|
+
author: this.deps.author(),
|
|
12860
|
+
at: Date.now(),
|
|
12861
|
+
};
|
|
12862
|
+
const { hash } = await this.put(commit);
|
|
12863
|
+
await this.advance(hash);
|
|
12864
|
+
if (pending)
|
|
12865
|
+
await this.deps.store.delete(mergeKey(this.slug)).catch(() => { });
|
|
12866
|
+
return hash;
|
|
12867
|
+
}
|
|
12868
|
+
/** Move whatever HEAD points at onto a new commit. */
|
|
12869
|
+
async advance(hash) {
|
|
12870
|
+
const head = await this.head();
|
|
12871
|
+
if ("detached" in head)
|
|
12872
|
+
await this.setHead({ detached: hash });
|
|
12873
|
+
else
|
|
12874
|
+
await this.writeRef(head.branch, hash);
|
|
12875
|
+
}
|
|
12876
|
+
/** The other side of a merge that stopped for conflicts, if any. */
|
|
12877
|
+
async pendingMerge() {
|
|
12878
|
+
const raw = await this.deps.store.get(mergeKey(this.slug));
|
|
12879
|
+
return raw ? await unseal(await this.deps.key(), raw) : null;
|
|
12880
|
+
}
|
|
12881
|
+
/** Write a directory and everything under it, returning the tree's hash. */
|
|
12882
|
+
async writeTree(path) {
|
|
12883
|
+
const entries = {};
|
|
12884
|
+
for (const entry of await this.deps.vfs.list(path)) {
|
|
12885
|
+
if (entry.kind === "dir") {
|
|
12886
|
+
entries[entry.name] = { kind: "tree", hash: await this.writeTree(entry.path) };
|
|
12887
|
+
}
|
|
12888
|
+
else {
|
|
12889
|
+
const stat = await this.deps.vfs.statManifest(entry.path);
|
|
12890
|
+
entries[entry.name] = { kind: "file", manifest: stat.manifest, size: entry.size };
|
|
12891
|
+
}
|
|
12892
|
+
}
|
|
12893
|
+
const { hash, created } = await this.put({ v: 1, entries });
|
|
12894
|
+
// A NEW tree means this directory's contents are newly recorded, so the
|
|
12895
|
+
// repository takes its own reference to their content. Without it, a
|
|
12896
|
+
// later working-tree delete frees shards the commit still needs and the
|
|
12897
|
+
// history becomes unreadable.
|
|
12898
|
+
//
|
|
12899
|
+
// Retaining per new tree rather than per changed FILE slightly
|
|
12900
|
+
// over-retains — an unchanged file in a directory whose sibling changed
|
|
12901
|
+
// gets a second reference. That errs towards keeping content, which is
|
|
12902
|
+
// the correct direction to err in.
|
|
12903
|
+
if (created) {
|
|
12904
|
+
for (const entry of Object.values(entries)) {
|
|
12905
|
+
if (entry.kind === "file")
|
|
12906
|
+
await this.deps.vfs.retainManifest(entry.manifest);
|
|
12907
|
+
}
|
|
12908
|
+
}
|
|
12909
|
+
return hash;
|
|
12910
|
+
}
|
|
12911
|
+
// ---- history ------------------------------------------------------------
|
|
12912
|
+
/** Commits reachable from HEAD, newest first. */
|
|
12913
|
+
async log(limit = 50) {
|
|
12914
|
+
const start = await this.current();
|
|
12915
|
+
if (!start)
|
|
12916
|
+
return [];
|
|
12917
|
+
const out = [];
|
|
12918
|
+
const seen = new Set();
|
|
12919
|
+
// Breadth-first over parents so a merge's two sides both appear, and
|
|
12920
|
+
// `seen` keeps a diamond from listing the shared history twice.
|
|
12921
|
+
const queue = [start];
|
|
12922
|
+
while (queue.length && out.length < limit) {
|
|
12923
|
+
const hash = queue.shift();
|
|
12924
|
+
if (seen.has(hash))
|
|
12925
|
+
continue;
|
|
12926
|
+
seen.add(hash);
|
|
12927
|
+
const commit = await this.get(hash);
|
|
12928
|
+
out.push({
|
|
12929
|
+
hash,
|
|
12930
|
+
message: commit.message,
|
|
12931
|
+
author: commit.author,
|
|
12932
|
+
at: commit.at,
|
|
12933
|
+
parents: commit.parents,
|
|
12934
|
+
});
|
|
12935
|
+
queue.push(...commit.parents);
|
|
12936
|
+
}
|
|
12937
|
+
return out.sort((a, b) => b.at - a.at);
|
|
12938
|
+
}
|
|
12939
|
+
// ---- diff ---------------------------------------------------------------
|
|
12940
|
+
/**
|
|
12941
|
+
* What changed between two commits.
|
|
12942
|
+
*
|
|
12943
|
+
* Identical subtree hashes prune the walk: an untouched directory is one
|
|
12944
|
+
* comparison regardless of how much is inside it. That is the payoff for
|
|
12945
|
+
* content-addressing the trees.
|
|
12946
|
+
*/
|
|
12947
|
+
async diff(fromHash, toHash) {
|
|
12948
|
+
const from = fromHash ? (await this.get(fromHash)).tree : null;
|
|
12949
|
+
const to = (await this.get(toHash)).tree;
|
|
12950
|
+
const changes = [];
|
|
12951
|
+
await this.diffTrees(from, to, "", changes);
|
|
12952
|
+
return changes.sort((a, b) => (a.path < b.path ? -1 : 1));
|
|
12953
|
+
}
|
|
12954
|
+
async diffTrees(fromHash, toHash, prefix, out) {
|
|
12955
|
+
if (fromHash === toHash)
|
|
12956
|
+
return; // identical subtrees: nothing below can differ
|
|
12957
|
+
const from = fromHash ? await this.get(fromHash) : { entries: {} };
|
|
12958
|
+
const to = toHash ? await this.get(toHash) : { entries: {} };
|
|
12959
|
+
for (const name of new Set([...Object.keys(from.entries), ...Object.keys(to.entries)])) {
|
|
12960
|
+
const a = from.entries[name];
|
|
12961
|
+
const b = to.entries[name];
|
|
12962
|
+
const path = `${prefix}/${name}`;
|
|
12963
|
+
if (a && b && a.kind === "tree" && b.kind === "tree") {
|
|
12964
|
+
await this.diffTrees(a.hash, b.hash, path, out);
|
|
12965
|
+
}
|
|
12966
|
+
else if (a?.kind === "tree" || b?.kind === "tree") {
|
|
12967
|
+
// A directory replaced by a file (or the reverse) — recurse both
|
|
12968
|
+
// sides so every affected path is reported, not just the name.
|
|
12969
|
+
await this.diffTrees(a?.kind === "tree" ? a.hash : null, b?.kind === "tree" ? b.hash : null, path, out);
|
|
12970
|
+
if (a?.kind === "file")
|
|
12971
|
+
out.push({ path, kind: "removed" });
|
|
12972
|
+
if (b?.kind === "file")
|
|
12973
|
+
out.push({ path, kind: "added" });
|
|
12974
|
+
}
|
|
12975
|
+
else if (!a && b) {
|
|
12976
|
+
out.push({ path, kind: "added" });
|
|
12977
|
+
}
|
|
12978
|
+
else if (a && !b) {
|
|
12979
|
+
out.push({ path, kind: "removed" });
|
|
12980
|
+
}
|
|
12981
|
+
else if (a?.kind === "file" && b?.kind === "file" && a.manifest.id !== b.manifest.id) {
|
|
12982
|
+
out.push({ path, kind: "modified" });
|
|
12983
|
+
}
|
|
12984
|
+
}
|
|
12985
|
+
}
|
|
12986
|
+
// ---- checkout -----------------------------------------------------------
|
|
12987
|
+
/**
|
|
12988
|
+
* Replace the working tree with a commit's.
|
|
12989
|
+
*
|
|
12990
|
+
* Moves no file content: entries carry manifests the shard store already
|
|
12991
|
+
* holds, so this rewrites directory records only.
|
|
12992
|
+
*/
|
|
12993
|
+
/**
|
|
12994
|
+
* What has changed since the last commit.
|
|
12995
|
+
*
|
|
12996
|
+
* Compares the WORKING TREE against HEAD without writing any objects: the
|
|
12997
|
+
* common case is answering "anything to commit?", and a `status` that
|
|
12998
|
+
* quietly stored a tree object every time would litter the repository with
|
|
12999
|
+
* trees for states nobody chose to keep.
|
|
13000
|
+
*/
|
|
13001
|
+
async status() {
|
|
13002
|
+
const head = await this.current();
|
|
13003
|
+
const before = head ? await this.filesOf(head) : new Map();
|
|
13004
|
+
const after = await this.workingFiles();
|
|
13005
|
+
return comparePaths(before, after);
|
|
13006
|
+
}
|
|
13007
|
+
/** The working tree as a path → entry map, mirroring `filesOf`. */
|
|
13008
|
+
async workingFiles() {
|
|
13009
|
+
const out = new Map();
|
|
13010
|
+
for (const abs of await this.deps.vfs.walk(this.root)) {
|
|
13011
|
+
const stat = await this.deps.vfs.statManifest(abs);
|
|
13012
|
+
out.set(abs.slice(this.root.length), { kind: "file", manifest: stat.manifest, size: stat.size });
|
|
13013
|
+
}
|
|
13014
|
+
return out;
|
|
13015
|
+
}
|
|
13016
|
+
/**
|
|
13017
|
+
* Turn what a person typed into a commit hash.
|
|
13018
|
+
*
|
|
13019
|
+
* Accepts a branch name, `HEAD`, a full or abbreviated hash, and trailing
|
|
13020
|
+
* `^` / `~n` to walk back through parents — so `show abc123^` means "the
|
|
13021
|
+
* commit before that one" the way anyone who has used git expects.
|
|
13022
|
+
*/
|
|
13023
|
+
async resolve(rev) {
|
|
13024
|
+
const m = /^(.*?)((?:\^|~\d+)*)$/.exec(rev.trim());
|
|
13025
|
+
if (!m)
|
|
13026
|
+
throw new VcsError(`cannot understand "${rev}"`);
|
|
13027
|
+
const [, base, steps] = m;
|
|
13028
|
+
let hash = null;
|
|
13029
|
+
if (!base || base === "HEAD")
|
|
13030
|
+
hash = await this.current();
|
|
13031
|
+
else if (await this.readRef(base))
|
|
13032
|
+
hash = await this.readRef(base);
|
|
13033
|
+
else
|
|
13034
|
+
hash = await this.expand(base);
|
|
13035
|
+
if (!hash)
|
|
13036
|
+
throw new VcsError(`no commit matches "${rev}"`);
|
|
13037
|
+
for (const step of steps.match(/\^|~\d+/g) ?? []) {
|
|
13038
|
+
const back = step === "^" ? 1 : Number(step.slice(1));
|
|
13039
|
+
for (let i = 0; i < back; i++) {
|
|
13040
|
+
const parents = (await this.get(hash)).parents;
|
|
13041
|
+
if (!parents.length)
|
|
13042
|
+
throw new VcsError(`"${rev}" reaches past the first commit`);
|
|
13043
|
+
hash = parents[0]; // first parent: the branch you were on
|
|
13044
|
+
}
|
|
13045
|
+
}
|
|
13046
|
+
return hash;
|
|
13047
|
+
}
|
|
13048
|
+
/** A hash prefix a person typed → the one commit it names. */
|
|
13049
|
+
async expand(prefix) {
|
|
13050
|
+
if (!/^[0-9a-f]{4,}$/.test(prefix))
|
|
13051
|
+
return null;
|
|
13052
|
+
const keys = await this.deps.store.list();
|
|
13053
|
+
const wanted = objectKey(this.slug, prefix);
|
|
13054
|
+
const hits = keys.filter((k) => k.startsWith(wanted));
|
|
13055
|
+
// Ambiguous is an error, not a coin toss — acting on the wrong commit
|
|
13056
|
+
// is exactly the kind of mistake version control exists to prevent.
|
|
13057
|
+
if (hits.length > 1)
|
|
13058
|
+
throw new VcsError(`"${prefix}" matches ${hits.length} objects — use more characters`);
|
|
13059
|
+
if (!hits.length)
|
|
13060
|
+
return null;
|
|
13061
|
+
const hash = hits[0].slice(objectKey(this.slug, "").length);
|
|
13062
|
+
// Objects include trees; only a commit is a valid revision.
|
|
13063
|
+
const object = await this.get(hash);
|
|
13064
|
+
return object && "tree" in object ? hash : null;
|
|
13065
|
+
}
|
|
13066
|
+
/**
|
|
13067
|
+
* Put one file back to the way it was in a commit.
|
|
13068
|
+
*
|
|
13069
|
+
* Scoped to a path on purpose — recovering one file you broke should not
|
|
13070
|
+
* mean rewinding everything else you have done since.
|
|
13071
|
+
*/
|
|
13072
|
+
async restore(path, rev = "HEAD") {
|
|
13073
|
+
const hash = await this.resolve(rev);
|
|
13074
|
+
const files = await this.filesOf(hash);
|
|
13075
|
+
const key = path.startsWith(this.root) ? path.slice(this.root.length) : path.startsWith("/") ? path : `/${path}`;
|
|
13076
|
+
const entry = files.get(key);
|
|
13077
|
+
if (!entry)
|
|
13078
|
+
throw new VcsError(`${key} is not in ${rev}`);
|
|
13079
|
+
await this.deps.vfs.writeManifest(this.abs(key), entry.manifest, entry.size);
|
|
13080
|
+
}
|
|
13081
|
+
async checkout(commitHash, opts = {}) {
|
|
13082
|
+
await this.guardWorkingTree(opts.discardChanges);
|
|
13083
|
+
await this.materialise(commitHash);
|
|
13084
|
+
// DETACHED on purpose: committing from a historical state must not
|
|
13085
|
+
// silently rewrite whichever branch you happened to be on. `switchTo`
|
|
13086
|
+
// is how you re-attach.
|
|
13087
|
+
await this.setHead({ detached: commitHash });
|
|
13088
|
+
}
|
|
13089
|
+
/** Make the working tree match a commit, leaving HEAD alone. */
|
|
13090
|
+
async materialise(commitHash) {
|
|
13091
|
+
const commit = await this.get(commitHash);
|
|
13092
|
+
const wanted = new Map();
|
|
13093
|
+
await this.collect(commit.tree, "", wanted);
|
|
13094
|
+
// Remove what the commit does not have BEFORE writing what it does —
|
|
13095
|
+
// otherwise a file replaced by a directory of the same name collides.
|
|
13096
|
+
for (const abs of await this.deps.vfs.walk(this.root)) {
|
|
13097
|
+
const path = abs.slice(this.root.length);
|
|
13098
|
+
// keepContent: history still references these manifests.
|
|
13099
|
+
if (!wanted.has(path))
|
|
13100
|
+
await this.deps.vfs.delete(abs, { keepContent: true }).catch(() => { });
|
|
13101
|
+
}
|
|
13102
|
+
for (const [path, entry] of wanted) {
|
|
13103
|
+
await this.deps.vfs.writeManifest(this.abs(path), entry.manifest, entry.size);
|
|
13104
|
+
}
|
|
13105
|
+
}
|
|
13106
|
+
/** Project-relative path → absolute VFS path. */
|
|
13107
|
+
abs(path) {
|
|
13108
|
+
return `${this.root}${path}`;
|
|
13109
|
+
}
|
|
13110
|
+
async collect(treeHash, prefix, out) {
|
|
13111
|
+
const tree = await this.get(treeHash);
|
|
13112
|
+
for (const [name, entry] of Object.entries(tree.entries)) {
|
|
13113
|
+
const path = `${prefix}/${name}`;
|
|
13114
|
+
if (entry.kind === "tree")
|
|
13115
|
+
await this.collect(entry.hash, path, out);
|
|
13116
|
+
else
|
|
13117
|
+
out.set(path, entry);
|
|
13118
|
+
}
|
|
13119
|
+
}
|
|
13120
|
+
// ---- branches -----------------------------------------------------------
|
|
13121
|
+
/** Create a branch at a commit (HEAD by default). Does not switch to it. */
|
|
13122
|
+
async branch(name, at) {
|
|
13123
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._\/-]*$/.test(name))
|
|
13124
|
+
throw new VcsError(`"${name}" is not a usable branch name`);
|
|
13125
|
+
if (await this.readRef(name))
|
|
13126
|
+
throw new VcsError(`branch "${name}" already exists`);
|
|
13127
|
+
const commit = at ?? (await this.current());
|
|
13128
|
+
if (!commit)
|
|
13129
|
+
throw new VcsError("cannot branch an empty repository — commit something first");
|
|
13130
|
+
await this.writeRef(name, commit);
|
|
13131
|
+
return commit;
|
|
13132
|
+
}
|
|
13133
|
+
/**
|
|
13134
|
+
* Move onto a branch, bringing the working tree with it.
|
|
13135
|
+
*
|
|
13136
|
+
* Unlike {@link checkout}, this ATTACHES head: later commits advance the
|
|
13137
|
+
* branch. Checking out a bare commit deliberately does not, so that
|
|
13138
|
+
* committing from a historical state cannot silently rewrite a branch.
|
|
13139
|
+
*/
|
|
13140
|
+
async switchTo(name, opts = {}) {
|
|
13141
|
+
const commit = await this.readRef(name);
|
|
13142
|
+
if (!commit)
|
|
13143
|
+
throw new VcsError(`no branch named "${name}"`);
|
|
13144
|
+
await this.guardWorkingTree(opts.discardChanges);
|
|
13145
|
+
await this.materialise(commit);
|
|
13146
|
+
await this.setHead({ branch: name });
|
|
13147
|
+
}
|
|
13148
|
+
/**
|
|
13149
|
+
* Refuse to overwrite work that was never recorded.
|
|
13150
|
+
*
|
|
13151
|
+
* Moving between commits REPLACES the working tree, so uncommitted edits
|
|
13152
|
+
* are gone — and unlike an ordinary overwrite, the person did not ask for
|
|
13153
|
+
* anything to be written. Naming the files is the point: "you have changes"
|
|
13154
|
+
* with no list leaves them guessing what they are about to lose.
|
|
13155
|
+
*/
|
|
13156
|
+
async guardWorkingTree(discard) {
|
|
13157
|
+
if (discard)
|
|
13158
|
+
return;
|
|
13159
|
+
const pending = await this.status();
|
|
13160
|
+
if (!pending.length)
|
|
13161
|
+
return;
|
|
13162
|
+
const names = pending.slice(0, 5).map((c) => c.path).join(", ");
|
|
13163
|
+
const more = pending.length > 5 ? `, and ${pending.length - 5} more` : "";
|
|
13164
|
+
throw new VcsError(`${pending.length} uncommitted change(s) would be lost: ${names}${more}. ` +
|
|
13165
|
+
"Commit them first, or pass { discardChanges: true } to throw them away.");
|
|
13166
|
+
}
|
|
13167
|
+
/** The branch HEAD is on, or null when detached. */
|
|
13168
|
+
async currentBranch() {
|
|
13169
|
+
const head = await this.head();
|
|
13170
|
+
return "detached" in head ? null : head.branch;
|
|
13171
|
+
}
|
|
13172
|
+
// ---- merge --------------------------------------------------------------
|
|
13173
|
+
/**
|
|
13174
|
+
* Merge another branch into the current one.
|
|
13175
|
+
*
|
|
13176
|
+
* Every part of this is client-side by necessity: the server holds
|
|
13177
|
+
* ciphertext and cannot compare versions, so base, ours and theirs are all
|
|
13178
|
+
* fetched and decrypted here.
|
|
13179
|
+
*/
|
|
13180
|
+
async merge(name, opts = {}) {
|
|
13181
|
+
const theirs = await this.readRef(name);
|
|
13182
|
+
if (!theirs)
|
|
13183
|
+
throw new VcsError(`no branch named "${name}"`);
|
|
13184
|
+
// A merge writes to the working tree as freely as a switch does.
|
|
13185
|
+
await this.guardWorkingTree(opts.discardChanges);
|
|
13186
|
+
const ours = await this.current();
|
|
13187
|
+
if (!ours)
|
|
13188
|
+
throw new VcsError("nothing to merge into — commit something first");
|
|
13189
|
+
if (ours === theirs)
|
|
13190
|
+
return { kind: "up-to-date", conflicts: [] };
|
|
13191
|
+
const base = await this.mergeBase(ours, theirs);
|
|
13192
|
+
if (base === theirs)
|
|
13193
|
+
return { kind: "up-to-date", conflicts: [] };
|
|
13194
|
+
if (base === ours) {
|
|
13195
|
+
// Fast-forward: our history is contained in theirs, so there is
|
|
13196
|
+
// nothing to combine — just move.
|
|
13197
|
+
await this.materialise(theirs);
|
|
13198
|
+
await this.advance(theirs);
|
|
13199
|
+
return { kind: "fast-forward", commit: theirs, conflicts: [] };
|
|
13200
|
+
}
|
|
13201
|
+
const [baseFiles, ourFiles, theirFiles] = await Promise.all([
|
|
13202
|
+
base ? this.filesOf(base) : new Map(),
|
|
13203
|
+
this.filesOf(ours),
|
|
13204
|
+
this.filesOf(theirs),
|
|
13205
|
+
]);
|
|
13206
|
+
const conflicts = [];
|
|
13207
|
+
for (const path of new Set([...ourFiles.keys(), ...theirFiles.keys(), ...baseFiles.keys()])) {
|
|
13208
|
+
const b = baseFiles.get(path);
|
|
13209
|
+
const o = ourFiles.get(path);
|
|
13210
|
+
const t = theirFiles.get(path);
|
|
13211
|
+
const conflict = await this.settlePath(path, b, o, t);
|
|
13212
|
+
if (conflict)
|
|
13213
|
+
conflicts.push(conflict);
|
|
13214
|
+
}
|
|
13215
|
+
if (conflicts.length) {
|
|
13216
|
+
// Record what to merge with, so finishing is just: fix the files,
|
|
13217
|
+
// then commit. Losing this would silently produce a commit with one
|
|
13218
|
+
// parent, and the merge would appear never to have happened.
|
|
13219
|
+
await this.deps.store.put(mergeKey(this.slug), await seal(await this.deps.key(), { theirs, name }));
|
|
13220
|
+
return { kind: "conflicted", conflicts };
|
|
13221
|
+
}
|
|
13222
|
+
const commit = await this.commit(`Merge ${name}`, [ours, theirs]);
|
|
13223
|
+
return { kind: "merged", commit, conflicts: [] };
|
|
13224
|
+
}
|
|
13225
|
+
/** Decide one path. Returns a conflict when it cannot be settled. */
|
|
13226
|
+
async settlePath(path, b, o, t) {
|
|
13227
|
+
const id = (f) => f?.manifest.id;
|
|
13228
|
+
if (id(o) === id(t))
|
|
13229
|
+
return null; // already agree
|
|
13230
|
+
if (!o && !b && t)
|
|
13231
|
+
return this.take(path, t); // only they added it
|
|
13232
|
+
if (!t && !b && o)
|
|
13233
|
+
return null; // only we added it
|
|
13234
|
+
if (id(o) === id(b)) { // only they touched it
|
|
13235
|
+
if (!t) {
|
|
13236
|
+
await this.deps.vfs.delete(this.abs(path), { keepContent: true }).catch(() => { });
|
|
13237
|
+
return null;
|
|
13238
|
+
}
|
|
13239
|
+
return this.take(path, t);
|
|
13240
|
+
}
|
|
13241
|
+
if (id(t) === id(b))
|
|
13242
|
+
return null; // only we touched it
|
|
13243
|
+
// Both sides moved. A file one side deleted and the other edited is not
|
|
13244
|
+
// a merge decision a tool should make silently.
|
|
13245
|
+
if (!o || !t)
|
|
13246
|
+
return { path, reason: "modify-delete" };
|
|
13247
|
+
// Definitely-binary types are rejected WITHOUT reading them — merging
|
|
13248
|
+
// bytes line-by-line produces corruption, not a result, and these files
|
|
13249
|
+
// are exactly the large ones.
|
|
13250
|
+
if (BINARY_TYPE.test(o.manifest.type ?? "") || BINARY_TYPE.test(t.manifest.type ?? "")) {
|
|
13251
|
+
return { path, reason: "binary" };
|
|
13252
|
+
}
|
|
13253
|
+
const [baseBytes, ourBytes, theirBytes] = await Promise.all([
|
|
13254
|
+
b ? this.deps.vfs.readByManifest(b.manifest) : Promise.resolve(new Uint8Array()),
|
|
13255
|
+
this.deps.vfs.readByManifest(o.manifest),
|
|
13256
|
+
this.deps.vfs.readByManifest(t.manifest),
|
|
13257
|
+
]);
|
|
13258
|
+
// Then judge by CONTENT, not by the declared type.
|
|
13259
|
+
//
|
|
13260
|
+
// The type is a hint we do not own: a content store may omit it, and
|
|
13261
|
+
// plenty of real text files carry no extension to derive one from
|
|
13262
|
+
// (Makefile, LICENSE, .gitignore, .env). Trusting it alone turned every
|
|
13263
|
+
// one of those into an unmergeable false conflict.
|
|
13264
|
+
if (![ourBytes, theirBytes, baseBytes].every(looksTextual))
|
|
13265
|
+
return { path, reason: "binary" };
|
|
13266
|
+
const decode = (bytes) => new TextDecoder().decode(bytes);
|
|
13267
|
+
const { text, conflicted } = merge3Text(decode(baseBytes), decode(ourBytes), decode(theirBytes));
|
|
13268
|
+
await this.deps.vfs.writeFile(this.abs(path), text);
|
|
13269
|
+
return conflicted ? { path, reason: "content" } : null;
|
|
13270
|
+
}
|
|
13271
|
+
async take(path, entry) {
|
|
13272
|
+
await this.deps.vfs.writeManifest(this.abs(path), entry.manifest, entry.size);
|
|
13273
|
+
return null;
|
|
13274
|
+
}
|
|
13275
|
+
/**
|
|
13276
|
+
* The most recent commit reachable from both.
|
|
13277
|
+
*
|
|
13278
|
+
* Walks our ancestry into a set, then theirs breadth-first until it hits it —
|
|
13279
|
+
* so the first match is the nearest, which is what a three-way merge needs.
|
|
13280
|
+
*/
|
|
13281
|
+
async mergeBase(a, b) {
|
|
13282
|
+
const ancestors = new Set();
|
|
13283
|
+
const collect = async (start) => {
|
|
13284
|
+
const queue = [start];
|
|
13285
|
+
while (queue.length) {
|
|
13286
|
+
const hash = queue.shift();
|
|
13287
|
+
if (ancestors.has(hash))
|
|
13288
|
+
continue;
|
|
13289
|
+
ancestors.add(hash);
|
|
13290
|
+
queue.push(...(await this.get(hash)).parents);
|
|
13291
|
+
}
|
|
13292
|
+
};
|
|
13293
|
+
await collect(a);
|
|
13294
|
+
const queue = [b];
|
|
13295
|
+
const seen = new Set();
|
|
13296
|
+
while (queue.length) {
|
|
13297
|
+
const hash = queue.shift();
|
|
13298
|
+
if (seen.has(hash))
|
|
13299
|
+
continue;
|
|
13300
|
+
seen.add(hash);
|
|
13301
|
+
if (ancestors.has(hash))
|
|
13302
|
+
return hash;
|
|
13303
|
+
queue.push(...(await this.get(hash)).parents);
|
|
13304
|
+
}
|
|
13305
|
+
return null;
|
|
13306
|
+
}
|
|
13307
|
+
/** Every file in a commit, by path. */
|
|
13308
|
+
async filesOf(commitHash) {
|
|
13309
|
+
const commit = await this.get(commitHash);
|
|
13310
|
+
const out = new Map();
|
|
13311
|
+
await this.collect(commit.tree, "", out);
|
|
13312
|
+
return out;
|
|
13313
|
+
}
|
|
13314
|
+
}
|
|
13315
|
+
/**
|
|
13316
|
+
* Version control over the VFS.
|
|
13317
|
+
*
|
|
13318
|
+
* One repository per project, addressed by slug — the same slug the VFS stores
|
|
13319
|
+
* that project's working tree under. `client.vcs.open("my-app")` is the whole
|
|
13320
|
+
* entry point.
|
|
13321
|
+
*/
|
|
13322
|
+
class VcsNamespace {
|
|
13323
|
+
deps;
|
|
13324
|
+
repos = new Map();
|
|
13325
|
+
constructor(deps) {
|
|
13326
|
+
this.deps = deps;
|
|
13327
|
+
}
|
|
13328
|
+
/** The repository for one project. Cheap and cached; creates nothing. */
|
|
13329
|
+
open(slug) {
|
|
13330
|
+
let repo = this.repos.get(slug);
|
|
13331
|
+
if (!repo) {
|
|
13332
|
+
repo = new Repo(slug, {
|
|
13333
|
+
vfs: this.deps.vfs,
|
|
13334
|
+
store: this.deps.store,
|
|
13335
|
+
key: async () => {
|
|
13336
|
+
const seed = this.deps.seed();
|
|
13337
|
+
if (!seed)
|
|
13338
|
+
throw new VcsError("client.vcs: no session — sign in first.");
|
|
13339
|
+
return deriveRepoKey(seed, slug);
|
|
13340
|
+
},
|
|
13341
|
+
author: this.deps.author,
|
|
13342
|
+
});
|
|
13343
|
+
this.repos.set(slug, repo);
|
|
13344
|
+
}
|
|
13345
|
+
return repo;
|
|
13346
|
+
}
|
|
13347
|
+
}
|
|
13348
|
+
|
|
12436
13349
|
/**
|
|
12437
13350
|
* `ChatKeyVault` — SDK-owned lifecycle for a member's long-lived ratchet/space
|
|
12438
13351
|
* keypair (the connect `KeyStore` entry keyed by the member id / username).
|
|
@@ -13676,6 +14589,8 @@ class Client {
|
|
|
13676
14589
|
storage;
|
|
13677
14590
|
/** Filesystem over the personal space: directories, versions, real paths. */
|
|
13678
14591
|
vfs;
|
|
14592
|
+
/** Version control over the VFS: commits, branches, merges — per project. */
|
|
14593
|
+
vcs;
|
|
13679
14594
|
/** Realtime messaging — `client.message.subscribe(...)`, etc. */
|
|
13680
14595
|
message;
|
|
13681
14596
|
/** Fan-out group spaces — `client.space.createSpace(...)`, etc. */
|
|
@@ -13863,6 +14778,15 @@ class Client {
|
|
|
13863
14778
|
},
|
|
13864
14779
|
},
|
|
13865
14780
|
});
|
|
14781
|
+
// Sealed into the SAME personal store as the VFS metadata, so a project
|
|
14782
|
+
// and its history live or die together — there is no way to end up with
|
|
14783
|
+
// commits whose working tree is gone, or the reverse.
|
|
14784
|
+
this.vcs = new VcsNamespace({
|
|
14785
|
+
vfs: this.vfs,
|
|
14786
|
+
store: personalStore,
|
|
14787
|
+
seed: () => this.session.seed,
|
|
14788
|
+
author: () => this.session.commitment ?? "unknown",
|
|
14789
|
+
});
|
|
13866
14790
|
// Group keys are cached in the user's PersonalSpace (encrypted at rest
|
|
13867
14791
|
// by client.kv), so a returning member hydrates without a fresh keyring
|
|
13868
14792
|
// round-trip. The server only ever sees ciphertext.
|
|
@@ -14801,5 +15725,5 @@ var bundledLoader = /*#__PURE__*/Object.freeze({
|
|
|
14801
15725
|
default: loadRsWasm
|
|
14802
15726
|
});
|
|
14803
15727
|
|
|
14804
|
-
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_BASE_URL, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReauthRequiredError, ReedSolomonCodec, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes, configureDeviceStore, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, log, manifestToStat, materializeRow, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
15728
|
+
export { ACCESS_TOKEN_SCOPES, AES_GCM_IV_BYTES$1 as AES_GCM_IV_BYTES, AES_GCM_KEY_BYTES, AES_GCM_TAG_BYTES, AccessTokensNamespace, AgentsNamespace, AuthClient, AuthNamespace, AuthPublicInput, Authenticator, BlockEngine, BroadcastChannel, BroadcastChannelEvents, ChannelExistsError, ChannelNotFoundError, ChunkCipher, Client, ConnectivityManager, DEFAULT_BASE_URL, DEFAULT_BRANCH, DEFAULT_FN_HOST_SUFFIX, DEVICE_IDENTITY_STORAGE_KEY, DEVICE_KEY_DB_NAME, DbCache, DbNamespace, DbTable, DevicePairingError, DeviceStoreUnavailableError, DoubleRatchet, DoubleRatchetCipher, DoubleRatchetManager, EncryptedSession, EventCore, EventCoreEvents, Field, Field as FieldElement, FileStorage, FunctionsNamespace, HLC, HashKnowledge, HostedAuth, HttpClient, HttpError, IndexedDbStore, KEEPER_MEMBER_ID, KeyStore, KeyringClient, KvCache, KvNamespace, LocalEngineHost, LocalStorageSessionStore, Logger, MemorySessionStore, Message, Message as MessageDefault, MessageNamespace, MuhkooAgent, MuhkooDB, MuhkooFunction, MuhkooSpace, NoopStore, OfflineLockedError, OfflineManager, OutboundQueue, P2P_SUBJECT, PAIRING_CODE_ALPHABET, PAIRING_CODE_LENGTH, PBKDF2_DEFAULT_ITERATIONS, PREIMAGE_POK_VERIFICATION_KEY, Packet, PasskeyOriginError, PeerExchange, PeerNetwork, PersonalSpaceClient, Poseidon, PreimagePoK, Ready, ReauthRequiredError, ReedSolomonCodec, Repo, Retry, Space as Room, SerializeMessage, SessionState, ShardCache, ShardClient, SharedSpaceClient, SnapshotNamespace, Space, SpaceCache, SpaceKeyring, SpaceNamespace, SpacePacketCipher, SpaceSignaler, StorageNamespace, SyncEngine, VERSION, VaultUnavailableError, VcsError, VcsNamespace, VfsLockedError, VfsNamespace, WSTransport, WebRtcTransport, WorkerEngineHost, ZERO_HLC, ZkAuth, _accountId, _formatBytes, _messageId, _objectId, _packetId, _socketId, _userId, appLogger$1 as appLogger, assertType, base58Decode, base58Encode, buildCommitment, canonicalUserCode, clearDeviceIdentity, compareHlc, compilePrograms, concatBytes, configureDeviceStore, decodeFromHex, decorators, decryptAesGcm, defaultCircuitUrls, defaultSessionStore, deleteBeatsWrite, deleteRegister, deriveAesKeyFromPassphrase, deriveBitsHkdf, deriveIdentity, deriveIdentityFromSeed, deriveMasterSeedFromPassword, deserialize, deviceFingerprint, deviceIdentityIsEphemeral, deviceIdentityKey, ejectAgentPrompt, ejectAgentTools, emptyMap, emptyRegister, encodeToHex, encryptAesGcm, exportPublicKeyBase64, exportPublicKeyHex, formatUserCode, fromBase64$2 as fromBase64, fromBase64Url, fromHex, generateAuthProof, generateChecksum, generateDevicePairingKeypair, generateId, generatePkce, getIPAddress, getId, getMuhkooAppDescriptor, getSubtle, hasDeviceIdentity, initBn128Wasm, initRsWasm, initializeCircuits, isNewer, isNumber, isOfflineCapable, isP2pCapable, isProvisional, loadDeviceIdentity, log, manifestToStat, materializeRow, merge3, merge3Text, mergeMap, mergeMessage, mergeRegister, messageKey, pack, padHandle, pairingVerificationCode, passkeyUsableFromOrigin, persistDeviceIdentity, poseidonHash, protocol, provisionalHandle, quickVerify, randomBytes, randomState, rpIdUsableForOrigin, sealSeed, sealSeedToDevice, serialize, shardHash, signMessage, stableStringify, toBase64$2 as toBase64, toBase64Url, toField, toHex, tombstoneRow, unpack, unsealSeed, unsealSeedFromDevice, unwrapWithPassphrase, utf8Decode, utf8Encode, verify, verifyChecksum, verifyGroth16, verifyHashKnowledge, verifyPreimagePoK, wrapWithPassphrase, writeBeatsTombstone, writeColumns, writeRegister };
|
|
14805
15729
|
//# sourceMappingURL=index.js.map
|