@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.
Files changed (33) hide show
  1. package/README.md +16 -3
  2. package/dist/cjs/main.cjs +390 -322
  3. package/dist/cjs/package.json +1 -1
  4. package/dist/cjs/project-mirror-fetch-policy.cjs +48 -7
  5. package/dist/cjs/project-mirror-refs-token.cjs +36 -2
  6. package/dist/cjs/project-worktrees.cjs +49 -32
  7. package/dist/cjs/working-tree-mirror.cjs +347 -99
  8. package/dist/cjs/workspace-git-sync.cjs +189 -28
  9. package/dist/cjs/workspace-merge-projection.cjs +5 -19
  10. package/dist/cjs/workspace-mutation-gate.cjs +35 -0
  11. package/dist/cjs/workspace-projection-ledger.cjs +81 -0
  12. package/dist/cjs/workspace-sync-coalescer.cjs +58 -0
  13. package/dist/mjs/main.mjs +395 -324
  14. package/dist/mjs/package.json +1 -1
  15. package/dist/mjs/project-mirror-fetch-policy.mjs +46 -6
  16. package/dist/mjs/project-mirror-refs-token.mjs +30 -1
  17. package/dist/mjs/project-worktrees.mjs +52 -33
  18. package/dist/mjs/working-tree-mirror.mjs +347 -99
  19. package/dist/mjs/workspace-git-sync.mjs +189 -28
  20. package/dist/mjs/workspace-merge-projection.mjs +5 -19
  21. package/dist/mjs/workspace-mutation-gate.mjs +35 -0
  22. package/dist/mjs/workspace-projection-ledger.mjs +57 -0
  23. package/dist/mjs/workspace-sync-coalescer.mjs +34 -0
  24. package/dist/types/main.d.ts +29 -17
  25. package/dist/types/project-mirror-fetch-policy.d.ts +70 -15
  26. package/dist/types/project-mirror-refs-token.d.ts +34 -0
  27. package/dist/types/project-worktrees.d.ts +26 -8
  28. package/dist/types/working-tree-mirror.d.ts +56 -11
  29. package/dist/types/workspace-git-sync.d.ts +41 -4
  30. package/dist/types/workspace-mutation-gate.d.ts +24 -3
  31. package/dist/types/workspace-projection-ledger.d.ts +59 -0
  32. package/dist/types/workspace-sync-coalescer.d.ts +28 -0
  33. 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({ sourceRoot: outerPath, targetRoot: sourcePath, sourceMode: "all", deletionMode: "git" });
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 remoteHead = await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername);
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 resolveGitObjectIdsAsync(
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 resolveGitObjectIdsAsync(workspacePath, names) {
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 resolveGitObjectIdsAsync(
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
- input.workspacePath,
1863
- input.remoteHead,
1864
- input.localHead,
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
- size += await input.measure(input.workspacePath, source, published, input.limit - size);
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: [...new Set(activeCandidates.filter(({ id }) => !skippedIds.has(id)).map(({ id }) => id))].sort(),
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
- mirrorMountsToWorkspace(workspacePath, [mount]);
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
- let remoteHead = await fetchWorkspaceHead(workspacePath, input.remoteUrl, input.credentialHelper, input.credentialUsername);
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
- let size = paths.length > 0 ? await measureDiffSize(workspacePath, remoteHead, localHead, maxDiffBytes) : 0;
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
+ };