@ricsam/r5d-worker 0.0.99 → 0.0.101

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.
@@ -128,6 +128,22 @@ async function gitAsync(cwd, args, action) {
128
128
  if (result.exitCode !== 0) throw new Error(`${action}: ${result.stderr || result.stdout || `git exited ${result.exitCode}`}`);
129
129
  return result.stdout;
130
130
  }
131
+ async function gitOutputAsync(cwd, args, action, input) {
132
+ const subprocess = Bun.spawn(gitCommandArgs(args), {
133
+ cwd,
134
+ stdin: input ?? "ignore",
135
+ stdout: "pipe",
136
+ stderr: "pipe",
137
+ env: (0, import_git_process_environment.workerGitProcessEnvironment)()
138
+ });
139
+ const [stdout, stderr, exitCode] = await Promise.all([
140
+ new Response(subprocess.stdout).arrayBuffer(),
141
+ new Response(subprocess.stderr).text(),
142
+ subprocess.exited
143
+ ]);
144
+ if (exitCode !== 0) throw new Error(`${action}: ${stderr.trim() || `git exited ${exitCode}`}`);
145
+ return Buffer.from(stdout);
146
+ }
131
147
  async function tryGitAsync(cwd, args) {
132
148
  return (await gitResultAsync(cwd, args)).exitCode === 0;
133
149
  }
@@ -1139,7 +1155,13 @@ function beginHydrationTransaction(workspacePath, mounts, targetReceipt) {
1139
1155
  candidate: outerPath,
1140
1156
  label: `Workspace mount ${mount.id} outer source`
1141
1157
  });
