@ricsam/r5d-worker 0.0.81 → 0.0.83

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.
@@ -1,6 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { gitCredentialUsernameConfigKey, gitTransportSecurityArgs, workerGitProcessEnvironment } from "./git-process-environment.mjs";
4
+ import {
5
+ materializeWorkspaceProjectionTree,
6
+ mergeWorkspaceProjectionMount,
7
+ workspaceMergeProjectionSupport
8
+ } from "./workspace-merge-projection.mjs";
4
9
  import { mirrorWorkingTree } from "./working-tree-mirror.mjs";
5
10
  import { assertManagedDirectoryPath } from "./workspace-mount-boundary.mjs";
6
11
  const WORKSPACE_GIT_BRANCH = "main";
@@ -10,6 +15,12 @@ const WORKSPACE_GIT_INTEGRATED_REF = "refs/r5d/workspace-local/integrated";
10
15
  const WORKSPACE_GIT_HYDRATED_RECEIPT = "r5d/workspace-hydrated-head";
11
16
  const WORKSPACE_GIT_HYDRATION_TRANSACTION = "r5d/workspace-hydration-transaction";
12
17
  const WORKSPACE_GIT_CHECKOUT_DURABILITY = "r5d/workspace-checkout-durability";
18
+ class WorkspaceRemediationAncestryError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = "WorkspaceRemediationAncestryError";
22
+ }
23
+ }
13
24
  const NON_RECURSIVE_GIT_CONFIG = [
14
25
  "-c",
15
26
  "submodule.recurse=false",
@@ -49,6 +60,29 @@ function revParse(workspacePath, revision) {
49
60
  const result = gitResult(workspacePath, ["rev-parse", "--verify", revision]);
50
61
  return result.exitCode === 0 ? result.stdout : null;
51
62
  }
63
+ function requiredWorkspaceAncestorHeads(value) {
64
+ if (value === void 0) return [];
65
+ if (!Array.isArray(value) || value.length === 0 || value.some((head) => typeof head !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(head)) || new Set(value).size !== value.length) {
66
+ throw new WorkspaceRemediationAncestryError("Workspace remediation ancestor requirements are invalid");
67
+ }
68
+ return [...value];
69
+ }
70
+ function assertWorkspaceRemediationAncestry(input) {
71
+ if (input.requiredAncestorHeads.length === 0) return;
72
+ if (!input.currentHead) throw new WorkspaceRemediationAncestryError("Workspace remediation has no resolved HEAD to verify");
73
+ for (const requiredHead of input.requiredAncestorHeads) {
74
+ if (!tryGit(input.workspacePath, ["cat-file", "-e", `${requiredHead}^{commit}`]) || !tryGit(input.workspacePath, ["merge-base", "--is-ancestor", requiredHead, input.currentHead])) {
75
+ throw new WorkspaceRemediationAncestryError(
76
+ `Workspace remediation HEAD does not descend from required conflict commit ${requiredHead}`
77
+ );
78
+ }
79
+ }
80
+ if (!input.remoteHead || !tryGit(input.workspacePath, ["cat-file", "-e", `${input.remoteHead}^{commit}`]) || !tryGit(input.workspacePath, ["merge-base", "--is-ancestor", input.remoteHead, input.currentHead])) {
81
+ throw new WorkspaceRemediationAncestryError(
82
+ "Fetched workspace head is not an ancestor of the resolved remediation HEAD; merge it explicitly before synchronizing"
83
+ );
84
+ }
85
+ }
52
86
  function updateIntegratedWorkspaceHead(workspacePath, head) {
53
87
  git(workspacePath, ["update-ref", WORKSPACE_GIT_INTEGRATED_REF, head], "record integrated workspace head");
54
88
  }
@@ -95,22 +129,52 @@ function compareWorkspaceHydrationMountBasis(left, right) {
95
129
  return left.id.localeCompare(right.id);
96
130
  }
97
131
  function workspaceHydrationReceipt(head, mounts) {
98
- const bases = mounts.map(workspaceHydrationMountBasis).sort(compareWorkspaceHydrationMountBasis);
132
+ const bases = mounts.map((mount) => ({ ...workspaceHydrationMountBasis(mount), head })).sort(compareWorkspaceHydrationMountBasis);
99
133
  const ids = /* @__PURE__ */ new Set();
100
134
  for (const basis of bases) {
101
135
  if (ids.has(basis.id)) throw new Error(`Workspace hydration receipt mount id must be unique: ${basis.id}`);
102
136
  ids.add(basis.id);
103
137
  }
104
- return { version: 2, head, mounts: bases };
138
+ return { version: 3, mounts: bases };
139
+ }
140
+ function receiptWithMountsAtHead(previous, head, mounts, allConfiguredMounts) {
141
+ const advanceIds = new Set(mounts.map(({ id }) => id));
142
+ const previousById = new Map(previous?.mounts.map((entry) => [entry.id, entry]) ?? []);
143
+ const entries = allConfiguredMounts.flatMap((mount) => {
144
+ const basis = workspaceHydrationMountBasis(mount);
145
+ if (advanceIds.has(mount.id)) return [{ ...basis, head }];
146
+ const carried = previousById.get(mount.id);
147
+ if (!carried) return [];
148
+ return [carried];
149
+ });
150
+ entries.sort(compareWorkspaceHydrationMountBasis);
151
+ const ids = /* @__PURE__ */ new Set();
152
+ for (const entry of entries) {
153
+ if (ids.has(entry.id)) throw new Error(`Workspace hydration receipt mount id must be unique: ${entry.id}`);
154
+ ids.add(entry.id);
155
+ }
156
+ return { version: 3, mounts: entries };
105
157
  }
106
158
  function workspaceHydrationReceiptsEqual(left, right) {
107
- return JSON.stringify(left) === JSON.stringify(right);
159
+ if (JSON.stringify(left) !== JSON.stringify(right)) return false;
160
+ const effectiveLegacyHead = (receipt) => receipt?.mounts.length === 0 ? receipt.legacyHead ?? null : null;
161
+ return effectiveLegacyHead(left) === effectiveLegacyHead(right);
108
162
  }
109
163
  function workspaceHydrationReceiptCoversMount(receipt, head, mount) {
110
- if (!receipt || receipt.head !== head) return false;
164
+ if (!receipt) return false;
165
+ const expected = workspaceHydrationMountBasis(mount);
166
+ const actual = receipt.mounts.find(({ id }) => id === expected.id);
167
+ if (!actual || actual.head !== head) return false;
168
+ const { head: _head, ...actualBasis } = actual;
169
+ return JSON.stringify(actualBasis) === JSON.stringify(expected);
170
+ }
171
+ function workspaceHydrationReceiptMountBasisHead(receipt, mount) {
172
+ if (!receipt) return null;
111
173
  const expected = workspaceHydrationMountBasis(mount);
112
174
  const actual = receipt.mounts.find(({ id }) => id === expected.id);
113
- return Boolean(actual && JSON.stringify(actual) === JSON.stringify(expected));
175
+ if (!actual) return null;
176
+ const { head, ...actualBasis } = actual;
177
+ return JSON.stringify(actualBasis) === JSON.stringify(expected) ? head : null;
114
178
  }
115
179
  function workspaceHydrationReceiptMatchesMounts(receipt, head, mounts) {
116
180
  return workspaceHydrationReceiptsEqual(receipt, workspaceHydrationReceipt(head, mounts));
@@ -131,7 +195,9 @@ function parseWorkspaceHydrationReceiptContent(workspacePath, receiptPath, conte
131
195
  if (!tryGit(workspacePath, ["cat-file", "-e", `${legacyHead}^{commit}`])) {
132
196
  throw new Error(`Workspace hydration receipt does not resolve to a local commit: ${receiptPath}`);
133
197
  }
134
- return { version: 2, head: legacyHead, mounts: [] };
198
+ const receipt2 = { version: 3, mounts: [] };
199
+ Object.defineProperty(receipt2, "legacyHead", { value: legacyHead, enumerable: false });
200
+ return receipt2;
135
201
  }
136
202
  let parsed;
137
203
  try {
@@ -143,7 +209,8 @@ function parseWorkspaceHydrationReceiptContent(workspacePath, receiptPath, conte
143
209
  throw new Error(`Workspace hydration receipt contains an invalid object id or mount basis: ${receiptPath}`);
144
210
  }
145
211
  const candidate = parsed;
146
- if (candidate.version !== 2 || typeof candidate.head !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(candidate.head)) {
212
+ const liftedV2Head = candidate.version === 2 && typeof candidate.head === "string" && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(candidate.head) ? candidate.head : null;
213
+ if (candidate.version !== 3 && liftedV2Head === null) {
147
214
  throw new Error(`Workspace hydration receipt contains an invalid object id or mount basis: ${receiptPath}`);
148
215
  }
149
216
  if (!Array.isArray(candidate.mounts)) {
@@ -157,23 +224,51 @@ function parseWorkspaceHydrationReceiptContent(workspacePath, receiptPath, conte
157
224
  const mount = rawMount;
158
225
  const normalizedRelativePath = typeof mount.workspaceRelativePath === "string" ? normalizedWorkspaceMountPath(mount.workspaceRelativePath) : "";
159
226
  const durabilityRelative = typeof mount.sourcePath === "string" && typeof mount.durabilityRootPath === "string" ? path.relative(mount.durabilityRootPath, mount.sourcePath) : "..";
160
- if (typeof mount.id !== "string" || !mount.id || seenIds.has(mount.id) || typeof mount.incarnationKey !== "string" || !mount.incarnationKey || mount.incarnationKey.includes("\0") || typeof mount.sourcePath !== "string" || path.resolve(mount.sourcePath) !== mount.sourcePath || typeof mount.durabilityRootPath !== "string" || path.resolve(mount.durabilityRootPath) !== mount.durabilityRootPath || !durabilityRelative || durabilityRelative === ".." || durabilityRelative.startsWith(`..${path.sep}`) || path.isAbsolute(durabilityRelative) || typeof mount.workspaceRelativePath !== "string" || mount.workspaceRelativePath !== normalizedRelativePath || !normalizedRelativePath || normalizedRelativePath === "." || normalizedRelativePath.split("/").some((segment) => segment === ".." || segment === ".git") || mount.sourceMode !== "all" && mount.sourceMode !== "git" || mount.hydrateDeletionMode !== "all" && mount.hydrateDeletionMode !== "git" || typeof mount.preserveLocalOnInitialOuterAbsence !== "boolean" || typeof mount.preserveLocalOnHydrationBasisChange !== "boolean" || typeof mount.deleteWhenSourceMissing !== "boolean") {
227
+ if (typeof mount.id !== "string" || !mount.id || seenIds.has(mount.id) || typeof mount.incarnationKey !== "string" || !mount.incarnationKey || mount.incarnationKey.includes("\0") || typeof mount.sourcePath !== "string" || path.resolve(mount.sourcePath) !== mount.sourcePath || typeof mount.durabilityRootPath !== "string" || path.resolve(mount.durabilityRootPath) !== mount.durabilityRootPath || !durabilityRelative || durabilityRelative === ".." || durabilityRelative.startsWith(`..${path.sep}`) || path.isAbsolute(durabilityRelative) || typeof mount.workspaceRelativePath !== "string" || mount.workspaceRelativePath !== normalizedRelativePath || !normalizedRelativePath || normalizedRelativePath === "." || normalizedRelativePath.split("/").some((segment) => segment === ".." || segment === ".git") || mount.sourceMode !== "all" && mount.sourceMode !== "git" || mount.hydrateDeletionMode !== "all" && mount.hydrateDeletionMode !== "git" || typeof mount.preserveLocalOnInitialOuterAbsence !== "boolean" || typeof mount.preserveLocalOnHydrationBasisChange !== "boolean" || typeof mount.deleteWhenSourceMissing !== "boolean" || candidate.version === 3 && (typeof mount.head !== "string" || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(mount.head))) {
161
228
  throw new Error(`Workspace hydration receipt contains an invalid mount basis: ${receiptPath}`);
162
229
  }
163
230
  seenIds.add(mount.id);
164
- return mount;
231
+ return {
232
+ id: mount.id,
233
+ incarnationKey: mount.incarnationKey,
234
+ sourcePath: mount.sourcePath,
235
+ durabilityRootPath: mount.durabilityRootPath,
236
+ workspaceRelativePath: mount.workspaceRelativePath,
237
+ sourceMode: mount.sourceMode,
238
+ hydrateDeletionMode: mount.hydrateDeletionMode,
239
+ preserveLocalOnInitialOuterAbsence: mount.preserveLocalOnInitialOuterAbsence,
240
+ preserveLocalOnHydrationBasisChange: mount.preserveLocalOnHydrationBasisChange,
241
+ deleteWhenSourceMissing: mount.deleteWhenSourceMissing,
242
+ head: candidate.version === 3 ? mount.head : liftedV2Head
243
+ };
165
244
  });
245
+ const sortedMounts = mounts.sort(compareWorkspaceHydrationMountBasis);
246
+ if (liftedV2Head) {
247
+ const legacyReceipt = {
248
+ version: 2,
249
+ head: liftedV2Head,
250
+ mounts: sortedMounts.map(({ head: _head, ...basis }) => basis)
251
+ };
252
+ if (content !== `${JSON.stringify(legacyReceipt)}
253
+ `) {
254
+ throw new Error(`Workspace hydration receipt contains a noncanonical mount basis: ${receiptPath}`);
255
+ }
256
+ }
166
257
  const receipt = {
167
- version: 2,
168
- head: candidate.head,
169
- mounts: mounts.sort(compareWorkspaceHydrationMountBasis)
258
+ version: 3,
259
+ mounts: sortedMounts
170
260
  };
171
- if (content !== `${JSON.stringify(receipt)}
261
+ if (liftedV2Head) Object.defineProperty(receipt, "legacyHead", { value: liftedV2Head, enumerable: false });
262
+ if (candidate.version === 3 && content !== `${JSON.stringify(receipt)}
172
263
  `) {
173
264
  throw new Error(`Workspace hydration receipt contains a noncanonical mount basis: ${receiptPath}`);
174
265
  }
175
- if (!tryGit(workspacePath, ["cat-file", "-e", `${receipt.head}^{commit}`])) {
176
- throw new Error(`Workspace hydration receipt does not resolve to a local commit: ${receiptPath}`);
266
+ const receiptHeads = new Set(receipt.mounts.map((mount) => mount.head));
267
+ if (liftedV2Head) receiptHeads.add(liftedV2Head);
268
+ for (const head of receiptHeads) {
269
+ if (!tryGit(workspacePath, ["cat-file", "-e", `${head}^{commit}`])) {
270
+ throw new Error(`Workspace hydration receipt does not resolve to a local commit: ${receiptPath}`);
271
+ }
177
272
  }
178
273
  return receipt;
179
274
  }
@@ -187,22 +282,92 @@ function readHydratedWorkspaceReceipt(workspacePath) {
187
282
  const content = fs.readFileSync(receiptPath, "utf8");
188
283
  return parseWorkspaceHydrationReceiptContent(workspacePath, receiptPath, content);
189
284
  }
285
+ function hydratedWorkspaceReceiptFileIsV3(workspacePath) {
286
+ const receiptPath = hydratedWorkspaceReceiptPath(workspacePath);
287
+ const status = lstatIfExists(receiptPath);
288
+ if (!status?.isFile() || status.isSymbolicLink()) return false;
289
+ try {
290
+ return JSON.parse(fs.readFileSync(receiptPath, "utf8")).version === 3;
291
+ } catch {
292
+ return false;
293
+ }
294
+ }
190
295
  function workspaceGitHydrationIsCurrent(workspacePath, mounts) {
191
296
  const resolvedWorkspacePath = path.resolve(workspacePath);
192
297
  if (!fs.existsSync(path.join(resolvedWorkspacePath, ".git"))) return true;
193
298
  if (mounts) validateMounts(resolvedWorkspacePath, mounts);
194
299
  const head = revParse(resolvedWorkspacePath, "HEAD");
195
300
  if (!workspaceCheckoutDurabilityIsCurrent(resolvedWorkspacePath)) return false;
196
- if (head === null) return true;
197
301
  const receipt = readHydratedWorkspaceReceipt(resolvedWorkspacePath);
198
- return mounts ? workspaceHydrationReceiptMatchesMounts(receipt, head, configuredWorkspaceHydrationBasisMounts(resolvedWorkspacePath, mounts)) : receipt?.head === head;
302
+ if (!workspaceBasisRefsAreCurrent(resolvedWorkspacePath, receipt ?? { version: 3, mounts: [] })) return false;
303
+ if (head === null) return true;
304
+ if (!receipt) return false;
305
+ return mounts ? configuredWorkspaceHydrationBasisMounts(resolvedWorkspacePath, mounts).every(
306
+ (mount) => workspaceHydrationReceiptCoversMount(receipt, head, mount) || Boolean(mount.busy?.() && workspaceHydrationReceiptMountBasisHead(receipt, mount))
307
+ ) : Boolean(receipt && receipt.mounts.every((mount) => mount.head === head));
308
+ }
309
+ const WORKSPACE_GIT_BASIS_REF_PREFIX = "refs/r5d/workspace-basis/";
310
+ const WORKSPACE_GIT_BASIS_STAGING_REF_PREFIX = "refs/r5d/workspace-basis-staging/";
311
+ function workspaceBasisRefName(mountId) {
312
+ const sanitized = Buffer.from(mountId, "utf8").toString("base64url");
313
+ if (!sanitized) throw new Error(`Cannot derive a workspace basis ref for mount id: ${mountId}`);
314
+ return `${WORKSPACE_GIT_BASIS_REF_PREFIX}${sanitized}`;
315
+ }
316
+ function listWorkspaceRefs(workspacePath, prefix) {
317
+ return git(workspacePath, ["for-each-ref", "--format=%(refname)", prefix], `list ${prefix} refs`).split("\n").filter(Boolean).sort();
318
+ }
319
+ function desiredWorkspaceBasisRefs(receipt) {
320
+ const desired = /* @__PURE__ */ new Map();
321
+ for (const mount of receipt.mounts) {
322
+ const ref = workspaceBasisRefName(mount.id);
323
+ if (desired.has(ref)) throw new Error(`Workspace hydration mount ids collide at basis ref ${ref}`);
324
+ desired.set(ref, mount.head);
325
+ }
326
+ return desired;
327
+ }
328
+ function workspaceBasisRefsMatch(workspacePath, receipt) {
329
+ const desired = desiredWorkspaceBasisRefs(receipt);
330
+ const actualRefs = listWorkspaceRefs(workspacePath, WORKSPACE_GIT_BASIS_REF_PREFIX);
331
+ if (actualRefs.length !== desired.size) return false;
332
+ return actualRefs.every((ref) => desired.get(ref) === revParse(workspacePath, ref));
333
+ }
334
+ function workspaceBasisRefsAreCurrent(workspacePath, receipt) {
335
+ return workspaceBasisRefsMatch(workspacePath, receipt) && listWorkspaceRefs(workspacePath, WORKSPACE_GIT_BASIS_STAGING_REF_PREFIX).length === 0;
336
+ }
337
+ function repairWorkspaceBasisRefs(workspacePath, receipt) {
338
+ const effectiveReceipt = receipt ?? { version: 3, mounts: [] };
339
+ if (!workspaceBasisRefsAreCurrent(workspacePath, effectiveReceipt)) updateWorkspaceBasisRefs(workspacePath, effectiveReceipt);
340
+ }
341
+ function updateWorkspaceBasisRefs(workspacePath, receipt) {
342
+ const desired = desiredWorkspaceBasisRefs(receipt);
343
+ const transaction = ["start"];
344
+ for (const [ref, head] of [...desired].sort(([left], [right]) => left.localeCompare(right))) transaction.push(`update ${ref} ${head}`);
345
+ for (const ref of listWorkspaceRefs(workspacePath, WORKSPACE_GIT_BASIS_REF_PREFIX)) {
346
+ if (!desired.has(ref)) transaction.push(`delete ${ref}`);
347
+ }
348
+ transaction.push("prepare", "commit", "");
349
+ const updated = Bun.spawnSync(gitCommandArgs(["update-ref", "--stdin"]), {
350
+ cwd: workspacePath,
351
+ stdin: Buffer.from(transaction.join("\n")),
352
+ stdout: "pipe",
353
+ stderr: "pipe",
354
+ env: workerGitProcessEnvironment()
355
+ });
356
+ if (updated.exitCode !== 0) {
357
+ throw new Error(`Synchronize workspace hydration basis refs: ${updated.stderr.toString().trim() || `git exited ${updated.exitCode}`}`);
358
+ }
359
+ for (const ref of listWorkspaceRefs(workspacePath, WORKSPACE_GIT_BASIS_STAGING_REF_PREFIX)) {
360
+ git(workspacePath, ["update-ref", "-d", ref], "remove staged workspace hydration basis ref");
361
+ }
199
362
  }
200
363
  function updateHydratedWorkspaceReceipt(workspacePath, receipt) {
201
- if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(receipt.head)) {
202
- throw new Error(`Cannot record invalid fully hydrated workspace object id: ${receipt.head}`);
203
- }
204
- if (!tryGit(workspacePath, ["cat-file", "-e", `${receipt.head}^{commit}`])) {
205
- throw new Error(`Cannot record workspace hydration receipt for a missing commit: ${receipt.head}`);
364
+ for (const mount of receipt.mounts) {
365
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(mount.head)) {
366
+ throw new Error(`Cannot record invalid workspace hydration object id: ${mount.head}`);
367
+ }
368
+ if (!tryGit(workspacePath, ["cat-file", "-e", `${mount.head}^{commit}`])) {
369
+ throw new Error(`Cannot record workspace hydration receipt for a missing commit: ${mount.head}`);
370
+ }
206
371
  }
207
372
  readHydratedWorkspaceReceipt(workspacePath);
208
373
  const gitDirectory = path.join(workspacePath, ".git");
@@ -222,6 +387,12 @@ function updateHydratedWorkspaceReceipt(workspacePath, receipt) {
222
387
  throw new Error(`Workspace hydration receipt directory is not a regular directory: ${receiptDirectory}`);
223
388
  }
224
389
  const temporaryPath = path.join(receiptDirectory, `.workspace-hydrated-head.${process.pid}.${crypto.randomUUID()}.tmp`);
390
+ const stagingPrefix = `${WORKSPACE_GIT_BASIS_STAGING_REF_PREFIX}${crypto.randomUUID()}/`;
391
+ desiredWorkspaceBasisRefs(receipt);
392
+ for (const mount of receipt.mounts) {
393
+ const stagedRef = `${stagingPrefix}${workspaceBasisRefName(mount.id).slice(WORKSPACE_GIT_BASIS_REF_PREFIX.length)}`;
394
+ git(workspacePath, ["update-ref", stagedRef, mount.head], "stage workspace hydration basis ref");
395
+ }
225
396
  let descriptor;
226
397
  try {
227
398
  descriptor = fs.openSync(temporaryPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 384);
@@ -232,6 +403,7 @@ function updateHydratedWorkspaceReceipt(workspacePath, receipt) {
232
403
  descriptor = void 0;
233
404
  fs.renameSync(temporaryPath, receiptPath);
234
405
  fsyncDirectory(receiptDirectory);
406
+ updateWorkspaceBasisRefs(workspacePath, receipt);
235
407
  } catch (error) {
236
408
  if (descriptor !== void 0) fs.closeSync(descriptor);
237
409
  fs.rmSync(temporaryPath, { force: true });
@@ -359,6 +531,16 @@ function workspaceRebaseInProgress(workspacePath) {
359
531
  const gitDirectory = path.join(workspacePath, ".git");
360
532
  return fs.existsSync(path.join(gitDirectory, "rebase-merge")) || fs.existsSync(path.join(gitDirectory, "rebase-apply"));
361
533
  }
534
+ function workspaceResolutionInProgress(workspacePath) {
535
+ return workspaceRebaseInProgress(workspacePath) || fs.existsSync(path.join(workspacePath, ".git", "MERGE_HEAD"));
536
+ }
537
+ function workspaceHasUnmergedEntries(workspacePath) {
538
+ const result = gitResult(workspacePath, ["ls-files", "-u", "-z"]);
539
+ if (result.exitCode !== 0) {
540
+ throw new Error(`Inspect unfinished workspace merge: ${result.stderr || result.stdout || `git exited ${result.exitCode}`}`);
541
+ }
542
+ return result.stdout.length > 0;
543
+ }
362
544
  function clearWorkspaceCheckoutTree(workspacePath) {
363
545
  for (const name of fs.readdirSync(workspacePath)) {
364
546
  if (name === ".git") continue;
@@ -366,15 +548,18 @@ function clearWorkspaceCheckoutTree(workspacePath) {
366
548
  }
367
549
  fsyncDirectory(workspacePath);
368
550
  }
369
- function recoverWorkspaceCheckoutDurability(workspacePath) {
551
+ function recoverWorkspaceCheckoutDurability(workspacePath, preserveResolutionInProgress = false) {
370
552
  workspacePath = path.resolve(workspacePath);
371
553
  const record = readWorkspaceCheckoutDurabilityRecord(workspacePath);
372
554
  const observedHead = revParse(workspacePath, "HEAD");
373
555
  if (record?.state === "durable" && record.head === observedHead) return;
556
+ if (preserveResolutionInProgress && workspaceResolutionInProgress(workspacePath)) {
557
+ throw new Error("Workspace resolution is in progress; finish or abort it before synchronizing");
558
+ }
374
559
  let targetHead = record?.state === "transition" ? record.head : observedHead;
375
560
  if (record?.state === "transition" && observedHead && observedHead !== record.head) {
376
561
  const hydrationReceipt = readHydratedWorkspaceReceipt(workspacePath);
377
- if (hydrationReceipt?.head === observedHead) {
562
+ if (hydrationReceipt?.legacyHead === observedHead || hydrationReceipt?.mounts.some((mount) => mount.head === observedHead)) {
378
563
  targetHead = observedHead;
379
564
  }
380
565
  }
@@ -456,7 +641,7 @@ function parseHydrationTransactionManifest(transactionPath) {
456
641
  throw new Error(`Workspace hydration transaction manifest is invalid: ${manifestPath}`);
457
642
  }
458
643
  const candidate = parsed;
459
- if (candidate.version !== 2 || !Array.isArray(candidate.mounts)) {
644
+ if (candidate.version !== 2 && candidate.version !== 3 || !Array.isArray(candidate.mounts)) {
460
645
  throw new Error(`Workspace hydration transaction manifest is invalid: ${manifestPath}`);
461
646
  }
462
647
  const parseReceipt = (rawReceipt) => {
@@ -488,7 +673,7 @@ function parseHydrationTransactionManifest(transactionPath) {
488
673
  return mount;
489
674
  });
490
675
  return {
491
- version: 2,
676
+ version: 3,
492
677
  targetReceipt,
493
678
  receiptBefore,
494
679
  mounts
@@ -581,7 +766,7 @@ function beginHydrationTransaction(workspacePath, mounts, targetReceipt) {
581
766
  const snapshotRoot = path.join(stagingPath, "mounts");
582
767
  fs.mkdirSync(snapshotRoot, { mode: 448 });
583
768
  const manifest = {
584
- version: 2,
769
+ version: 3,
585
770
  targetReceipt,
586
771
  receiptBefore: readHydratedWorkspaceReceipt(workspacePath),
587
772
  mounts: mounts.map((mount, index) => {
@@ -760,9 +945,22 @@ function configureWorkspaceRepository(input) {
760
945
  if (!name || !email) throw new Error("Workspace Git identity must include name and email");
761
946
  git(input.workspacePath, ["config", "--local", "user.name", name], "configure workspace Git user name");
762
947
  git(input.workspacePath, ["config", "--local", "user.email", email], "configure workspace Git user email");
763
- git(input.workspacePath, ["config", "--local", "core.fsync", "committed"], "configure durable workspace commits");
948
+ git(
949
+ input.workspacePath,
950
+ ["config", "--local", "core.fsync", "committed,reference"],
951
+ "configure durable workspace commits and references"
952
+ );
764
953
  git(input.workspacePath, ["config", "--local", "core.fsyncMethod", "fsync"], "configure workspace fsync method");
765
954
  }
955
+ function configureExistingWorkspaceGitForRemediation(input) {
956
+ const workspacePath = path.resolve(input.workspacePath);
957
+ const workspaceStatus = lstatIfExists(workspacePath);
958
+ const gitStatus = lstatIfExists(path.join(workspacePath, ".git"));
959
+ if (!workspaceStatus?.isDirectory() || workspaceStatus.isSymbolicLink() || !gitStatus?.isDirectory() || gitStatus.isSymbolicLink()) {
960
+ throw new Error("Active workspace remediation requires a regular existing canonical synchronization checkout");
961
+ }
962
+ configureWorkspaceRepository({ ...input, workspacePath });
963
+ }
766
964
  function fetchWorkspaceHead(workspacePath, remoteUrl, credentialHelper, credentialUsername) {
767
965
  const result = gitResult(workspacePath, [
768
966
  ...gitTransportSecurityArgs(remoteUrl, credentialHelper, credentialUsername),
@@ -824,7 +1022,7 @@ function ensureWorkspaceGitClone(input) {
824
1022
  }
825
1023
  }
826
1024
  configureWorkspaceRepository({ ...input, workspacePath });
827
- recoverWorkspaceCheckoutDurability(workspacePath);
1025
+ recoverWorkspaceCheckoutDurability(workspacePath, input.preserveResolutionInProgress);
828
1026
  const previousRemoteHead = revParse(workspacePath, `refs/remotes/origin/${WORKSPACE_GIT_BRANCH}`);
829
1027
  const localHeadBeforeFetch = revParse(workspacePath, "HEAD");
830
1028
  if (!revParse(workspacePath, WORKSPACE_GIT_INTEGRATED_REF) && previousRemoteHead && localHeadBeforeFetch && tryGit(workspacePath, ["merge-base", "--is-ancestor", previousRemoteHead, localHeadBeforeFetch])) {
@@ -974,31 +1172,40 @@ function hydrateWorkspaceGitMountsRaw(workspacePath, mounts, options = {}) {
974
1172
  }
975
1173
  function hydrateWorkspaceGitMountsTransactionally(input) {
976
1174
  const workspacePath = path.resolve(input.workspacePath);
977
- const mountIds = input.mounts.map(({ id }) => id).sort();
978
- const requiredIds = (input.requiredMounts ?? input.mounts).map(({ id }) => id).sort();
979
- if (mountIds.length !== requiredIds.length || mountIds.some((id, index) => id !== requiredIds[index])) {
980
- return { hydratedMountIds: [], skippedMountIds: requiredIds };
981
- }
982
- const busyMountIds = input.ignoreBusy ? [] : (input.durabilityMounts ?? input.mounts).filter((mount) => mount.busy?.()).map(({ id }) => id).sort();
983
- if (busyMountIds.length > 0) {
984
- return { hydratedMountIds: [], skippedMountIds: busyMountIds };
985
- }
1175
+ const durabilityMounts = input.durabilityMounts ?? input.mounts;
1176
+ const receiptMounts = input.receiptMounts ?? durabilityMounts;
1177
+ const requiredMounts = input.requiredMounts ?? input.mounts;
1178
+ const candidateMounts = [
1179
+ ...new Map([...input.mounts, ...durabilityMounts, ...requiredMounts].map((mount) => [mount.id, mount])).values()
1180
+ ];
1181
+ const busyMountIds = new Set(input.ignoreBusy ? [] : candidateMounts.filter((mount) => mount.busy?.()).map(({ id }) => id));
1182
+ const requestedIds = new Set(input.mounts.map(({ id }) => id));
1183
+ const requiredIds = new Set(requiredMounts.map(({ id }) => id));
1184
+ const hydrationMounts = input.mounts.filter(({ id }) => !busyMountIds.has(id));
1185
+ const advancedDurabilityMounts = durabilityMounts.filter(
1186
+ ({ id }) => !busyMountIds.has(id) && (requestedIds.has(id) || !requiredIds.has(id))
1187
+ );
1188
+ const unavailableRequiredIds = requiredMounts.filter(({ id }) => busyMountIds.has(id) || !requestedIds.has(id)).map(({ id }) => id);
986
1189
  if (input.recordCurrentHead && !workspaceCheckoutDurabilityIsCurrent(workspacePath)) {
987
1190
  throw new Error("Workspace checkout is not durably materialized at its current HEAD");
988
1191
  }
989
1192
  const targetHead = input.recordCurrentHead ? revParse(workspacePath, "HEAD") : null;
990
- const targetReceipt = targetHead ? workspaceHydrationReceipt(targetHead, input.receiptMounts ?? input.durabilityMounts ?? input.mounts) : null;
991
- const manifest = beginHydrationTransaction(workspacePath, input.mounts, targetReceipt);
1193
+ const targetReceipt = targetHead ? receiptWithMountsAtHead(readHydratedWorkspaceReceipt(workspacePath), targetHead, advancedDurabilityMounts, receiptMounts) : null;
1194
+ const manifest = beginHydrationTransaction(workspacePath, hydrationMounts, targetReceipt);
992
1195
  try {
993
- const hydration = hydrateWorkspaceGitMountsRaw(workspacePath, input.mounts, { ignoreBusy: true });
994
- if (hydration.skippedMountIds.length > 0 || hydration.hydratedMountIds.length !== requiredIds.length || hydration.hydratedMountIds.some((id, index) => id !== requiredIds[index])) {
1196
+ const hydration = hydrateWorkspaceGitMountsRaw(workspacePath, hydrationMounts, { ignoreBusy: true });
1197
+ const expectedHydratedIds = hydrationMounts.map(({ id }) => id).sort();
1198
+ if (hydration.skippedMountIds.length > 0 || hydration.hydratedMountIds.length !== expectedHydratedIds.length || hydration.hydratedMountIds.some((id, index) => id !== expectedHydratedIds[index])) {
995
1199
  throw new Error("Workspace hydration did not include every required mount");
996
1200
  }
997
- fsyncHydratedWorkspaceMounts(input.durabilityMounts ?? input.mounts);
1201
+ fsyncHydratedWorkspaceMounts(advancedDurabilityMounts);
998
1202
  markHydrationTransactionDurable(workspacePath);
999
1203
  if (targetReceipt) updateHydratedWorkspaceReceipt(workspacePath, targetReceipt);
1000
1204
  removeHydrationTransaction(workspacePath);
1001
- return hydration;
1205
+ return {
1206
+ hydratedMountIds: hydration.hydratedMountIds,
1207
+ skippedMountIds: [.../* @__PURE__ */ new Set([...busyMountIds, ...unavailableRequiredIds])].sort()
1208
+ };
1002
1209
  } catch (error) {
1003
1210
  const transactionPath = hydrationTransactionPath(workspacePath);
1004
1211
  if (lstatIfExists(transactionPath) && hydrationTransactionCommitted(transactionPath)) {
@@ -1031,44 +1238,44 @@ function hydrateWorkspaceGitMounts(workspacePath, mounts, options = {}) {
1031
1238
  function recoverWorkspaceGitHydration(workspacePath, mounts, options = {}) {
1032
1239
  workspacePath = path.resolve(workspacePath);
1033
1240
  validateMounts(workspacePath, mounts);
1034
- recoverWorkspaceCheckoutDurability(workspacePath);
1241
+ recoverWorkspaceCheckoutDurability(workspacePath, options.preserveResolutionInProgress);
1035
1242
  recoverPendingHydrationTransaction(workspacePath);
1036
1243
  const currentHead = revParse(workspacePath, "HEAD");
1037
- if (!currentHead) return;
1038
1244
  const currentReceipt = readHydratedWorkspaceReceipt(workspacePath);
1245
+ repairWorkspaceBasisRefs(workspacePath, currentReceipt);
1246
+ if (!currentHead) return;
1039
1247
  const outerHeadContainsMount = (mount) => tryGit(workspacePath, ["cat-file", "-e", `HEAD:${normalizedWorkspaceMountPath(mount.workspaceRelativePath)}`]);
1040
1248
  const configuredBasisMounts = configuredWorkspaceHydrationBasisMounts(workspacePath, mounts, options.deferMountIds);
1041
1249
  const uncoveredBasisMounts = configuredBasisMounts.filter(
1042
1250
  (mount) => !workspaceHydrationReceiptCoversMount(currentReceipt, currentHead, mount)
1043
1251
  );
1044
1252
  if (workspaceHydrationReceiptMatchesMounts(currentReceipt, currentHead, configuredBasisMounts)) return;
1045
- const busyMountIds = options.ignoreBusy ? [] : uncoveredBasisMounts.filter((mount) => mount.busy?.()).map(({ id }) => id);
1046
- if (busyMountIds.length > 0) {
1047
- throw new Error(
1048
- `Workspace HEAD ${currentHead} has not been fully hydrated; busy mounts must finish before projection: ${busyMountIds.join(", ")}`
1049
- );
1050
- }
1051
- const hydrationTargets = uncoveredBasisMounts.filter(
1052
- (mount) => !mount.deleteWhenSourceMissing && !(mount.preserveLocalOnHydrationBasisChange === true && currentReceipt?.head === currentHead) && (outerHeadContainsMount(mount) || mount.preserveLocalOnInitialOuterAbsence !== true)
1253
+ const busyMountIds = new Set(options.ignoreBusy ? [] : uncoveredBasisMounts.filter((mount) => mount.busy?.()).map(({ id }) => id));
1254
+ const recoveryMounts = uncoveredBasisMounts.filter(
1255
+ (mount) => workspaceHydrationReceiptMountBasisHead(currentReceipt, mount) === null || !options.preserveStaleBases && !busyMountIds.has(mount.id)
1256
+ );
1257
+ const idleBasislessMounts = recoveryMounts.filter((mount) => !busyMountIds.has(mount.id));
1258
+ const hydrationTargets = idleBasislessMounts.filter(
1259
+ (mount) => !mount.deleteWhenSourceMissing && !(mount.preserveLocalOnHydrationBasisChange === true && currentReceipt?.mounts.find(({ id }) => id === mount.id)?.head === currentHead) && (outerHeadContainsMount(mount) || mount.preserveLocalOnInitialOuterAbsence !== true)
1053
1260
  );
1054
1261
  const hydration = hydrateWorkspaceGitMountsTransactionally({
1055
1262
  workspacePath,
1056
1263
  mounts: hydrationTargets,
1057
- durabilityMounts: uncoveredBasisMounts,
1264
+ durabilityMounts: idleBasislessMounts,
1058
1265
  receiptMounts: configuredBasisMounts,
1059
1266
  requiredMounts: hydrationTargets,
1060
1267
  ignoreBusy: options.ignoreBusy,
1061
1268
  recordCurrentHead: true
1062
1269
  });
1063
- if (hydration.skippedMountIds.length > 0) {
1064
- throw new Error(
1065
- `Workspace HEAD ${currentHead} has not been fully hydrated; busy mounts must finish before projection: ${hydration.skippedMountIds.join(", ")}`
1066
- );
1067
- }
1270
+ void hydration;
1068
1271
  }
1069
1272
  function resetWorkspaceGit(input) {
1070
1273
  const workspacePath = path.resolve(input.workspacePath);
1071
1274
  validateMounts(workspacePath, input.mounts);
1275
+ const busyResetMountIds = input.mounts.filter((mount) => mount.busy?.()).map(({ id }) => id);
1276
+ if (busyResetMountIds.length > 0) {
1277
+ throw new Error(`Workspace reset cannot run while mounts are busy: ${busyResetMountIds.sort().join(", ")}`);
1278
+ }
1072
1279
  const initial = ensureWorkspaceGitClone({ ...input, workspacePath });
1073
1280
  recoverPendingHydrationTransaction(workspacePath);
1074
1281
  const status = gitResult(workspacePath, ["status", "--porcelain=v1", "-z"]);
@@ -1255,69 +1462,137 @@ async function synchronizeWorkspaceGit(input) {
1255
1462
  const maxDiffBytes = input.maxDiffBytes ?? MAX_WORKSPACE_GIT_DIFF_BYTES;
1256
1463
  const maxPushAttempts = Math.max(1, input.maxPushAttempts ?? 4);
1257
1464
  validateMounts(workspacePath, input.mounts);
1258
- const busyLiveMountIds = input.skipMountMirror ? [] : input.mounts.filter((mount) => !(mount.deleteWhenSourceMissing && !fs.existsSync(mount.sourcePath)) && mount.busy?.()).map(({ id }) => id).sort();
1259
- if (busyLiveMountIds.length > 0) {
1260
- const cloneExists = fs.existsSync(path.join(workspacePath, ".git"));
1261
- const localHead = cloneExists ? revParse(workspacePath, "HEAD") : null;
1262
- const remoteHead2 = cloneExists ? revParse(workspacePath, `refs/remotes/origin/${WORKSPACE_GIT_BRANCH}`) : null;
1263
- return {
1264
- outcome: "no_change",
1265
- startingHead: localHead,
1266
- localHead,
1267
- remoteHead: remoteHead2,
1268
- publishedHead: remoteHead2,
1269
- rebaseCount: 0,
1270
- diffSizeBytes: 0,
1271
- affectedPaths: [],
1272
- activeMountIds: [],
1273
- skippedMountIds: input.mounts.map(({ id }) => id).sort()
1274
- };
1275
- }
1276
- const initial = ensureWorkspaceGitClone({ ...input, workspacePath });
1465
+ const requiredAncestorHeads = requiredWorkspaceAncestorHeads(input.requiredAncestorHeads);
1466
+ const preserveResolutionInProgress = input.skipMountMirror === true;
1467
+ const initial = ensureWorkspaceGitClone({ ...input, workspacePath, preserveResolutionInProgress });
1468
+ assertWorkspaceRemediationAncestry({
1469
+ workspacePath,
1470
+ currentHead: initial.localHead,
1471
+ remoteHead: initial.remoteHead,
1472
+ requiredAncestorHeads
1473
+ });
1474
+ const verifiedAncestorHeads = requiredAncestorHeads.length > 0 ? requiredAncestorHeads : void 0;
1277
1475
  const startingHead = initial.localHead;
1278
1476
  const receiptBeforeInitialHydration = readHydratedWorkspaceReceipt(workspacePath);
1279
1477
  const deferredMountIds = /* @__PURE__ */ new Set();
1280
- if (initial.localHead && initial.remoteHead && initial.remoteHead !== initial.localHead && receiptBeforeInitialHydration?.head === initial.localHead) {
1478
+ if (initial.localHead && initial.remoteHead && initial.remoteHead !== initial.localHead) {
1281
1479
  for (const mount of input.mounts) {
1282
- if (!mount.deleteWhenSourceMissing && mount.preserveLocalOnInitialOuterAbsence !== true && mount.preserveLocalOnHydrationBasisChange !== true && !workspaceHydrationReceiptCoversMount(receiptBeforeInitialHydration, initial.localHead, mount)) {
1480
+ if (!mount.deleteWhenSourceMissing && mount.preserveLocalOnInitialOuterAbsence !== true && mount.preserveLocalOnHydrationBasisChange !== true && workspaceHydrationReceiptMountBasisHead(receiptBeforeInitialHydration, mount) === null) {
1283
1481
  deferredMountIds.add(mount.id);
1284
1482
  }
1285
1483
  }
1286
1484
  }
1287
1485
  recoverWorkspaceGitHydration(workspacePath, input.mounts, {
1288
- ...deferredMountIds.size > 0 ? { deferMountIds: deferredMountIds } : {}
1486
+ ...deferredMountIds.size > 0 ? { deferMountIds: deferredMountIds } : {},
1487
+ preserveResolutionInProgress,
1488
+ preserveStaleBases: true
1289
1489
  });
1290
- const receiptRequiredLiveMounts = input.mounts.filter(({ sourcePath }) => fs.existsSync(sourcePath));
1291
1490
  const selected = activeMounts(input.mounts);
1292
- const projectedActiveMounts = selected.active.filter(({ id }) => !deferredMountIds.has(id));
1293
- const projectedTombstones = selected.tombstones.filter(({ id }) => !deferredMountIds.has(id));
1491
+ const projectionMutationTokens = new Map(
1492
+ input.mounts.flatMap((mount) => mount.mutationToken ? [[mount.id, mount.mutationToken()]] : [])
1493
+ );
1494
+ const cycleSkippedMountIds = new Set(selected.skipped.map(({ id }) => id));
1495
+ const receiptRequiredLiveMounts = selected.active;
1294
1496
  const projectionBasisHead = revParse(workspacePath, "HEAD");
1295
1497
  const projectionBasisReceipt = readHydratedWorkspaceReceipt(workspacePath);
1296
- const projectionReceiptMounts = configuredWorkspaceHydrationBasisMounts(workspacePath, input.mounts).filter(
1297
- (mount) => !deferredMountIds.has(mount.id) && Boolean(projectionBasisHead && workspaceHydrationReceiptCoversMount(projectionBasisReceipt, projectionBasisHead, mount))
1298
- );
1498
+ const allProjectionReceiptMounts = configuredWorkspaceHydrationBasisMounts(workspacePath, input.mounts);
1499
+ const fastProjectionMounts = [];
1500
+ const mergedProjectionTrees = /* @__PURE__ */ new Map();
1501
+ const mergedProjectionMounts = [];
1502
+ const projectionSkippedIds = /* @__PURE__ */ new Set();
1503
+ let projectionWarning;
1504
+ const refreshCycleSkippedMounts = (mounts = input.mounts) => {
1505
+ for (const mount of mounts) {
1506
+ const projectionToken = projectionMutationTokens.get(mount.id);
1507
+ if (projectionToken !== void 0 && mount.mutationToken?.() !== projectionToken) cycleSkippedMountIds.add(mount.id);
1508
+ if (mount.busy?.()) cycleSkippedMountIds.add(mount.id);
1509
+ }
1510
+ };
1511
+ const classifyMounts = (activeCandidates, additionalSkippedIds = []) => {
1512
+ refreshCycleSkippedMounts();
1513
+ const skippedIds = /* @__PURE__ */ new Set([...cycleSkippedMountIds, ...projectionSkippedIds, ...additionalSkippedIds]);
1514
+ return {
1515
+ activeMountIds: [...new Set(activeCandidates.filter(({ id }) => !skippedIds.has(id)).map(({ id }) => id))].sort(),
1516
+ skippedMountIds: [...skippedIds].sort()
1517
+ };
1518
+ };
1519
+ if (!input.skipMountMirror) {
1520
+ const mergeProjectionEnabled = process.env.R5D_WORKSPACE_MERGE_PROJECTION !== "0";
1521
+ const mergeSupport = mergeProjectionEnabled ? workspaceMergeProjectionSupport() : { supported: false, error: "Workspace merge projection is disabled by R5D_WORKSPACE_MERGE_PROJECTION=0" };
1522
+ for (const mount of [...selected.active, ...selected.tombstones]) {
1523
+ if (deferredMountIds.has(mount.id)) continue;
1524
+ const basisHead = workspaceHydrationReceiptMountBasisHead(projectionBasisReceipt, mount);
1525
+ if (projectionBasisHead === null || basisHead === projectionBasisHead) {
1526
+ fastProjectionMounts.push(mount);
1527
+ continue;
1528
+ }
1529
+ if (basisHead === null) {
1530
+ deferredMountIds.add(mount.id);
1531
+ continue;
1532
+ }
1533
+ if (!mergeSupport.supported) {
1534
+ projectionSkippedIds.add(mount.id);
1535
+ projectionWarning = mergeSupport.error ?? "Git 2.40 or newer is required for stale workspace mount projection";
1536
+ continue;
1537
+ }
1538
+ const merged = mergeWorkspaceProjectionMount({
1539
+ workspacePath,
1540
+ mount: {
1541
+ id: mount.id,
1542
+ sourcePath: mount.sourcePath,
1543
+ workspaceRelativePath: normalizedWorkspaceMountPath(mount.workspaceRelativePath),
1544
+ sourceMode: mount.sourceMode
1545
+ },
1546
+ basisHead,
1547
+ currentHead: projectionBasisHead,
1548
+ attemptId
1549
+ });
1550
+ if (merged.kind === "conflict") {
1551
+ const refs = snapshotConflict({ workspacePath, attemptId, localHead: merged.oursCommit, remoteHead: projectionBasisHead });
1552
+ return {
1553
+ outcome: "conflict_blocked",
1554
+ startingHead,
1555
+ localHead: merged.oursCommit,
1556
+ remoteHead: initial.remoteHead,
1557
+ publishedHead: initial.remoteHead,
1558
+ rebaseCount: 0,
1559
+ diffSizeBytes: 0,
1560
+ affectedPaths: merged.conflictPaths,
1561
+ activeMountIds: [],
1562
+ skippedMountIds: input.mounts.map(({ id }) => id).sort(),
1563
+ conflictPaths: merged.conflictPaths,
1564
+ conflictSnapshotRefs: refs,
1565
+ conflictKind: "projection_merge",
1566
+ ...verifiedAncestorHeads ? { verifiedAncestorHeads } : {},
1567
+ error: merged.error
1568
+ };
1569
+ }
1570
+ mergedProjectionTrees.set(mount.id, merged.resultTree);
1571
+ mergedProjectionMounts.push(mount);
1572
+ }
1573
+ }
1574
+ if (projectionWarning) process.stderr.write(`[r5d-worker] ${projectionWarning}
1575
+ `);
1299
1576
  const completedMountSelection = (requireCurrentHeadReceipt = false) => {
1300
1577
  const currentHeadBeforeHydration = revParse(workspacePath, "HEAD");
1301
1578
  const completionReceiptMounts = configuredWorkspaceHydrationBasisMounts(workspacePath, input.mounts);
1302
- if (currentHeadBeforeHydration && workspaceHydrationReceiptMatchesMounts(
1303
- readHydratedWorkspaceReceipt(workspacePath),
1304
- currentHeadBeforeHydration,
1305
- completionReceiptMounts
1306
- )) {
1307
- const lateBusyIds = selected.active.filter((mount) => mount.busy?.()).map(({ id }) => id);
1308
- const lateBusy = new Set(lateBusyIds);
1309
- return {
1310
- activeMountIds: [...selected.active.filter(({ id }) => !lateBusy.has(id)), ...selected.tombstones].map(({ id }) => id).sort(),
1311
- skippedMountIds: [.../* @__PURE__ */ new Set([...selected.skipped.map(({ id }) => id), ...lateBusyIds])].sort()
1312
- };
1313
- }
1314
1579
  const currentReceiptBeforeHydration = readHydratedWorkspaceReceipt(workspacePath);
1580
+ refreshCycleSkippedMounts(completionReceiptMounts);
1315
1581
  const mountIsAlreadyCovered = (mount) => Boolean(
1316
1582
  currentHeadBeforeHydration && workspaceHydrationReceiptCoversMount(currentReceiptBeforeHydration, currentHeadBeforeHydration, mount)
1317
1583
  );
1318
- const uncoveredCompletionMounts = selected.active.filter((mount) => !mountIsAlreadyCovered(mount));
1319
- const uncoveredRequiredLiveMounts = receiptRequiredLiveMounts.filter((mount) => !mountIsAlreadyCovered(mount));
1320
- const uncoveredCompletionBasisMounts = completionReceiptMounts.filter((mount) => !mountIsAlreadyCovered(mount));
1584
+ if (currentHeadBeforeHydration && completionReceiptMounts.every((mount) => cycleSkippedMountIds.has(mount.id) || mountIsAlreadyCovered(mount))) {
1585
+ return classifyMounts([...selected.active, ...selected.tombstones]);
1586
+ }
1587
+ const uncoveredCompletionMounts = selected.active.filter(
1588
+ (mount) => !cycleSkippedMountIds.has(mount.id) && !projectionSkippedIds.has(mount.id) && !mountIsAlreadyCovered(mount)
1589
+ );
1590
+ const uncoveredRequiredLiveMounts = receiptRequiredLiveMounts.filter(
1591
+ (mount) => !cycleSkippedMountIds.has(mount.id) && !projectionSkippedIds.has(mount.id) && !mountIsAlreadyCovered(mount)
1592
+ );
1593
+ const uncoveredCompletionBasisMounts = completionReceiptMounts.filter(
1594
+ (mount) => !cycleSkippedMountIds.has(mount.id) && !projectionSkippedIds.has(mount.id) && !mountIsAlreadyCovered(mount)
1595
+ );
1321
1596
  const hydration = hydrateWorkspaceGitMountsTransactionally({
1322
1597
  workspacePath,
1323
1598
  mounts: uncoveredCompletionMounts,
@@ -1326,22 +1601,32 @@ async function synchronizeWorkspaceGit(input) {
1326
1601
  requiredMounts: uncoveredRequiredLiveMounts,
1327
1602
  recordCurrentHead: true
1328
1603
  });
1604
+ for (const id of hydration.skippedMountIds) cycleSkippedMountIds.add(id);
1605
+ refreshCycleSkippedMounts(completionReceiptMounts);
1329
1606
  if (requireCurrentHeadReceipt) {
1330
1607
  const currentHead = revParse(workspacePath, "HEAD");
1331
- if (currentHead && !workspaceHydrationReceiptMatchesMounts(readHydratedWorkspaceReceipt(workspacePath), currentHead, completionReceiptMounts)) {
1608
+ const receipt = readHydratedWorkspaceReceipt(workspacePath);
1609
+ const transactionSkippedMountIds = new Set(hydration.skippedMountIds);
1610
+ const idleMounts = completionReceiptMounts.filter(
1611
+ (mount) => !cycleSkippedMountIds.has(mount.id) && !transactionSkippedMountIds.has(mount.id) && !mount.busy?.() && !projectionSkippedIds.has(mount.id)
1612
+ );
1613
+ if (currentHead && !idleMounts.every((mount) => workspaceHydrationReceiptCoversMount(receipt, currentHead, mount))) {
1332
1614
  throw new Error(`Workspace inbound integration ${currentHead} could not be fully hydrated before publication continued`);
1333
1615
  }
1334
1616
  }
1335
- return {
1336
- activeMountIds: [...selected.active, ...selected.tombstones].map(({ id }) => id).sort(),
1337
- skippedMountIds: [.../* @__PURE__ */ new Set([...selected.skipped.map(({ id }) => id), ...hydration.skippedMountIds])].sort()
1338
- };
1617
+ return classifyMounts([...selected.active, ...selected.tombstones], hydration.skippedMountIds);
1339
1618
  };
1340
1619
  let newlyStagedPaths = [];
1341
1620
  const stageAndCommitWorkspace = () => {
1621
+ if (workspaceResolutionInProgress(workspacePath) || workspaceHasUnmergedEntries(workspacePath)) {
1622
+ throw new Error("Outer workspace clone has an unfinished merge/rebase; finish or abort it before synchronizing");
1623
+ }
1342
1624
  git(workspacePath, ["add", "-A", "--", "."], "stage workspace working trees");
1343
1625
  newlyStagedPaths = stagedPaths(workspacePath);
1344
1626
  if (newlyStagedPaths.length > 0) {
1627
+ if (workspaceResolutionInProgress(workspacePath) || workspaceHasUnmergedEntries(workspacePath)) {
1628
+ throw new Error("Outer workspace clone has an unfinished merge/rebase; finish or abort it before synchronizing");
1629
+ }
1345
1630
  git(
1346
1631
  workspacePath,
1347
1632
  [
@@ -1356,11 +1641,22 @@ async function synchronizeWorkspaceGit(input) {
1356
1641
  if (!input.skipMountMirror) {
1357
1642
  beginWorkspaceCheckoutTransition(workspacePath);
1358
1643
  try {
1359
- mirrorMountsToWorkspace(workspacePath, projectedActiveMounts);
1360
- removeWorkspaceMounts(workspacePath, projectedTombstones);
1644
+ const selectedActiveIds = new Set(selected.active.map(({ id }) => id));
1645
+ const fastActiveMounts = fastProjectionMounts.filter(({ id }) => selectedActiveIds.has(id));
1646
+ const fastTombstones = fastProjectionMounts.filter(({ id }) => !selectedActiveIds.has(id));
1647
+ mirrorMountsToWorkspace(workspacePath, fastActiveMounts);
1648
+ removeWorkspaceMounts(workspacePath, fastTombstones);
1649
+ for (const mount of mergedProjectionMounts) {
1650
+ materializeWorkspaceProjectionTree({
1651
+ workspacePath,
1652
+ workspaceRelativePath: normalizedWorkspaceMountPath(mount.workspaceRelativePath),
1653
+ resultTree: mergedProjectionTrees.get(mount.id)
1654
+ });
1655
+ }
1656
+ const restoreFromHeadIds = /* @__PURE__ */ new Set([...deferredMountIds, ...selected.skipped.map(({ id }) => id)]);
1361
1657
  restoreDeferredWorkspaceMountsFromHead(
1362
1658
  workspacePath,
1363
- input.mounts.filter(({ id }) => deferredMountIds.has(id))
1659
+ input.mounts.filter(({ id }) => restoreFromHeadIds.has(id))
1364
1660
  );
1365
1661
  stageAndCommitWorkspace();
1366
1662
  fsyncWorkspaceCheckoutTree(workspacePath);
@@ -1374,9 +1670,15 @@ async function synchronizeWorkspaceGit(input) {
1374
1670
  if (!input.skipMountMirror) {
1375
1671
  const projectedHead = revParse(workspacePath, "HEAD");
1376
1672
  if (!projectedHead) throw new Error("Workspace projection did not retain a local HEAD");
1377
- const targetProjectionReceipt = workspaceHydrationReceipt(projectedHead, projectionReceiptMounts);
1378
- if (!workspaceHydrationReceiptsEqual(readHydratedWorkspaceReceipt(workspacePath), targetProjectionReceipt)) {
1379
- fsyncHydratedWorkspaceMounts([...projectedActiveMounts, ...projectedTombstones]);
1673
+ const targetProjectionReceipt = receiptWithMountsAtHead(
1674
+ projectionBasisReceipt,
1675
+ projectedHead,
1676
+ fastProjectionMounts,
1677
+ allProjectionReceiptMounts
1678
+ );
1679
+ const currentProjectionReceipt = readHydratedWorkspaceReceipt(workspacePath);
1680
+ if (!workspaceHydrationReceiptsEqual(currentProjectionReceipt, targetProjectionReceipt) || !hydratedWorkspaceReceiptFileIsV3(workspacePath) || !workspaceBasisRefsAreCurrent(workspacePath, targetProjectionReceipt)) {
1681
+ fsyncHydratedWorkspaceMounts(fastProjectionMounts);
1380
1682
  updateHydratedWorkspaceReceipt(workspacePath, targetProjectionReceipt);
1381
1683
  }
1382
1684
  completeWorkspaceCheckoutTransition(workspacePath);
@@ -1385,9 +1687,16 @@ async function synchronizeWorkspaceGit(input) {
1385
1687
  let rebaseCount = 0;
1386
1688
  let updated = false;
1387
1689
  for (let pushAttempt = 0; pushAttempt < maxPushAttempts; pushAttempt += 1) {
1690
+ assertWorkspaceRemediationAncestry({
1691
+ workspacePath,
1692
+ currentHead: revParse(workspacePath, "HEAD"),
1693
+ remoteHead,
1694
+ requiredAncestorHeads
1695
+ });
1388
1696
  const reconciled = synchronizeWithFetchedHead({ workspacePath, remoteHead, attemptId });
1389
1697
  if (reconciled.kind === "conflict") {
1390
1698
  const localHead2 = revParse(workspacePath, "HEAD");
1699
+ const classifiedMounts = classifyMounts([...fastProjectionMounts, ...mergedProjectionMounts], deferredMountIds);
1391
1700
  return {
1392
1701
  outcome: "conflict_blocked",
1393
1702
  startingHead,
@@ -1397,10 +1706,11 @@ async function synchronizeWorkspaceGit(input) {
1397
1706
  rebaseCount: rebaseCount + 1,
1398
1707
  diffSizeBytes: 0,
1399
1708
  affectedPaths: [.../* @__PURE__ */ new Set([...newlyStagedPaths, ...reconciled.conflictPaths])].sort(),
1400
- activeMountIds: [...projectedActiveMounts, ...projectedTombstones].map(({ id }) => id).sort(),
1401
- skippedMountIds: [.../* @__PURE__ */ new Set([...selected.skipped.map(({ id }) => id), ...deferredMountIds])].sort(),
1709
+ ...classifiedMounts,
1402
1710
  conflictPaths: reconciled.conflictPaths,
1403
1711
  conflictSnapshotRefs: reconciled.refs,
1712
+ conflictKind: "integration_rebase",
1713
+ ...verifiedAncestorHeads ? { verifiedAncestorHeads } : {},
1404
1714
  error: reconciled.error
1405
1715
  };
1406
1716
  }
@@ -1409,6 +1719,7 @@ async function synchronizeWorkspaceGit(input) {
1409
1719
  if (reconciled.updated || reconciled.rebased) completedMountSelection(true);
1410
1720
  const localHead = revParse(workspacePath, "HEAD");
1411
1721
  if (!localHead) {
1722
+ const classifiedMounts = classifyMounts([...selected.active, ...selected.tombstones], deferredMountIds);
1412
1723
  return {
1413
1724
  outcome: "no_change",
1414
1725
  startingHead,
@@ -1418,13 +1729,16 @@ async function synchronizeWorkspaceGit(input) {
1418
1729
  rebaseCount,
1419
1730
  diffSizeBytes: 0,
1420
1731
  affectedPaths: [],
1421
- activeMountIds: [...selected.active, ...selected.tombstones].map(({ id }) => id).sort(),
1422
- skippedMountIds: selected.skipped.map(({ id }) => id).sort()
1732
+ ...classifiedMounts,
1733
+ ...verifiedAncestorHeads ? { verifiedAncestorHeads } : {},
1734
+ ...projectionWarning ? { error: projectionWarning } : {}
1423
1735
  };
1424
1736
  }
1425
1737
  const paths = changedPaths(workspacePath, remoteHead, localHead);
1426
- const size = paths.length > 0 ? await diffSizeBytes(workspacePath, remoteHead, localHead, maxDiffBytes) : 0;
1738
+ const size = paths.length > 0 ? await (input.measureDiffSize ?? diffSizeBytes)(workspacePath, remoteHead, localHead, maxDiffBytes) : 0;
1739
+ input.assertStillAdmitted?.();
1427
1740
  if (paths.length > 0 && size > maxDiffBytes && !input.allowLargeDiff) {
1741
+ const classifiedMounts = classifyMounts([...selected.active, ...selected.tombstones]);
1428
1742
  return {
1429
1743
  outcome: "large_diff_blocked",
1430
1744
  startingHead,
@@ -1434,17 +1748,19 @@ async function synchronizeWorkspaceGit(input) {
1434
1748
  rebaseCount,
1435
1749
  diffSizeBytes: size,
1436
1750
  affectedPaths: paths,
1437
- activeMountIds: [...selected.active, ...selected.tombstones].map(({ id }) => id).sort(),
1438
- skippedMountIds: selected.skipped.map(({ id }) => id).sort(),
1751
+ ...classifiedMounts,
1752
+ ...verifiedAncestorHeads ? { verifiedAncestorHeads } : {},
1439
1753
  error: `Workspace diff exceeds the ${maxDiffBytes}-byte automatic publication limit`
1440
1754
  };
1441
1755
  }
1442
1756
  if (localHead === remoteHead) {
1757
+ assertWorkspaceRemediationAncestry({ workspacePath, currentHead: localHead, remoteHead, requiredAncestorHeads });
1443
1758
  const completedMounts = completedMountSelection(Boolean(input.skipMountMirror));
1444
1759
  await input.afterWorkspacePublished?.({
1445
1760
  publishedHead: localHead,
1446
1761
  activeMountIds: completedMounts.activeMountIds
1447
1762
  });
1763
+ input.assertStillAdmitted?.();
1448
1764
  return {
1449
1765
  outcome: updated ? "updated" : "no_change",
1450
1766
  startingHead,
@@ -1454,9 +1770,12 @@ async function synchronizeWorkspaceGit(input) {
1454
1770
  rebaseCount,
1455
1771
  diffSizeBytes: size,
1456
1772
  affectedPaths: paths,
1457
- ...completedMounts
1773
+ ...completedMounts,
1774
+ ...verifiedAncestorHeads ? { verifiedAncestorHeads } : {},
1775
+ ...projectionWarning ? { error: projectionWarning } : {}
1458
1776
  };
1459
1777
  }
1778
+ assertWorkspaceRemediationAncestry({ workspacePath, currentHead: localHead, remoteHead, requiredAncestorHeads });
1460
1779
  const pushArgs = [
1461
1780
  ...gitTransportSecurityArgs(input.remoteUrl, input.credentialHelper, input.credentialUsername),
1462
1781
  "push",
@@ -1466,12 +1785,14 @@ async function synchronizeWorkspaceGit(input) {
1466
1785
  pushArgs.push("origin", `HEAD:refs/heads/${WORKSPACE_GIT_BRANCH}`);
1467
1786
  const push = gitResult(workspacePath, pushArgs);
1468
1787
  if (push.exitCode === 0) {
1788
+ assertWorkspaceRemediationAncestry({ workspacePath, currentHead: localHead, remoteHead, requiredAncestorHeads });
1469
1789
  updateIntegratedWorkspaceHead(workspacePath, localHead);
1470
1790
  const completedMounts = completedMountSelection(Boolean(input.skipMountMirror));
1471
1791
  await input.afterWorkspacePublished?.({
1472
1792
  publishedHead: localHead,
1473
1793
  activeMountIds: completedMounts.activeMountIds
1474
1794
  });
1795
+ input.assertStillAdmitted?.();
1475
1796
  return {
1476
1797
  outcome: "pushed",
1477
1798
  startingHead,
@@ -1481,7 +1802,9 @@ async function synchronizeWorkspaceGit(input) {
1481
1802
  rebaseCount,
1482
1803
  diffSizeBytes: size,
1483
1804
  affectedPaths: paths,
1484
- ...completedMounts
1805
+ ...completedMounts,
1806
+ ...verifiedAncestorHeads ? { verifiedAncestorHeads } : {},
1807
+ ...projectionWarning ? { error: projectionWarning } : {}
1485
1808
  };
1486
1809
  }
1487
1810
  if (!/(non-fast-forward|fetch first|rejected|failed to push some refs)/i.test(`${push.stderr}
@@ -1506,6 +1829,8 @@ export {
1506
1829
  WORKSPACE_GIT_CONFIRMED_LARGE_DIFF_PUSH_OPTION,
1507
1830
  WORKSPACE_GIT_HYDRATED_RECEIPT,
1508
1831
  WORKSPACE_GIT_HYDRATION_TRANSACTION,
1832
+ WorkspaceRemediationAncestryError,
1833
+ configureExistingWorkspaceGitForRemediation,
1509
1834
  ensureWorkspaceGitClone,
1510
1835
  hydrateWorkspaceGitMounts,
1511
1836
  recoverWorkspaceGitHydration,