@ricsam/r5d-worker 0.0.100 → 0.0.103
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/README.md +16 -3
- package/dist/cjs/main.cjs +390 -322
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-mirror-fetch-policy.cjs +48 -7
- package/dist/cjs/project-mirror-refs-token.cjs +36 -2
- package/dist/cjs/project-worktrees.cjs +49 -32
- package/dist/cjs/working-tree-mirror.cjs +347 -99
- package/dist/cjs/workspace-git-sync.cjs +189 -28
- package/dist/cjs/workspace-merge-projection.cjs +5 -19
- package/dist/cjs/workspace-mutation-gate.cjs +35 -0
- package/dist/cjs/workspace-projection-ledger.cjs +81 -0
- package/dist/cjs/workspace-sync-coalescer.cjs +58 -0
- package/dist/mjs/main.mjs +395 -324
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-mirror-fetch-policy.mjs +46 -6
- package/dist/mjs/project-mirror-refs-token.mjs +30 -1
- package/dist/mjs/project-worktrees.mjs +52 -33
- package/dist/mjs/working-tree-mirror.mjs +347 -99
- package/dist/mjs/workspace-git-sync.mjs +189 -28
- package/dist/mjs/workspace-merge-projection.mjs +5 -19
- package/dist/mjs/workspace-mutation-gate.mjs +35 -0
- package/dist/mjs/workspace-projection-ledger.mjs +57 -0
- package/dist/mjs/workspace-sync-coalescer.mjs +34 -0
- package/dist/types/main.d.ts +29 -17
- package/dist/types/project-mirror-fetch-policy.d.ts +70 -15
- package/dist/types/project-mirror-refs-token.d.ts +34 -0
- package/dist/types/project-worktrees.d.ts +26 -8
- package/dist/types/working-tree-mirror.d.ts +56 -11
- package/dist/types/workspace-git-sync.d.ts +41 -4
- package/dist/types/workspace-mutation-gate.d.ts +24 -3
- package/dist/types/workspace-projection-ledger.d.ts +59 -0
- package/dist/types/workspace-sync-coalescer.d.ts +28 -0
- package/package.json +1 -1
|
@@ -91,6 +91,22 @@ async function gitAsync(cwd, args, action) {
|
|
|
91
91
|
if (result.exitCode !== 0) throw new Error(`${action}: ${result.stderr || result.stdout || `git exited ${result.exitCode}`}`);
|
|
92
92
|
return result.stdout;
|
|
93
93
|
}
|
|
94
|
+
async function gitOutputAsync(cwd, args, action, input) {
|
|
95
|
+
const subprocess = Bun.spawn(gitCommandArgs(args), {
|
|
96
|
+
cwd,
|
|
97
|
+
stdin: input ?? "ignore",
|
|
98
|
+
stdout: "pipe",
|
|
99
|
+
stderr: "pipe",
|
|
100
|
+
env: workerGitProcessEnvironment()
|
|
101
|
+
});
|
|
102
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
103
|
+
new Response(subprocess.stdout).arrayBuffer(),
|
|
104
|
+
new Response(subprocess.stderr).text(),
|
|
105
|
+
subprocess.exited
|
|
106
|
+
]);
|
|
107
|
+
if (exitCode !== 0) throw new Error(`${action}: ${stderr.trim() || `git exited ${exitCode}`}`);
|
|
108
|
+
return Buffer.from(stdout);
|
|
109
|
+
}
|
|
94
110
|
async function tryGitAsync(cwd, args) {
|
|
95
111
|
return (await gitResultAsync(cwd, args)).exitCode === 0;
|
|
96
112
|
}
|
|
@@ -1102,7 +1118,13 @@ function beginHydrationTransaction(workspacePath, mounts, targetReceipt) {
|
|
|
1102
1118
|
candidate: outerPath,
|
|
1103
1119
|
label: `Workspace mount ${mount.id} outer source`
|
|
1104
1120
|
});
|
|
1105
|
-
const plan = planWorkingTreeMirror({
|
|
1121
|
+
const plan = planWorkingTreeMirror({
|
|
1122
|
+
sourceRoot: outerPath,
|
|
1123
|
+
targetRoot: sourcePath,
|
|
1124
|
+
sourceMode: "all",
|
|
1125
|
+
deletionMode: "git",
|
|
1126
|
+
sourceGitlinks: outerSubtreeGitlinks(workspacePath, mount)
|
|
1127
|
+
});
|
|
1106
1128
|
scope = captureWorkingTreeMirrorPlan({
|
|
1107
1129
|
plan,
|
|
1108
1130
|
targetRoot: sourcePath,
|
|
@@ -1330,6 +1352,19 @@ ${result.stdout}`;
|
|
|
1330
1352
|
}
|
|
1331
1353
|
return revParse(workspacePath, `refs/remotes/origin/${WORKSPACE_GIT_BRANCH}`);
|
|
1332
1354
|
}
|
|
1355
|
+
async function resolveWorkspaceRemoteHead(workspacePath, input) {
|
|
1356
|
+
const advertised = input.advertisedRemoteHead;
|
|
1357
|
+
if (advertised && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(advertised)) {
|
|
1358
|
+
const tracked = await revParseAsync(workspacePath, `refs/remotes/origin/${WORKSPACE_GIT_BRANCH}`);
|
|
1359
|
+
if (tracked === advertised && await tryGitAsync(workspacePath, ["cat-file", "-e", `${tracked}^{commit}`])) {
|
|
1360
|
+
return { head: tracked, fetched: false };
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return {
|
|
1364
|
+
head: await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername),
|
|
1365
|
+
fetched: true
|
|
1366
|
+
};
|
|
1367
|
+
}
|
|
1333
1368
|
async function ensureWorkspaceGitClone(input) {
|
|
1334
1369
|
const workspacePath = path.resolve(input.workspacePath);
|
|
1335
1370
|
const workspaceParent = path.dirname(workspacePath);
|
|
@@ -1377,7 +1412,8 @@ async function ensureWorkspaceGitClone(input) {
|
|
|
1377
1412
|
if (!await revParseAsync(workspacePath, WORKSPACE_GIT_INTEGRATED_REF) && previousRemoteHead && localHeadBeforeFetch && await tryGitAsync(workspacePath, ["merge-base", "--is-ancestor", previousRemoteHead, localHeadBeforeFetch])) {
|
|
1378
1413
|
await updateIntegratedWorkspaceHead(workspacePath, previousRemoteHead);
|
|
1379
1414
|
}
|
|
1380
|
-
const
|
|
1415
|
+
const resolvedRemoteHead = await resolveWorkspaceRemoteHead(workspacePath, input);
|
|
1416
|
+
const remoteHead = resolvedRemoteHead.head;
|
|
1381
1417
|
let localHead = await revParseAsync(workspacePath, "HEAD");
|
|
1382
1418
|
if (!localHead && remoteHead) {
|
|
1383
1419
|
await runWorkspaceCheckoutTransition(workspacePath, async () => {
|
|
@@ -1398,7 +1434,7 @@ async function ensureWorkspaceGitClone(input) {
|
|
|
1398
1434
|
await updateIntegratedWorkspaceHead(workspacePath, remoteHead);
|
|
1399
1435
|
localHead = remoteHead;
|
|
1400
1436
|
}
|
|
1401
|
-
return { localHead, remoteHead };
|
|
1437
|
+
return { localHead, remoteHead, remoteHeadFetched: resolvedRemoteHead.fetched };
|
|
1402
1438
|
}
|
|
1403
1439
|
function activeMounts(mounts) {
|
|
1404
1440
|
const active = [];
|
|
@@ -1413,6 +1449,7 @@ function activeMounts(mounts) {
|
|
|
1413
1449
|
return { active, tombstones, skipped };
|
|
1414
1450
|
}
|
|
1415
1451
|
function mirrorMountsToWorkspace(workspacePath, mounts) {
|
|
1452
|
+
const projected = [];
|
|
1416
1453
|
for (const mount of mounts) {
|
|
1417
1454
|
assertManagedDirectoryPath({
|
|
1418
1455
|
trustedRoot: mount.durabilityRootPath,
|
|
@@ -1424,13 +1461,61 @@ function mirrorMountsToWorkspace(workspacePath, mounts) {
|
|
|
1424
1461
|
candidate: path.join(workspacePath, ...mount.workspaceRelativePath.replace(/\\/g, "/").split("/")),
|
|
1425
1462
|
label: `Workspace mount ${mount.id} outer target`
|
|
1426
1463
|
});
|
|
1427
|
-
mirrorWorkingTree({
|
|
1464
|
+
const mirrored = mirrorWorkingTree({
|
|
1428
1465
|
sourceRoot: mount.sourcePath,
|
|
1429
1466
|
targetRoot: path.join(workspacePath, ...mount.workspaceRelativePath.replace(/\\/g, "/").split("/")),
|
|
1430
1467
|
sourceMode: mount.sourceMode,
|
|
1431
1468
|
deletionMode: "all"
|
|
1432
1469
|
});
|
|
1470
|
+
projected.push({ mount, gitlinks: mirrored.gitlinks });
|
|
1433
1471
|
}
|
|
1472
|
+
return projected;
|
|
1473
|
+
}
|
|
1474
|
+
async function stageWorkspaceGitlinks(workspacePath, projected) {
|
|
1475
|
+
if (projected.length === 0) return;
|
|
1476
|
+
const mountPaths = projected.map(({ mount }) => normalizedWorkspaceMountPath(mount.workspaceRelativePath));
|
|
1477
|
+
const desired = /* @__PURE__ */ new Map();
|
|
1478
|
+
projected.forEach(({ gitlinks }, index) => {
|
|
1479
|
+
for (const gitlink of gitlinks) desired.set(`${mountPaths[index]}/${gitlink.relativePath}`, gitlink.objectId);
|
|
1480
|
+
});
|
|
1481
|
+
const desiredRoots = new Set(desired.keys());
|
|
1482
|
+
const listed = await gitOutputAsync(
|
|
1483
|
+
workspacePath,
|
|
1484
|
+
["ls-files", "-z", "--stage", "--", ...mountPaths.map((mountPath) => `:(literal)${mountPath}`)],
|
|
1485
|
+
"inspect staged workspace mount entries"
|
|
1486
|
+
);
|
|
1487
|
+
const feed = [];
|
|
1488
|
+
const current = /* @__PURE__ */ new Map();
|
|
1489
|
+
const drop = (objectId, relativePath) => {
|
|
1490
|
+
feed.push(Buffer.concat([Buffer.from(`0 ${"0".repeat(objectId.length)} `), Buffer.from(relativePath), Buffer.from([0])]));
|
|
1491
|
+
};
|
|
1492
|
+
for (const record of listed.toString().split("\0")) {
|
|
1493
|
+
const match = /^([0-7]{6}) ([0-9a-f]{40,64}) [0-3]\t(.+)$/su.exec(record);
|
|
1494
|
+
if (!match) continue;
|
|
1495
|
+
const mode = match[1];
|
|
1496
|
+
const objectId = match[2];
|
|
1497
|
+
const relativePath = match[3];
|
|
1498
|
+
if (mode === "160000") {
|
|
1499
|
+
current.set(relativePath, objectId);
|
|
1500
|
+
if (!desired.has(relativePath)) drop(objectId, relativePath);
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
if (desired.has(relativePath) || hasAncestorIn(relativePath, desiredRoots)) drop(objectId, relativePath);
|
|
1504
|
+
}
|
|
1505
|
+
for (const [relativePath, objectId] of desired) {
|
|
1506
|
+
if (current.get(relativePath) === objectId) continue;
|
|
1507
|
+
feed.push(Buffer.concat([Buffer.from(`160000 ${objectId} `), Buffer.from(relativePath), Buffer.from([0])]));
|
|
1508
|
+
}
|
|
1509
|
+
if (feed.length === 0) return;
|
|
1510
|
+
await gitOutputAsync(workspacePath, ["update-index", "-z", "--index-info"], "stage workspace gitlinks", Buffer.concat(feed));
|
|
1511
|
+
}
|
|
1512
|
+
function hasAncestorIn(relativePath, ancestors) {
|
|
1513
|
+
let parent = path.posix.dirname(relativePath);
|
|
1514
|
+
while (parent !== ".") {
|
|
1515
|
+
if (ancestors.has(parent)) return true;
|
|
1516
|
+
parent = path.posix.dirname(parent);
|
|
1517
|
+
}
|
|
1518
|
+
return false;
|
|
1434
1519
|
}
|
|
1435
1520
|
function removeWorkspaceMounts(workspacePath, mounts) {
|
|
1436
1521
|
for (const mount of mounts) {
|
|
@@ -1470,6 +1555,24 @@ function restoreDeferredWorkspaceMountsFromHead(workspacePath, mounts) {
|
|
|
1470
1555
|
function workspaceMountOuterPath(workspacePath, mount) {
|
|
1471
1556
|
return path.join(workspacePath, ...mount.workspaceRelativePath.replace(/\\/g, "/").split("/"));
|
|
1472
1557
|
}
|
|
1558
|
+
function outerSubtreeGitlinks(workspacePath, mount) {
|
|
1559
|
+
const gitlinks = /* @__PURE__ */ new Map();
|
|
1560
|
+
const head = revParse(workspacePath, "HEAD");
|
|
1561
|
+
if (!head) return gitlinks;
|
|
1562
|
+
const subtree = `${head}:${normalizedWorkspaceMountPath(mount.workspaceRelativePath)}`;
|
|
1563
|
+
const listed = Bun.spawnSync(gitCommandArgs(["ls-tree", "-r", "-z", subtree]), {
|
|
1564
|
+
cwd: workspacePath,
|
|
1565
|
+
stdout: "pipe",
|
|
1566
|
+
stderr: "pipe",
|
|
1567
|
+
env: workerGitProcessEnvironment()
|
|
1568
|
+
});
|
|
1569
|
+
if (listed.exitCode !== 0) return gitlinks;
|
|
1570
|
+
for (const record of Buffer.from(listed.stdout).toString().split("\0")) {
|
|
1571
|
+
const match = /^160000 commit ([0-9a-f]{40,64})\t(.+)$/su.exec(record);
|
|
1572
|
+
if (match) gitlinks.set(match[2], match[1]);
|
|
1573
|
+
}
|
|
1574
|
+
return gitlinks;
|
|
1575
|
+
}
|
|
1473
1576
|
function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
|
|
1474
1577
|
const hydratedMountIds = [];
|
|
1475
1578
|
const skippedMountIds = [];
|
|
@@ -1513,6 +1616,9 @@ function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
|
|
|
1513
1616
|
sourceRoot,
|
|
1514
1617
|
targetRoot: mount.sourcePath,
|
|
1515
1618
|
sourceMode: "all",
|
|
1619
|
+
// The outer worktree shows a submodule as an empty directory; the tree
|
|
1620
|
+
// carries its pointer, which a Git checkout applies to its index (D4).
|
|
1621
|
+
sourceGitlinks: outerSubtreeGitlinks(workspacePath, mount),
|
|
1516
1622
|
// A missing checkout has no Git index to define git-only deletions. Its
|
|
1517
1623
|
// newly created directory is empty, so all-mode is equivalent and lets
|
|
1518
1624
|
// bootstrap hydration preserve the fetched tree before Git initializes.
|
|
@@ -1528,7 +1634,7 @@ async function selectMountsWithUnchangedOuterSubtree(workspacePath, receipt, hea
|
|
|
1528
1634
|
return basisHead && basisHead !== head ? [{ mount, basisHead }] : [];
|
|
1529
1635
|
});
|
|
1530
1636
|
if (candidates.length === 0) return /* @__PURE__ */ new Set();
|
|
1531
|
-
const objects = await
|
|
1637
|
+
const objects = await resolveGitObjectsAsync(
|
|
1532
1638
|
workspacePath,
|
|
1533
1639
|
candidates.flatMap(({ mount, basisHead }) => [
|
|
1534
1640
|
workspaceTreeObjectName(basisHead, mount.workspaceRelativePath),
|
|
@@ -1537,8 +1643,8 @@ async function selectMountsWithUnchangedOuterSubtree(workspacePath, receipt, hea
|
|
|
1537
1643
|
);
|
|
1538
1644
|
const unchanged = /* @__PURE__ */ new Set();
|
|
1539
1645
|
for (const { mount, basisHead } of candidates) {
|
|
1540
|
-
const before = objects.get(workspaceTreeObjectName(basisHead, mount.workspaceRelativePath)) ?? null;
|
|
1541
|
-
const after = objects.get(workspaceTreeObjectName(head, mount.workspaceRelativePath)) ?? null;
|
|
1646
|
+
const before = objects.get(workspaceTreeObjectName(basisHead, mount.workspaceRelativePath))?.objectId ?? null;
|
|
1647
|
+
const after = objects.get(workspaceTreeObjectName(head, mount.workspaceRelativePath))?.objectId ?? null;
|
|
1542
1648
|
if (before === after) unchanged.add(mount.id);
|
|
1543
1649
|
}
|
|
1544
1650
|
return unchanged;
|
|
@@ -1804,7 +1910,7 @@ async function diffSizeBytes(workspacePath, baseRevision, headRevision, limit, p
|
|
|
1804
1910
|
if (exitCode !== 0) throw new Error(`Measure workspace diff: ${stderr || `git exited ${exitCode}`}`);
|
|
1805
1911
|
return total;
|
|
1806
1912
|
}
|
|
1807
|
-
async function
|
|
1913
|
+
async function resolveGitObjectsAsync(workspacePath, names) {
|
|
1808
1914
|
const resolved = /* @__PURE__ */ new Map();
|
|
1809
1915
|
const unique = [...new Set(names)];
|
|
1810
1916
|
if (unique.length === 0) return resolved;
|
|
@@ -1827,21 +1933,43 @@ async function resolveGitObjectIdsAsync(workspacePath, names) {
|
|
|
1827
1933
|
throw new Error(`Resolve workspace objects: expected ${unique.length} results, received ${lines.length}`);
|
|
1828
1934
|
}
|
|
1829
1935
|
unique.forEach((name, index) => {
|
|
1830
|
-
const match = /^([0-9a-f]{40,64}) \S+ \d+$/.exec(lines[index]);
|
|
1831
|
-
resolved.set(name, match ? match[1] : null);
|
|
1936
|
+
const match = /^([0-9a-f]{40,64}) (\S+) \d+$/.exec(lines[index]);
|
|
1937
|
+
resolved.set(name, match ? { objectId: match[1], type: match[2] } : null);
|
|
1832
1938
|
});
|
|
1833
1939
|
return resolved;
|
|
1834
1940
|
}
|
|
1835
1941
|
function workspaceTreeObjectName(revision, workspaceRelativePath) {
|
|
1836
1942
|
return `${revision}:${normalizedWorkspaceMountPath(workspaceRelativePath)}`;
|
|
1837
1943
|
}
|
|
1944
|
+
async function pathsReplacedByGitlinks(workspacePath, baseRevision, headRevision) {
|
|
1945
|
+
const base = baseRevision ?? emptyTreeHash(workspacePath);
|
|
1946
|
+
const raw = await gitOutputAsync(
|
|
1947
|
+
workspacePath,
|
|
1948
|
+
["diff", "--raw", "--no-renames", "-z", base, headRevision],
|
|
1949
|
+
"inspect workspace gitlink changes"
|
|
1950
|
+
);
|
|
1951
|
+
const records = raw.toString().split("\0");
|
|
1952
|
+
const addedGitlinks = [];
|
|
1953
|
+
for (let index = 0; index + 1 < records.length; index += 2) {
|
|
1954
|
+
if (/^:[0-7]{6} 160000 \S+ \S+ A$/u.test(records[index])) addedGitlinks.push(records[index + 1]);
|
|
1955
|
+
}
|
|
1956
|
+
if (addedGitlinks.length === 0) return [];
|
|
1957
|
+
const objects = await resolveGitObjectsAsync(
|
|
1958
|
+
workspacePath,
|
|
1959
|
+
addedGitlinks.map((relativePath) => `${base}:${relativePath}`)
|
|
1960
|
+
);
|
|
1961
|
+
return addedGitlinks.filter((relativePath) => objects.get(`${base}:${relativePath}`)?.type === "tree").sort();
|
|
1962
|
+
}
|
|
1963
|
+
function gitlinkMigrationPathspecs(paths) {
|
|
1964
|
+
return paths.map((relativePath) => `:(exclude,literal)${relativePath}`);
|
|
1965
|
+
}
|
|
1838
1966
|
async function measureInitialPublicationDiffSize(input) {
|
|
1839
1967
|
const candidates = input.mounts.filter(
|
|
1840
1968
|
(mount) => typeof mount.initialPublicationSourceRelativePath === "string"
|
|
1841
1969
|
);
|
|
1842
1970
|
if (candidates.length === 0) return null;
|
|
1843
1971
|
const revisions = [input.localHead, ...input.remoteHead ? [input.remoteHead] : []];
|
|
1844
|
-
const objects = await
|
|
1972
|
+
const objects = await resolveGitObjectsAsync(
|
|
1845
1973
|
input.workspacePath,
|
|
1846
1974
|
candidates.flatMap(
|
|
1847
1975
|
(mount) => revisions.flatMap((revision) => [
|
|
@@ -1850,7 +1978,7 @@ async function measureInitialPublicationDiffSize(input) {
|
|
|
1850
1978
|
])
|
|
1851
1979
|
)
|
|
1852
1980
|
);
|
|
1853
|
-
const lookup = (revision, workspaceRelativePath) => revision ? objects.get(workspaceTreeObjectName(revision, workspaceRelativePath)) ?? null : null;
|
|
1981
|
+
const lookup = (revision, workspaceRelativePath) => revision ? objects.get(workspaceTreeObjectName(revision, workspaceRelativePath))?.objectId ?? null : null;
|
|
1854
1982
|
const initialPublications = candidates.flatMap((mount) => {
|
|
1855
1983
|
const published = lookup(input.localHead, mount.workspaceRelativePath);
|
|
1856
1984
|
if (!published || lookup(input.remoteHead, mount.workspaceRelativePath)) return [];
|
|
@@ -1858,16 +1986,14 @@ async function measureInitialPublicationDiffSize(input) {
|
|
|
1858
1986
|
return [{ mount, published, source }];
|
|
1859
1987
|
});
|
|
1860
1988
|
if (initialPublications.length === 0) return null;
|
|
1861
|
-
let size = await input.measure(
|
|
1862
|
-
|
|
1863
|
-
input.
|
|
1864
|
-
|
|
1865
|
-
input.limit,
|
|
1866
|
-
initialPublications.map(({ mount }) => `:(exclude,literal)${normalizedWorkspaceMountPath(mount.workspaceRelativePath)}`)
|
|
1867
|
-
);
|
|
1989
|
+
let size = await input.measure(input.workspacePath, input.remoteHead, input.localHead, input.limit, [
|
|
1990
|
+
...initialPublications.map(({ mount }) => `:(exclude,literal)${normalizedWorkspaceMountPath(mount.workspaceRelativePath)}`),
|
|
1991
|
+
...input.excludePathspecs ?? []
|
|
1992
|
+
]);
|
|
1868
1993
|
for (const { published, source } of initialPublications) {
|
|
1869
1994
|
if (size > input.limit) break;
|
|
1870
|
-
|
|
1995
|
+
const migrations = source ? await pathsReplacedByGitlinks(input.workspacePath, source, published) : [];
|
|
1996
|
+
size += await input.measure(input.workspacePath, source, published, input.limit - size, gitlinkMigrationPathspecs(migrations));
|
|
1871
1997
|
}
|
|
1872
1998
|
return { size, mountIds: initialPublications.map(({ mount }) => mount.id).sort() };
|
|
1873
1999
|
}
|
|
@@ -1957,6 +2083,11 @@ async function synchronizeWithFetchedHead(input) {
|
|
|
1957
2083
|
return rebased.ok ? { kind: "ready", updated: false, rebased: true } : { kind: "conflict", conflictPaths: rebased.conflictPaths, refs: rebased.refs, error: rebased.error };
|
|
1958
2084
|
}
|
|
1959
2085
|
async function synchronizeWorkspaceGit(input) {
|
|
2086
|
+
const remoteHeadFetches = { count: 0 };
|
|
2087
|
+
const result = await runWorkspaceGitSynchronization(input, remoteHeadFetches);
|
|
2088
|
+
return { ...result, remoteHeadFetches: remoteHeadFetches.count };
|
|
2089
|
+
}
|
|
2090
|
+
async function runWorkspaceGitSynchronization(input, remoteHeadFetches) {
|
|
1960
2091
|
const workspacePath = path.resolve(input.workspacePath);
|
|
1961
2092
|
const attemptId = input.attemptId ?? crypto.randomUUID();
|
|
1962
2093
|
const maxDiffBytes = input.maxDiffBytes ?? MAX_WORKSPACE_GIT_DIFF_BYTES;
|
|
@@ -1965,6 +2096,7 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
1965
2096
|
const requiredAncestorHeads = requiredWorkspaceAncestorHeads(input.requiredAncestorHeads);
|
|
1966
2097
|
const preserveResolutionInProgress = input.skipMountMirror === true;
|
|
1967
2098
|
const initial = await ensureWorkspaceGitClone({ ...input, workspacePath, preserveResolutionInProgress });
|
|
2099
|
+
if (initial.remoteHeadFetched) remoteHeadFetches.count += 1;
|
|
1968
2100
|
assertWorkspaceRemediationAncestry({
|
|
1969
2101
|
workspacePath,
|
|
1970
2102
|
currentHead: initial.localHead,
|
|
@@ -2012,11 +2144,22 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
2012
2144
|
const classifyMounts = (activeCandidates, additionalSkippedIds = []) => {
|
|
2013
2145
|
refreshCycleSkippedMounts();
|
|
2014
2146
|
const skippedIds = /* @__PURE__ */ new Set([...cycleSkippedMountIds, ...projectionSkippedIds, ...additionalSkippedIds]);
|
|
2147
|
+
const activeMountIds = [...new Set(activeCandidates.filter(({ id }) => !skippedIds.has(id)).map(({ id }) => id))].sort();
|
|
2015
2148
|
return {
|
|
2016
|
-
activeMountIds
|
|
2017
|
-
skippedMountIds: [...skippedIds].sort()
|
|
2149
|
+
activeMountIds,
|
|
2150
|
+
skippedMountIds: [...skippedIds].sort(),
|
|
2151
|
+
// An active mount's token is unchanged since capture (a moved token
|
|
2152
|
+
// lands it in cycleSkippedMountIds above), so this is the token its
|
|
2153
|
+
// projected bytes belong to.
|
|
2154
|
+
projectedMutationTokens: Object.fromEntries(
|
|
2155
|
+
activeMountIds.flatMap((id) => {
|
|
2156
|
+
const token = projectionMutationTokens.get(id);
|
|
2157
|
+
return token === void 0 ? [] : [[id, token]];
|
|
2158
|
+
})
|
|
2159
|
+
)
|
|
2018
2160
|
};
|
|
2019
2161
|
};
|
|
2162
|
+
const mountProjectionIsCurrent = (mount) => mount.lastProjectedMutationToken !== void 0 && !mount.deleteWhenSourceMissing && mount.preserveLocalOnHydrationBasisChange !== true && projectionBasisHead !== null && workspaceHydrationReceiptMountBasisHead(projectionBasisReceipt, mount) === projectionBasisHead && projectionMutationTokens.get(mount.id) === mount.lastProjectedMutationToken && mount.mutationToken?.() === mount.lastProjectedMutationToken;
|
|
2020
2163
|
if (!input.skipMountMirror) {
|
|
2021
2164
|
const mergeProjectionEnabled = process.env.R5D_WORKSPACE_MERGE_PROJECTION !== "0";
|
|
2022
2165
|
const mergeSupport = mergeProjectionEnabled ? workspaceMergeProjectionSupport() : { supported: false, error: "Workspace merge projection is disabled by R5D_WORKSPACE_MERGE_PROJECTION=0" };
|
|
@@ -2131,10 +2274,12 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
2131
2274
|
return classifyMounts([...selected.active, ...selected.tombstones], hydration.skippedMountIds);
|
|
2132
2275
|
};
|
|
2133
2276
|
let newlyStagedPaths = [];
|
|
2277
|
+
const projectedGitlinks = [];
|
|
2134
2278
|
const stageAndCommitWorkspace = async () => {
|
|
2135
2279
|
if (workspaceResolutionInProgress(workspacePath) || await workspaceHasUnmergedEntries(workspacePath)) {
|
|
2136
2280
|
throw new Error("Outer workspace clone has an unfinished merge/rebase; finish or abort it before synchronizing");
|
|
2137
2281
|
}
|
|
2282
|
+
await stageWorkspaceGitlinks(workspacePath, projectedGitlinks);
|
|
2138
2283
|
await gitAsync(workspacePath, ["add", "-A", "--", "."], "stage workspace working trees");
|
|
2139
2284
|
newlyStagedPaths = await stagedPaths(workspacePath);
|
|
2140
2285
|
if (newlyStagedPaths.length > 0) {
|
|
@@ -2153,6 +2298,7 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
2153
2298
|
}
|
|
2154
2299
|
};
|
|
2155
2300
|
const projectedFastMounts = [];
|
|
2301
|
+
const currentProjectionMounts = [];
|
|
2156
2302
|
if (!input.skipMountMirror) {
|
|
2157
2303
|
beginWorkspaceCheckoutTransition(workspacePath);
|
|
2158
2304
|
try {
|
|
@@ -2164,7 +2310,11 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
2164
2310
|
cycleSkippedMountIds.add(mount.id);
|
|
2165
2311
|
continue;
|
|
2166
2312
|
}
|
|
2167
|
-
|
|
2313
|
+
if (mountProjectionIsCurrent(mount)) {
|
|
2314
|
+
currentProjectionMounts.push(mount);
|
|
2315
|
+
continue;
|
|
2316
|
+
}
|
|
2317
|
+
projectedGitlinks.push(...mirrorMountsToWorkspace(workspacePath, [mount]));
|
|
2168
2318
|
projectedFastMounts.push(mount);
|
|
2169
2319
|
await yieldToWorkerEventLoop();
|
|
2170
2320
|
}
|
|
@@ -2197,7 +2347,7 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
2197
2347
|
const targetProjectionReceipt = receiptWithMountsAtHead(
|
|
2198
2348
|
projectionBasisReceipt,
|
|
2199
2349
|
projectedHead,
|
|
2200
|
-
projectedFastMounts,
|
|
2350
|
+
[...projectedFastMounts, ...currentProjectionMounts],
|
|
2201
2351
|
allProjectionReceiptMounts
|
|
2202
2352
|
);
|
|
2203
2353
|
const currentProjectionReceipt = await readHydratedWorkspaceReceiptAsync(workspacePath);
|
|
@@ -2210,7 +2360,9 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
2210
2360
|
}
|
|
2211
2361
|
await completeWorkspaceCheckoutTransition(workspacePath);
|
|
2212
2362
|
}
|
|
2213
|
-
|
|
2363
|
+
const prePublishRemoteHead = await resolveWorkspaceRemoteHead(workspacePath, input);
|
|
2364
|
+
if (prePublishRemoteHead.fetched) remoteHeadFetches.count += 1;
|
|
2365
|
+
let remoteHead = prePublishRemoteHead.head;
|
|
2214
2366
|
let rebaseCount = 0;
|
|
2215
2367
|
let updated = false;
|
|
2216
2368
|
for (let pushAttempt = 0; pushAttempt < maxPushAttempts; pushAttempt += 1) {
|
|
@@ -2263,7 +2415,9 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
2263
2415
|
}
|
|
2264
2416
|
const paths = await changedPaths(workspacePath, remoteHead, localHead);
|
|
2265
2417
|
const measureDiffSize = input.measureDiffSize ?? diffSizeBytes;
|
|
2266
|
-
|
|
2418
|
+
const gitlinkMigrations = paths.length > 0 ? await pathsReplacedByGitlinks(workspacePath, remoteHead, localHead) : [];
|
|
2419
|
+
const gitlinkMigrationExcludes = gitlinkMigrationPathspecs(gitlinkMigrations);
|
|
2420
|
+
let size = paths.length > 0 ? await measureDiffSize(workspacePath, remoteHead, localHead, maxDiffBytes, gitlinkMigrationExcludes) : 0;
|
|
2267
2421
|
input.assertStillAdmitted?.();
|
|
2268
2422
|
let initialPublicationMountIds;
|
|
2269
2423
|
if (paths.length > 0 && size > maxDiffBytes && !input.allowLargeDiff) {
|
|
@@ -2273,7 +2427,8 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
2273
2427
|
localHead,
|
|
2274
2428
|
mounts: input.mounts,
|
|
2275
2429
|
limit: maxDiffBytes,
|
|
2276
|
-
measure: measureDiffSize
|
|
2430
|
+
measure: measureDiffSize,
|
|
2431
|
+
excludePathspecs: gitlinkMigrationExcludes
|
|
2277
2432
|
});
|
|
2278
2433
|
input.assertStillAdmitted?.();
|
|
2279
2434
|
if (initialPublication && initialPublication.size <= maxDiffBytes) {
|
|
@@ -2281,6 +2436,11 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
2281
2436
|
initialPublicationMountIds = initialPublication.mountIds;
|
|
2282
2437
|
}
|
|
2283
2438
|
}
|
|
2439
|
+
let gitlinkMigrationConfirmed = false;
|
|
2440
|
+
if (gitlinkMigrations.length > 0 && size <= maxDiffBytes && !input.allowLargeDiff) {
|
|
2441
|
+
gitlinkMigrationConfirmed = await measureDiffSize(workspacePath, remoteHead, localHead, maxDiffBytes) > maxDiffBytes;
|
|
2442
|
+
input.assertStillAdmitted?.();
|
|
2443
|
+
}
|
|
2284
2444
|
if (paths.length > 0 && size > maxDiffBytes && !input.allowLargeDiff) {
|
|
2285
2445
|
const classifiedMounts = classifyMounts([...selected.active, ...selected.tombstones]);
|
|
2286
2446
|
return {
|
|
@@ -2325,7 +2485,7 @@ async function synchronizeWorkspaceGit(input) {
|
|
|
2325
2485
|
"push",
|
|
2326
2486
|
"--no-recurse-submodules"
|
|
2327
2487
|
];
|
|
2328
|
-
if (input.allowLargeDiff || initialPublicationMountIds) {
|
|
2488
|
+
if (input.allowLargeDiff || initialPublicationMountIds || gitlinkMigrationConfirmed) {
|
|
2329
2489
|
pushArgs.push(`--push-option=${WORKSPACE_GIT_CONFIRMED_LARGE_DIFF_PUSH_OPTION}`);
|
|
2330
2490
|
}
|
|
2331
2491
|
pushArgs.push("origin", `HEAD:refs/heads/${WORKSPACE_GIT_BRANCH}`);
|
|
@@ -2359,6 +2519,7 @@ ${push.stdout}`)) {
|
|
|
2359
2519
|
throw new Error(`Push workspace main: ${push.stderr || push.stdout || `git exited ${push.exitCode}`}`);
|
|
2360
2520
|
}
|
|
2361
2521
|
remoteHead = await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername);
|
|
2522
|
+
remoteHeadFetches.count += 1;
|
|
2362
2523
|
}
|
|
2363
2524
|
throw new Error(`Workspace push did not converge after ${maxPushAttempts} attempts`);
|
|
2364
2525
|
}
|
|
@@ -170,14 +170,6 @@ function indexMode(entry) {
|
|
|
170
170
|
function indexInfoRecord(mode, objectId, relativePath) {
|
|
171
171
|
return Buffer.concat([Buffer.from(`${mode} ${objectId} `), Buffer.from(relativePath), Buffer.from([0])]);
|
|
172
172
|
}
|
|
173
|
-
function basisGitlinks(workspacePath, basis) {
|
|
174
|
-
const listed = gitResult(workspacePath, ["ls-tree", "-r", "-z", `${basis.basisHead}:${basis.workspaceRelativePath}`]);
|
|
175
|
-
if (listed.exitCode !== 0) return [];
|
|
176
|
-
return nulRecords(listed.stdout).flatMap((record) => {
|
|
177
|
-
const match = /^160000 commit ([0-9a-f]{40,64})\t(.+)$/su.exec(record.toString());
|
|
178
|
-
return match ? [{ objectId: match[1], relativePath: match[2] }] : [];
|
|
179
|
-
});
|
|
180
|
-
}
|
|
181
173
|
function synthesizeMountTree(input) {
|
|
182
174
|
const sourceRoot = path.resolve(input.sourcePath);
|
|
183
175
|
const entries = inspectWorkingTree(sourceRoot, input.sourceMode);
|
|
@@ -188,19 +180,14 @@ function synthesizeMountTree(input) {
|
|
|
188
180
|
const records = [];
|
|
189
181
|
for (const [relativePath, entry] of [...entries.entries()].sort(([left], [right]) => left.localeCompare(right))) {
|
|
190
182
|
if (entry.kind === "directory") continue;
|
|
183
|
+
if (entry.kind === "gitlink") {
|
|
184
|
+
records.push(indexInfoRecord("160000", entry.objectId, relativePath));
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
191
187
|
const sourcePath = sourceEntryPath(sourceRoot, relativePath);
|
|
192
188
|
const objectId = entry.kind === "file" ? hashRegularFile(input.workspacePath, sourcePath, entry) : hashSymlink(input.workspacePath, sourcePath, entry);
|
|
193
189
|
records.push(indexInfoRecord(indexMode(entry), objectId, relativePath));
|
|
194
190
|
}
|
|
195
|
-
if (input.gitlinkBasis && input.sourceMode === "git") {
|
|
196
|
-
for (const gitlink of basisGitlinks(input.workspacePath, input.gitlinkBasis)) {
|
|
197
|
-
const populatedPrefix = `${gitlink.relativePath}/`;
|
|
198
|
-
const populated = [...entries.keys()].some((relativePath) => relativePath.startsWith(populatedPrefix));
|
|
199
|
-
if (entries.get(gitlink.relativePath)?.kind === "directory" && !populated) {
|
|
200
|
-
records.push(indexInfoRecord("160000", gitlink.objectId, gitlink.relativePath));
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
191
|
if (records.length > 0) {
|
|
205
192
|
git(input.workspacePath, ["update-index", "-z", "--index-info"], "populate workspace projection index", {
|
|
206
193
|
environment,
|
|
@@ -265,8 +252,7 @@ function synthesizeOursCommit(input) {
|
|
|
265
252
|
const mountTree = synthesizeMountTree({
|
|
266
253
|
workspacePath: input.workspacePath,
|
|
267
254
|
sourcePath: input.mount.sourcePath,
|
|
268
|
-
sourceMode: input.mount.sourceMode
|
|
269
|
-
gitlinkBasis: { basisHead: input.basisHead, workspaceRelativePath: input.workspaceRelativePath }
|
|
255
|
+
sourceMode: input.mount.sourceMode
|
|
270
256
|
});
|
|
271
257
|
const rootTree = graftMountTree({
|
|
272
258
|
workspacePath: input.workspacePath,
|
|
@@ -27,8 +27,43 @@ class WorkspaceMutationGate {
|
|
|
27
27
|
release();
|
|
28
28
|
}
|
|
29
29
|
}
|
|
30
|
+
/** A worktree create/delete lease; see the branch lane in the class comment. */
|
|
31
|
+
acquireBranchMutation() {
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
this.waiters.push({ kind: "branch", resolve });
|
|
34
|
+
this.drain();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async runBranchMutation(operation) {
|
|
38
|
+
const release = await this.acquireBranchMutation();
|
|
39
|
+
try {
|
|
40
|
+
return await operation();
|
|
41
|
+
} finally {
|
|
42
|
+
release();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Operations a branch mutation requested now would wait for: the running
|
|
47
|
+
* sync, if any, plus branch mutations queued behind it. Zero means it runs
|
|
48
|
+
* at once. Ordinary waiters are not counted because the branch lane admits
|
|
49
|
+
* ahead of them.
|
|
50
|
+
*/
|
|
51
|
+
branchMutationQueuePosition() {
|
|
52
|
+
if (!this.syncActive) return 0;
|
|
53
|
+
return 1 + this.waiters.filter((waiter) => waiter.kind === "branch").length;
|
|
54
|
+
}
|
|
30
55
|
drain() {
|
|
31
56
|
if (this.syncActive || this.waiters.length === 0) return;
|
|
57
|
+
for (let index = 0; index < this.waiters.length; ) {
|
|
58
|
+
const waiter = this.waiters[index];
|
|
59
|
+
if (waiter?.kind !== "branch") {
|
|
60
|
+
index += 1;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
this.waiters.splice(index, 1);
|
|
64
|
+
this.activeMutations += 1;
|
|
65
|
+
waiter.resolve(this.releaseMutation());
|
|
66
|
+
}
|
|
32
67
|
const first = this.waiters[0];
|
|
33
68
|
if (first?.kind === "sync") {
|
|
34
69
|
if (this.activeMutations > 0) return;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const RETAINED_OUTCOMES = /* @__PURE__ */ new Set(["no_change", "updated", "published", "large_diff_blocked"]);
|
|
2
|
+
class WorkspaceProjectionLedger {
|
|
3
|
+
projectedTokens = /* @__PURE__ */ new Map();
|
|
4
|
+
lastCycle = null;
|
|
5
|
+
static recordKey(mount) {
|
|
6
|
+
return `${mount.hydrationIncarnationKey}\0${mount.id}`;
|
|
7
|
+
}
|
|
8
|
+
get lastCycleResult() {
|
|
9
|
+
return this.lastCycle;
|
|
10
|
+
}
|
|
11
|
+
lastProjectedToken(mount) {
|
|
12
|
+
return this.projectedTokens.get(WorkspaceProjectionLedger.recordKey(mount));
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Record a completed cycle. `projectedMutationTokens` names the token each
|
|
16
|
+
* active mount carried when the cycle projected it; a mount absent from it
|
|
17
|
+
* keeps its earlier record. Any outcome that leaves the outer clone without
|
|
18
|
+
* a trustworthy projection clears the ledger.
|
|
19
|
+
*/
|
|
20
|
+
recordCycle(input) {
|
|
21
|
+
if (!RETAINED_OUTCOMES.has(input.result.outcome)) {
|
|
22
|
+
this.reset();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const mountsById = new Map(input.mounts.map((mount) => [mount.id, mount]));
|
|
26
|
+
for (const [mountId, token] of Object.entries(input.projectedMutationTokens ?? {})) {
|
|
27
|
+
const mount = mountsById.get(mountId);
|
|
28
|
+
if (mount) this.projectedTokens.set(WorkspaceProjectionLedger.recordKey(mount), token);
|
|
29
|
+
}
|
|
30
|
+
this.lastCycle = input.result;
|
|
31
|
+
}
|
|
32
|
+
reset() {
|
|
33
|
+
this.projectedTokens.clear();
|
|
34
|
+
this.lastCycle = null;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The previous cycle's result when every request targets a configured
|
|
38
|
+
* mount and no mount's token moved since its recorded projection. A request
|
|
39
|
+
* without a target (a workspace-rooted command may touch any checkout), a
|
|
40
|
+
* mount without a token (a pending deletion), or pending deletion work all
|
|
41
|
+
* require a real cycle.
|
|
42
|
+
*/
|
|
43
|
+
answerFromLastCycle(input) {
|
|
44
|
+
if (!this.lastCycle || input.pendingDeletions || input.targetMountIds.length === 0) return null;
|
|
45
|
+
const mountIds = new Set(input.mounts.map(({ id }) => id));
|
|
46
|
+
if (!input.targetMountIds.every((mountId) => mountId !== null && mountIds.has(mountId))) return null;
|
|
47
|
+
for (const mount of input.mounts) {
|
|
48
|
+
if (!mount.mutationToken) return null;
|
|
49
|
+
const recorded = this.projectedTokens.get(WorkspaceProjectionLedger.recordKey(mount));
|
|
50
|
+
if (recorded === void 0 || recorded !== mount.mutationToken()) return null;
|
|
51
|
+
}
|
|
52
|
+
return this.lastCycle;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export {
|
|
56
|
+
WorkspaceProjectionLedger
|
|
57
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
class WorkspaceSyncCoalescer {
|
|
2
|
+
constructor(options) {
|
|
3
|
+
this.options = options;
|
|
4
|
+
}
|
|
5
|
+
options;
|
|
6
|
+
pendingBatch = null;
|
|
7
|
+
batchesStarted = 0;
|
|
8
|
+
/** Requests attached to the batch that has not started yet. */
|
|
9
|
+
get queuedRequestCount() {
|
|
10
|
+
return this.pendingBatch?.requests.length ?? 0;
|
|
11
|
+
}
|
|
12
|
+
/** Batches whose body has been entered, whether or not they ran a cycle. */
|
|
13
|
+
get startedBatchCount() {
|
|
14
|
+
return this.batchesStarted;
|
|
15
|
+
}
|
|
16
|
+
request(request) {
|
|
17
|
+
const queued = this.pendingBatch;
|
|
18
|
+
if (queued) {
|
|
19
|
+
queued.requests.push(request);
|
|
20
|
+
return queued.settlement;
|
|
21
|
+
}
|
|
22
|
+
const batch = { requests: [request], settlement: Promise.resolve() };
|
|
23
|
+
this.pendingBatch = batch;
|
|
24
|
+
batch.settlement = this.options.runExclusive(async () => {
|
|
25
|
+
if (this.pendingBatch === batch) this.pendingBatch = null;
|
|
26
|
+
this.batchesStarted += 1;
|
|
27
|
+
return await this.options.runBatch(batch.requests);
|
|
28
|
+
});
|
|
29
|
+
return batch.settlement;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export {
|
|
33
|
+
WorkspaceSyncCoalescer
|
|
34
|
+
};
|