1142
- const plan = (0, import_working_tree_mirror.planWorkingTreeMirror)({ sourceRoot: outerPath, targetRoot: sourcePath, sourceMode: "all", deletionMode: "git" });
1158
+ const plan = (0, import_working_tree_mirror.planWorkingTreeMirror)({
1159
+ sourceRoot: outerPath,
1160
+ targetRoot: sourcePath,
1161
+ sourceMode: "all",
1162
+ deletionMode: "git",
1163
+ sourceGitlinks: outerSubtreeGitlinks(workspacePath, mount)
1164
+ });
1143
1165
  scope = (0, import_working_tree_mirror.captureWorkingTreeMirrorPlan)({
1144
1166
  plan,
1145
1167
  targetRoot: sourcePath,
@@ -1450,6 +1472,7 @@ function activeMounts(mounts) {
1450
1472
  return { active, tombstones, skipped };
1451
1473
  }
1452
1474
  function mirrorMountsToWorkspace(workspacePath, mounts) {
1475
+ const projected = [];
1453
1476
  for (const mount of mounts) {
1454
1477
  (0, import_workspace_mount_boundary.assertManagedDirectoryPath)({
1455
1478
  trustedRoot: mount.durabilityRootPath,
@@ -1461,13 +1484,61 @@ function mirrorMountsToWorkspace(workspacePath, mounts) {
1461
1484
  candidate: import_node_path.default.join(workspacePath, ...mount.workspaceRelativePath.replace(/\\/g, "/").split("/")),
1462
1485
  label: `Workspace mount ${mount.id} outer target`
1463
1486
  });
1464
- (0, import_working_tree_mirror.mirrorWorkingTree)({
1487
+ const mirrored = (0, import_working_tree_mirror.mirrorWorkingTree)({
1465
1488
  sourceRoot: mount.sourcePath,
1466
1489
  targetRoot: import_node_path.default.join(workspacePath, ...mount.workspaceRelativePath.replace(/\\/g, "/").split("/")),
1467
1490
  sourceMode: mount.sourceMode,
1468
1491
  deletionMode: "all"
1469
1492
  });
1493
+ projected.push({ mount, gitlinks: mirrored.gitlinks });
1494
+ }
1495
+ return projected;
1496
+ }
1497
+ async function stageWorkspaceGitlinks(workspacePath, projected) {
1498
+ if (projected.length === 0) return;
1499
+ const mountPaths = projected.map(({ mount }) => normalizedWorkspaceMountPath(mount.workspaceRelativePath));
1500
+ const desired = /* @__PURE__ */ new Map();
1501
+ projected.forEach(({ gitlinks }, index) => {
1502
+ for (const gitlink of gitlinks) desired.set(`${mountPaths[index]}/${gitlink.relativePath}`, gitlink.objectId);
1503
+ });
1504
+ const desiredRoots = new Set(desired.keys());
1505
+ const listed = await gitOutputAsync(
1506
+ workspacePath,
1507
+ ["ls-files", "-z", "--stage", "--", ...mountPaths.map((mountPath) => `:(literal)${mountPath}`)],
1508
+ "inspect staged workspace mount entries"
1509
+ );
1510
+ const feed = [];
1511
+ const current = /* @__PURE__ */ new Map();
1512
+ const drop = (objectId, relativePath) => {
1513
+ feed.push(Buffer.concat([Buffer.from(`0 ${"0".repeat(objectId.length)} `), Buffer.from(relativePath), Buffer.from([0])]));
1514
+ };
1515
+ for (const record of listed.toString().split("\0")) {
1516
+ const match = /^([0-7]{6}) ([0-9a-f]{40,64}) [0-3]\t(.+)$/su.exec(record);
1517
+ if (!match) continue;
1518
+ const mode = match[1];
1519
+ const objectId = match[2];
1520
+ const relativePath = match[3];
1521
+ if (mode === "160000") {
1522
+ current.set(relativePath, objectId);
1523
+ if (!desired.has(relativePath)) drop(objectId, relativePath);
1524
+ continue;
1525
+ }
1526
+ if (desired.has(relativePath) || hasAncestorIn(relativePath, desiredRoots)) drop(objectId, relativePath);
1470
1527
  }
1528
+ for (const [relativePath, objectId] of desired) {
1529
+ if (current.get(relativePath) === objectId) continue;
1530
+ feed.push(Buffer.concat([Buffer.from(`160000 ${objectId} `), Buffer.from(relativePath), Buffer.from([0])]));
1531
+ }
1532
+ if (feed.length === 0) return;
1533
+ await gitOutputAsync(workspacePath, ["update-index", "-z", "--index-info"], "stage workspace gitlinks", Buffer.concat(feed));
1534
+ }
1535
+ function hasAncestorIn(relativePath, ancestors) {
1536
+ let parent = import_node_path.default.posix.dirname(relativePath);
1537
+ while (parent !== ".") {
1538
+ if (ancestors.has(parent)) return true;
1539
+ parent = import_node_path.default.posix.dirname(parent);
1540
+ }
1541
+ return false;
1471
1542
  }
1472
1543
  function removeWorkspaceMounts(workspacePath, mounts) {
1473
1544
  for (const mount of mounts) {
@@ -1507,6 +1578,24 @@ function restoreDeferredWorkspaceMountsFromHead(workspacePath, mounts) {
1507
1578
  function workspaceMountOuterPath(workspacePath, mount) {
1508
1579
  return import_node_path.default.join(workspacePath, ...mount.workspaceRelativePath.replace(/\\/g, "/").split("/"));
1509
1580
  }
1581
+ function outerSubtreeGitlinks(workspacePath, mount) {
1582
+ const gitlinks = /* @__PURE__ */ new Map();
1583
+ const head = revParse(workspacePath, "HEAD");
1584
+ if (!head) return gitlinks;
1585
+ const subtree = `${head}:${normalizedWorkspaceMountPath(mount.workspaceRelativePath)}`;
1586
+ const listed = Bun.spawnSync(gitCommandArgs(["ls-tree", "-r", "-z", subtree]), {
1587
+ cwd: workspacePath,
1588
+ stdout: "pipe",
1589
+ stderr: "pipe",
1590
+ env: (0, import_git_process_environment.workerGitProcessEnvironment)()
1591
+ });
1592
+ if (listed.exitCode !== 0) return gitlinks;
1593
+ for (const record of Buffer.from(listed.stdout).toString().split("\0")) {
1594
+ const match = /^160000 commit ([0-9a-f]{40,64})\t(.+)$/su.exec(record);
1595
+ if (match) gitlinks.set(match[2], match[1]);
1596
+ }
1597
+ return gitlinks;
1598
+ }
1510
1599
  function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
1511
1600
  const hydratedMountIds = [];
1512
1601
  const skippedMountIds = [];
@@ -1550,6 +1639,9 @@ function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
1550
1639
  sourceRoot,
1551
1640
  targetRoot: mount.sourcePath,
1552
1641
  sourceMode: "all",
1642
+ // The outer worktree shows a submodule as an empty directory; the tree
1643
+ // carries its pointer, which a Git checkout applies to its index (D4).
1644
+ sourceGitlinks: outerSubtreeGitlinks(workspacePath, mount),
1553
1645
  // A missing checkout has no Git index to define git-only deletions. Its
1554
1646
  // newly created directory is empty, so all-mode is equivalent and lets
1555
1647
  // bootstrap hydration preserve the fetched tree before Git initializes.
@@ -1565,7 +1657,7 @@ async function selectMountsWithUnchangedOuterSubtree(workspacePath, receipt, hea
1565
1657
  return basisHead && basisHead !== head ? [{ mount, basisHead }] : [];
1566
1658
  });
1567
1659
  if (candidates.length === 0) return /* @__PURE__ */ new Set();
1568
- const objects = await resolveGitObjectIdsAsync(
1660
+ const objects = await resolveGitObjectsAsync(
1569
1661
  workspacePath,
1570
1662
  candidates.flatMap(({ mount, basisHead }) => [
1571
1663
  workspaceTreeObjectName(basisHead, mount.workspaceRelativePath),
@@ -1574,8 +1666,8 @@ async function selectMountsWithUnchangedOuterSubtree(workspacePath, receipt, hea
1574
1666
  );
1575
1667
  const unchanged = /* @__PURE__ */ new Set();
1576
1668
  for (const { mount, basisHead } of candidates) {
1577
- const before = objects.get(workspaceTreeObjectName(basisHead, mount.workspaceRelativePath)) ?? null;
1578
- const after = objects.get(workspaceTreeObjectName(head, mount.workspaceRelativePath)) ?? null;
1669
+ const before = objects.get(workspaceTreeObjectName(basisHead, mount.workspaceRelativePath))?.objectId ?? null;
1670
+ const after = objects.get(workspaceTreeObjectName(head, mount.workspaceRelativePath))?.objectId ?? null;
1579
1671
  if (before === after) unchanged.add(mount.id);
1580
1672
  }
1581
1673
  return unchanged;
@@ -1601,56 +1693,67 @@ async function hydrateWorkspaceGitMountsTransactionally(input) {
1601
1693
  const busyMountIds = new Set(input.ignoreBusy ? [] : candidateMounts.filter((mount) => mount.busy?.()).map(({ id }) => id));
1602
1694
  const requestedIds = new Set(input.mounts.map(({ id }) => id));
1603
1695
  const requiredIds = new Set(requiredMounts.map(({ id }) => id));
1604
- const hydrationMounts2 = input.mounts.filter(({ id }) => !busyMountIds.has(id));
1605
- const advancedDurabilityMounts2 = durabilityMounts.filter(
1696
+ const hydrationMounts = input.mounts.filter(({ id }) => !busyMountIds.has(id));
1697
+ const advancedDurabilityMounts = durabilityMounts.filter(
1606
1698
  ({ id }) => !busyMountIds.has(id) && (requestedIds.has(id) || !requiredIds.has(id))
1607
1699
  );
1608
1700
  const unavailableRequiredIds = requiredMounts.filter(({ id }) => busyMountIds.has(id) || !requestedIds.has(id)).map(({ id }) => id);
1609
1701
  const carriedMounts = carryMounts.filter(({ id }) => !busyMountIds.has(id));
1610
1702
  return {
1611
1703
  busyMountIds,
1612
- hydrationMounts: hydrationMounts2,
1613
- advancedDurabilityMounts: advancedDurabilityMounts2,
1704
+ hydrationMounts,
1705
+ advancedDurabilityMounts,
1614
1706
  skippedMountIds: [.../* @__PURE__ */ new Set([...busyMountIds, ...unavailableRequiredIds])].sort(),
1615
- targetReceipt: targetHead ? receiptWithMountsAtHead(receiptBeforeTransaction, targetHead, [...advancedDurabilityMounts2, ...carriedMounts], receiptMounts) : null
1707
+ targetReceipt: targetHead ? receiptWithMountsAtHead(receiptBeforeTransaction, targetHead, [...advancedDurabilityMounts, ...carriedMounts], receiptMounts) : null
1616
1708
  };
1617
1709
  };
1618
1710
  const noopCandidate = computeSelection();
1619
1711
  if (noopCandidate.hydrationMounts.length === 0 && noopCandidate.advancedDurabilityMounts.length === 0 && noopCandidate.targetReceipt && lstatIfExists(hydrationTransactionPath(workspacePath)) === null && workspaceHydrationReceiptsEqual(receiptBeforeTransaction, noopCandidate.targetReceipt) && hydratedWorkspaceReceiptFileIsV3(workspacePath) && await workspaceBasisRefsAreCurrentAsync(workspacePath, noopCandidate.targetReceipt)) {
1620
1712
  return { hydratedMountIds: [], skippedMountIds: noopCandidate.skippedMountIds };
1621
1713
  }
1622
- const selection = computeSelection();
1623
- const { hydrationMounts, advancedDurabilityMounts, targetReceipt } = selection;
1624
- const { manifest, durabilityScopes } = beginHydrationTransaction(workspacePath, hydrationMounts, targetReceipt);
1714
+ const hooks = input.hydrationHooks;
1715
+ const context = {
1716
+ transactionId: crypto.randomUUID(),
1717
+ workspacePath,
1718
+ mountIds: [...new Set([...noopCandidate.hydrationMounts, ...noopCandidate.advancedDurabilityMounts].map(({ id }) => id))].sort()
1719
+ };
1720
+ if (hooks) await hooks.beforeTransaction(context);
1625
1721
  try {
1626
- const hydration = hydrateWorkspaceGitMountsRaw(workspacePath, hydrationMounts, { ignoreBusy: true });
1627
- const expectedHydratedIds = hydrationMounts.map(({ id }) => id).sort();
1628
- if (hydration.skippedMountIds.length > 0 || hydration.hydratedMountIds.length !== expectedHydratedIds.length || hydration.hydratedMountIds.some((id, index) => id !== expectedHydratedIds[index])) {
1629
- throw new Error("Workspace hydration did not include every required mount");
1630
- }
1631
- fsyncHydratedWorkspaceMounts(advancedDurabilityMounts, durabilityScopes);
1632
- markHydrationTransactionDurable(workspacePath);
1633
- if (targetReceipt) updateHydratedWorkspaceReceipt(workspacePath, targetReceipt);
1634
- removeHydrationTransaction(workspacePath);
1635
- return {
1636
- hydratedMountIds: hydration.hydratedMountIds,
1637
- skippedMountIds: selection.skippedMountIds
1638
- };
1639
- } catch (error) {
1640
- const transactionPath = hydrationTransactionPath(workspacePath);
1641
- if (lstatIfExists(transactionPath) && hydrationTransactionCommitted(transactionPath)) {
1642
- throw error;
1643
- }
1722
+ const selection = computeSelection();
1723
+ const { hydrationMounts, advancedDurabilityMounts, targetReceipt } = selection;
1724
+ const { manifest, durabilityScopes } = beginHydrationTransaction(workspacePath, hydrationMounts, targetReceipt);
1644
1725
  try {
1645
- restoreHydrationTransaction(workspacePath, manifest);
1726
+ const hydration = hydrateWorkspaceGitMountsRaw(workspacePath, hydrationMounts, { ignoreBusy: true });
1727
+ const expectedHydratedIds = hydrationMounts.map(({ id }) => id).sort();
1728
+ if (hydration.skippedMountIds.length > 0 || hydration.hydratedMountIds.length !== expectedHydratedIds.length || hydration.hydratedMountIds.some((id, index) => id !== expectedHydratedIds[index])) {
1729
+ throw new Error("Workspace hydration did not include every required mount");
1730
+ }
1731
+ fsyncHydratedWorkspaceMounts(advancedDurabilityMounts, durabilityScopes);
1732
+ markHydrationTransactionDurable(workspacePath);
1733
+ if (targetReceipt) updateHydratedWorkspaceReceipt(workspacePath, targetReceipt);
1646
1734
  removeHydrationTransaction(workspacePath);
1647
- } catch (restoreError) {
1648
- throw new AggregateError(
1649
- [error, restoreError],
1650
- `Workspace hydration failed and its durable snapshot could not be restored: ${error instanceof Error ? error.message : String(error)}`
1651
- );
1735
+ return {
1736
+ hydratedMountIds: hydration.hydratedMountIds,
1737
+ skippedMountIds: selection.skippedMountIds
1738
+ };
1739
+ } catch (error) {
1740
+ const transactionPath = hydrationTransactionPath(workspacePath);
1741
+ if (lstatIfExists(transactionPath) && hydrationTransactionCommitted(transactionPath)) {
1742
+ throw error;
1743
+ }
1744
+ try {
1745
+ restoreHydrationTransaction(workspacePath, manifest);
1746
+ removeHydrationTransaction(workspacePath);
1747
+ } catch (restoreError) {
1748
+ throw new AggregateError(
1749
+ [error, restoreError],
1750
+ `Workspace hydration failed and its durable snapshot could not be restored: ${error instanceof Error ? error.message : String(error)}`
1751
+ );
1752
+ }
1753
+ throw error;
1652
1754
  }
1653
- throw error;
1755
+ } finally {
1756
+ hooks?.afterTransaction(context);
1654
1757
  }
1655
1758
  }
1656
1759
  async function hydrateWorkspaceGitMounts(workspacePath, mounts, options = {}) {
@@ -1662,7 +1765,8 @@ async function hydrateWorkspaceGitMounts(workspacePath, mounts, options = {}) {
1662
1765
  return hydrateWorkspaceGitMountsTransactionally({
1663
1766
  workspacePath,
1664
1767
  mounts,
1665
- ignoreBusy: options.ignoreBusy
1768
+ ignoreBusy: options.ignoreBusy,
1769
+ hydrationHooks: options.hydrationHooks
1666
1770
  });
1667
1771
  }
1668
1772
  async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {}) {
@@ -1702,7 +1806,8 @@ async function recoverWorkspaceGitHydration(workspacePath, mounts, options = {})
1702
1806
  receiptMounts: configuredBasisMounts,
1703
1807
  requiredMounts: hydrationTargets,
1704
1808
  ignoreBusy: options.ignoreBusy,
1705
- recordCurrentHead: true
1809
+ recordCurrentHead: true,
1810
+ hydrationHooks: options.hydrationHooks
1706
1811
  });
1707
1812
  void hydration;
1708
1813
  }
@@ -1753,7 +1858,8 @@ async function resetWorkspaceGit(input) {
1753
1858
  durabilityMounts: [...requiredLiveMounts, ...selected.tombstones, ...durablyAbsentMounts],
1754
1859
  receiptMounts: input.mounts,
1755
1860
  requiredMounts: requiredLiveMounts,
1756
- recordCurrentHead: true
1861
+ recordCurrentHead: true,
1862
+ hydrationHooks: input.hydrationHooks
1757
1863
  });
1758
1864
  const requiredLiveIds = requiredLiveMounts.map(({ id }) => id).sort();
1759
1865
  const resetHead = revParse(workspacePath, "HEAD");
@@ -1827,7 +1933,7 @@ async function diffSizeBytes(workspacePath, baseRevision, headRevision, limit, p
1827
1933
  if (exitCode !== 0) throw new Error(`Measure workspace diff: ${stderr || `git exited ${exitCode}`}`);
1828
1934
  return total;
1829
1935
  }
1830
- async function resolveGitObjectIdsAsync(workspacePath, names) {
1936
+ async function resolveGitObjectsAsync(workspacePath, names) {
1831
1937
  const resolved = /* @__PURE__ */ new Map();
1832
1938
  const unique = [...new Set(names)];
1833
1939
  if (unique.length === 0) return resolved;
@@ -1850,21 +1956,43 @@ async function resolveGitObjectIdsAsync(workspacePath, names) {
1850
1956
  throw new Error(`Resolve workspace objects: expected ${unique.length} results, received ${lines.length}`);
1851
1957
  }
1852
1958
  unique.forEach((name, index) => {
1853
- const match = /^([0-9a-f]{40,64}) \S+ \d+$/.exec(lines[index]);
1854
- resolved.set(name, match ? match[1] : null);
1959
+ const match = /^([0-9a-f]{40,64}) (\S+) \d+$/.exec(lines[index]);
1960
+ resolved.set(name, match ? { objectId: match[1], type: match[2] } : null);
1855
1961
  });
1856
1962
  return resolved;
1857
1963
  }
1858
1964
  function workspaceTreeObjectName(revision, workspaceRelativePath) {
1859
1965
  return `${revision}:${normalizedWorkspaceMountPath(workspaceRelativePath)}`;
1860
1966
  }
1967
+ async function pathsReplacedByGitlinks(workspacePath, baseRevision, headRevision) {
1968
+ const base = baseRevision ?? emptyTreeHash(workspacePath);
1969
+ const raw = await gitOutputAsync(
1970
+ workspacePath,
1971
+ ["diff", "--raw", "--no-renames", "-z", base, headRevision],
1972
+ "inspect workspace gitlink changes"
1973
+ );
1974
+ const records = raw.toString().split("\0");
1975
+ const addedGitlinks = [];
1976
+ for (let index = 0; index + 1 < records.length; index += 2) {
1977
+ if (/^:[0-7]{6} 160000 \S+ \S+ A$/u.test(records[index])) addedGitlinks.push(records[index + 1]);
1978
+ }
1979
+ if (addedGitlinks.length === 0) return [];
1980
+ const objects = await resolveGitObjectsAsync(
1981
+ workspacePath,
1982
+ addedGitlinks.map((relativePath) => `${base}:${relativePath}`)
1983
+ );
1984
+ return addedGitlinks.filter((relativePath) => objects.get(`${base}:${relativePath}`)?.type === "tree").sort();
1985
+ }
1986
+ function gitlinkMigrationPathspecs(paths) {
1987
+ return paths.map((relativePath) => `:(exclude,literal)${relativePath}`);
1988
+ }
1861
1989
  async function measureInitialPublicationDiffSize(input) {
1862
1990
  const candidates = input.mounts.filter(
1863
1991
  (mount) => typeof mount.initialPublicationSourceRelativePath === "string"
1864
1992
  );
1865
1993
  if (candidates.length === 0) return null;
1866
1994
  const revisions = [input.localHead, ...input.remoteHead ? [input.remoteHead] : []];
1867
- const objects = await resolveGitObjectIdsAsync(
1995
+ const objects = await resolveGitObjectsAsync(
1868
1996
  input.workspacePath,
1869
1997
  candidates.flatMap(
1870
1998
  (mount) => revisions.flatMap((revision) => [
@@ -1873,7 +2001,7 @@ async function measureInitialPublicationDiffSize(input) {
1873
2001
  ])
1874
2002
  )
1875
2003
  );
1876
- const lookup = (revision, workspaceRelativePath) => revision ? objects.get(workspaceTreeObjectName(revision, workspaceRelativePath)) ?? null : null;
2004
+ const lookup = (revision, workspaceRelativePath) => revision ? objects.get(workspaceTreeObjectName(revision, workspaceRelativePath))?.objectId ?? null : null;
1877
2005
  const initialPublications = candidates.flatMap((mount) => {
1878
2006
  const published = lookup(input.localHead, mount.workspaceRelativePath);
1879
2007
  if (!published || lookup(input.remoteHead, mount.workspaceRelativePath)) return [];
@@ -1881,16 +2009,14 @@ async function measureInitialPublicationDiffSize(input) {
1881
2009
  return [{ mount, published, source }];
1882
2010
  });
1883
2011
  if (initialPublications.length === 0) return null;
1884
- let size = await input.measure(
1885
- input.workspacePath,
1886
- input.remoteHead,
1887
- input.localHead,
1888
- input.limit,
1889
- initialPublications.map(({ mount }) => `:(exclude,literal)${normalizedWorkspaceMountPath(mount.workspaceRelativePath)}`)
1890
- );
2012
+ let size = await input.measure(input.workspacePath, input.remoteHead, input.localHead, input.limit, [
2013
+ ...initialPublications.map(({ mount }) => `:(exclude,literal)${normalizedWorkspaceMountPath(mount.workspaceRelativePath)}`),
2014
+ ...input.excludePathspecs ?? []
2015
+ ]);
1891
2016
  for (const { published, source } of initialPublications) {
1892
2017
  if (size > input.limit) break;
1893
- size += await input.measure(input.workspacePath, source, published, input.limit - size);
2018
+ const migrations = source ? await pathsReplacedByGitlinks(input.workspacePath, source, published) : [];
2019
+ size += await input.measure(input.workspacePath, source, published, input.limit - size, gitlinkMigrationPathspecs(migrations));
1894
2020
  }
1895
2021
  return { size, mountIds: initialPublications.map(({ mount }) => mount.id).sort() };
1896
2022
  }
@@ -2008,7 +2134,8 @@ async function synchronizeWorkspaceGit(input) {
2008
2134
  await recoverWorkspaceGitHydration(workspacePath, input.mounts, {
2009
2135
  ...deferredMountIds.size > 0 ? { deferMountIds: deferredMountIds } : {},
2010
2136
  preserveResolutionInProgress,
2011
- preserveStaleBases: true
2137
+ preserveStaleBases: true,
2138
+ hydrationHooks: input.hydrationHooks
2012
2139
  });
2013
2140
  const selected = activeMounts(input.mounts);
2014
2141
  const projectionMutationTokens = new Map(
@@ -2134,7 +2261,8 @@ async function synchronizeWorkspaceGit(input) {
2134
2261
  carryMounts: uncoveredCompletionMounts.filter(({ id }) => unchangedMountIds.has(id)),
2135
2262
  receiptMounts: completionReceiptMounts,
2136
2263
  requiredMounts: uncoveredRequiredLiveMounts.filter(({ id }) => !unchangedMountIds.has(id)),
2137
- recordCurrentHead: true
2264
+ recordCurrentHead: true,
2265
+ hydrationHooks: input.hydrationHooks
2138
2266
  });
2139
2267
  for (const id of hydration.skippedMountIds) cycleSkippedMountIds.add(id);
2140
2268
  refreshCycleSkippedMounts(completionReceiptMounts);
@@ -2152,10 +2280,12 @@ async function synchronizeWorkspaceGit(input) {
2152
2280
  return classifyMounts([...selected.active, ...selected.tombstones], hydration.skippedMountIds);
2153
2281
  };
2154
2282
  let newlyStagedPaths = [];
2283
+ const projectedGitlinks = [];
2155
2284
  const stageAndCommitWorkspace = async () => {
2156
2285
  if (workspaceResolutionInProgress(workspacePath) || await workspaceHasUnmergedEntries(workspacePath)) {
2157
2286
  throw new Error("Outer workspace clone has an unfinished merge/rebase; finish or abort it before synchronizing");
2158
2287
  }
2288
+ await stageWorkspaceGitlinks(workspacePath, projectedGitlinks);
2159
2289
  await gitAsync(workspacePath, ["add", "-A", "--", "."], "stage workspace working trees");
2160
2290
  newlyStagedPaths = await stagedPaths(workspacePath);
2161
2291
  if (newlyStagedPaths.length > 0) {
@@ -2185,7 +2315,7 @@ async function synchronizeWorkspaceGit(input) {
2185
2315
  cycleSkippedMountIds.add(mount.id);
2186
2316
  continue;
2187
2317
  }
2188
- mirrorMountsToWorkspace(workspacePath, [mount]);
2318
+ projectedGitlinks.push(...mirrorMountsToWorkspace(workspacePath, [mount]));
2189
2319
  projectedFastMounts.push(mount);
2190
2320
  await yieldToWorkerEventLoop();
2191
2321
  }
@@ -2284,7 +2414,9 @@ async function synchronizeWorkspaceGit(input) {
2284
2414
  }
2285
2415
  const paths = await changedPaths(workspacePath, remoteHead, localHead);
2286
2416
  const measureDiffSize = input.measureDiffSize ?? diffSizeBytes;
2287
- let size = paths.length > 0 ? await measureDiffSize(workspacePath, remoteHead, localHead, maxDiffBytes) : 0;
2417
+ const gitlinkMigrations = paths.length > 0 ? await pathsReplacedByGitlinks(workspacePath, remoteHead, localHead) : [];
2418
+ const gitlinkMigrationExcludes = gitlinkMigrationPathspecs(gitlinkMigrations);
2419
+ let size = paths.length > 0 ? await measureDiffSize(workspacePath, remoteHead, localHead, maxDiffBytes, gitlinkMigrationExcludes) : 0;
2288
2420
  input.assertStillAdmitted?.();
2289
2421
  let initialPublicationMountIds;
2290
2422
  if (paths.length > 0 && size > maxDiffBytes && !input.allowLargeDiff) {
@@ -2294,7 +2426,8 @@ async function synchronizeWorkspaceGit(input) {
2294
2426
  localHead,
2295
2427
  mounts: input.mounts,
2296
2428
  limit: maxDiffBytes,
2297
- measure: measureDiffSize
2429
+ measure: measureDiffSize,
2430
+ excludePathspecs: gitlinkMigrationExcludes
2298
2431
  });
2299
2432
  input.assertStillAdmitted?.();
2300
2433
  if (initialPublication && initialPublication.size <= maxDiffBytes) {
@@ -2302,6 +2435,11 @@ async function synchronizeWorkspaceGit(input) {
2302
2435
  initialPublicationMountIds = initialPublication.mountIds;
2303
2436
  }
2304
2437
  }
2438
+ let gitlinkMigrationConfirmed = false;
2439
+ if (gitlinkMigrations.length > 0 && size <= maxDiffBytes && !input.allowLargeDiff) {
2440
+ gitlinkMigrationConfirmed = await measureDiffSize(workspacePath, remoteHead, localHead, maxDiffBytes) > maxDiffBytes;
2441
+ input.assertStillAdmitted?.();
2442
+ }
2305
2443
  if (paths.length > 0 && size > maxDiffBytes && !input.allowLargeDiff) {
2306
2444
  const classifiedMounts = classifyMounts([...selected.active, ...selected.tombstones]);
2307
2445
  return {
@@ -2346,7 +2484,7 @@ async function synchronizeWorkspaceGit(input) {
2346
2484
  "push",
2347
2485
  "--no-recurse-submodules"
2348
2486
  ];
2349
- if (input.allowLargeDiff || initialPublicationMountIds) {
2487
+ if (input.allowLargeDiff || initialPublicationMountIds || gitlinkMigrationConfirmed) {
2350
2488
  pushArgs.push(`--push-option=${WORKSPACE_GIT_CONFIRMED_LARGE_DIFF_PUSH_OPTION}`);
2351
2489
  }
2352
2490
  pushArgs.push("origin", `HEAD:refs/heads/${WORKSPACE_GIT_BRANCH}`);
@@ -206,14 +206,6 @@ function indexMode(entry) {
206
206
  function indexInfoRecord(mode, objectId, relativePath) {
207
207
  return Buffer.concat([Buffer.from(`${mode} ${objectId} `), Buffer.from(relativePath), Buffer.from([0])]);
208
208
  }
209
- function basisGitlinks(workspacePath, basis) {
210
- const listed = gitResult(workspacePath, ["ls-tree", "-r", "-z", `${basis.basisHead}:${basis.workspaceRelativePath}`]);
211
- if (listed.exitCode !== 0) return [];
212
- return nulRecords(listed.stdout).flatMap((record) => {
213
- const match = /^160000 commit ([0-9a-f]{40,64})\t(.+)$/su.exec(record.toString());
214
- return match ? [{ objectId: match[1], relativePath: match[2] }] : [];
215
- });
216
- }
217
209
  function synthesizeMountTree(input) {
218
210
  const sourceRoot = import_node_path.default.resolve(input.sourcePath);
219
211
  const entries = (0, import_working_tree_mirror.inspectWorkingTree)(sourceRoot, input.sourceMode);
@@ -224,19 +216,14 @@ function synthesizeMountTree(input) {
224
216
  const records = [];
225
217
  for (const [relativePath, entry] of [...entries.entries()].sort(([left], [right]) => left.localeCompare(right))) {
226
218
  if (entry.kind === "directory") continue;
219
+ if (entry.kind === "gitlink") {
220
+ records.push(indexInfoRecord("160000", entry.objectId, relativePath));
221
+ continue;
222
+ }
227
223
  const sourcePath = sourceEntryPath(sourceRoot, relativePath);
228
224
  const objectId = entry.kind === "file" ? hashRegularFile(input.workspacePath, sourcePath, entry) : hashSymlink(input.workspacePath, sourcePath, entry);
229
225
  records.push(indexInfoRecord(indexMode(entry), objectId, relativePath));
230
226
  }
231
- if (input.gitlinkBasis && input.sourceMode === "git") {
232
- for (const gitlink of basisGitlinks(input.workspacePath, input.gitlinkBasis)) {
233
- const populatedPrefix = `${gitlink.relativePath}/`;
234
- const populated = [...entries.keys()].some((relativePath) => relativePath.startsWith(populatedPrefix));
235
- if (entries.get(gitlink.relativePath)?.kind === "directory" && !populated) {
236
- records.push(indexInfoRecord("160000", gitlink.objectId, gitlink.relativePath));
237
- }
238
- }
239
- }
240
227
  if (records.length > 0) {
241
228
  git(input.workspacePath, ["update-index", "-z", "--index-info"], "populate workspace projection index", {
242
229
  environment,
@@ -301,8 +288,7 @@ function synthesizeOursCommit(input) {
301
288
  const mountTree = synthesizeMountTree({
302
289
  workspacePath: input.workspacePath,
303
290
  sourcePath: input.mount.sourcePath,
304
- sourceMode: input.mount.sourceMode,
305
- gitlinkBasis: { basisHead: input.basisHead, workspaceRelativePath: input.workspaceRelativePath }
291
+ sourceMode: input.mount.sourceMode
306
292
  });
307
293
  const rootTree = graftMountTree({
308
294
  workspacePath: input.workspacePath,
package/dist/mjs/main.mjs CHANGED
@@ -104,9 +104,35 @@ function workerServerHeartbeatAction(input) {
104
104
  if (input.watchdogSweepDelayed || input.probeElapsedMs === void 0) return "start_probe";
105
105
  return input.probeElapsedMs >= input.timeoutMs ? "terminate" : "wait";
106
106
  }
107
+ const WORKSPACE_HYDRATION_LEASE_OPERATION_PREFIX = "hydration:";
108
+ function createWorkspaceHydrationLeaseHooks(send, options = {}) {
109
+ const yieldToEventLoop = options.yieldToEventLoop ?? (() => new Promise((resolve) => setImmediate(resolve)));
110
+ const log = options.log ?? ((line) => process.stdout.write(`${line}
111
+ `));
112
+ const now = options.now ?? (() => performance.now());
113
+ const operationId = (context) => `${WORKSPACE_HYDRATION_LEASE_OPERATION_PREFIX}${context.transactionId}`;
114
+ const startedAtMs = /* @__PURE__ */ new Map();
115
+ return {
116
+ async beforeTransaction(context) {
117
+ send({ type: "heartbeat_lease", operationId: operationId(context), kind: "hydration", active: true });
118
+ startedAtMs.set(context.transactionId, now());
119
+ log(`[r5d-worker] hydrating ${context.mountIds.length} workspace mount(s) under hydration lease ${context.transactionId}`);
120
+ await yieldToEventLoop();
121
+ },
122
+ afterTransaction(context) {
123
+ const startedAt = startedAtMs.get(context.transactionId);
124
+ startedAtMs.delete(context.transactionId);
125
+ send({ type: "heartbeat_lease", operationId: operationId(context), kind: "hydration", active: false });
126
+ log(
127
+ `[r5d-worker] released hydration lease ${context.transactionId} after ${startedAt === void 0 ? "?" : Math.round(now() - startedAt)}ms`
128
+ );
129
+ }
130
+ };
131
+ }
107
132
  const workerLifecycleTestHarness = {
108
133
  boundedWorkerLifecycleProgress,
109
- workerServerHeartbeatAction
134
+ workerServerHeartbeatAction,
135
+ createWorkspaceHydrationLeaseHooks
110
136
  };
111
137
  class ProjectWorkspaceConfigurationDeferredError extends Error {
112
138
  }
@@ -4377,6 +4403,7 @@ async function startWorker(options) {
4377
4403
  error: error instanceof Error ? error.message : String(error)
4378
4404
  };
4379
4405
  };
4406
+ const workspaceHydrationTransactionHooks = createWorkspaceHydrationLeaseHooks((message) => sendWorkerMessageFromCurrentSource(ws, message));
4380
4407
  const performWorkspaceSync = async (input) => {
4381
4408
  if (!workspaceRemoteUrl || !workspaceCredentialHelper || !workspaceCredentialUsername || !workspaceGitIdentity) {
4382
4409
  throw new Error("Worker workspace configuration has not been received");
@@ -4399,7 +4426,8 @@ async function startWorker(options) {
4399
4426
  credentialHelper: workspaceCredentialHelper,
4400
4427
  credentialUsername: workspaceCredentialUsername,
4401
4428
  gitIdentity: workspaceGitIdentity,
4402
- mounts: configuredMounts
4429
+ mounts: configuredMounts,
4430
+ hydrationHooks: workspaceHydrationTransactionHooks
4403
4431
  });
4404
4432
  await applyObservedProjectHeadsAfterInboundWorkspace(observedProjectHeads2, reset.activeMountIds, true);
4405
4433
  return {
@@ -4453,7 +4481,8 @@ async function startWorker(options) {
4453
4481
  assertStillAdmitted: input.assertStillAdmitted,
4454
4482
  afterWorkspacePublished: outerRemediation ? void 0 : async ({ activeMountIds, publishedHead }) => {
4455
4483
  await pushChangedProjectHeads(activeMountIds, publishedHead, publicationHeads, expectedMirrorHeads, inboundMoveMountIds);
4456
- }
4484
+ },
4485
+ hydrationHooks: workspaceHydrationTransactionHooks
4457
4486
  });
4458
4487
  input.assertStillAdmitted?.();
4459
4488
  if (result.initialPublicationMountIds && result.initialPublicationMountIds.length > 0) {
@@ -5047,7 +5076,10 @@ async function startWorker(options) {
5047
5076
  sourcePath: recoverySourceOverrides.get(mount.id) ?? mount.sourcePath,
5048
5077
  busy: mount.busyForRecovery ?? mount.busy
5049
5078
  }));
5050
- await recoverWorkspaceGitHydration(workspaceShadowRoot, recoveryMounts, { preserveStaleBases: true });
5079
+ await recoverWorkspaceGitHydration(workspaceShadowRoot, recoveryMounts, {
5080
+ preserveStaleBases: true,
5081
+ hydrationHooks: workspaceHydrationTransactionHooks
5082
+ });
5051
5083
  for (const project of message.projects) {
5052
5084
  for (const { branchName } of project.preserveOnlyBranches) {
5053
5085
  if (stillPendingCreatedBranches.has(pendingCreatedBranchKey(project.projectId, branchName))) {
@@ -5091,7 +5123,7 @@ async function startWorker(options) {
5091
5123
  // layouts can be marked ready. No process can resolve an unready
5092
5124
  // target, and preserving the fetched outer tree here prevents clean
5093
5125
  // checkout initialization from erasing synchronized dirt.
5094
- { ignoreBusy: true }
5126
+ { ignoreBusy: true, hydrationHooks: workspaceHydrationTransactionHooks }
5095
5127
  );
5096
5128
  await ensureConfiguredProjects((progress) => {
5097
5129
  sendLifecycleProgress({ phase: "configuring", operationId: configurationOperationId, ...progress });
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.99",
3
+ "version": "0.0.101",
4
4
  "type": "module"
5
5
  }