@ricsam/r5d-worker 0.0.55 → 0.0.56

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.
@@ -15,6 +15,8 @@ const DESTRUCTIVE_CHECKOUT_MIN_TRACKED_FILES = 20;
15
15
  const DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO = 0.8;
16
16
  const ACTIVE_GIT_LOCK_PATHS = ["index.lock", "HEAD.lock", "packed-refs.lock", "shallow.lock"];
17
17
  const mirrorComparisonCache = /* @__PURE__ */ new Map();
18
+ const gitObjectFormatCache = /* @__PURE__ */ new Map();
19
+ const hydrationEntryCache = /* @__PURE__ */ new Map();
18
20
  function gitArgs(input, args) {
19
21
  const configArgs = ["-c", "submodule.recurse=false", "-c", "fetch.recurseSubmodules=false", "-c", "push.recurseSubmodules=false"];
20
22
  return input.authHeader ? ["git", ...configArgs, "-c", `http.extraHeader=${input.authHeader}`, ...args] : ["git", ...configArgs, ...args];
@@ -219,6 +221,127 @@ function lstatOrNull(filePath) {
219
221
  throw error;
220
222
  }
221
223
  }
224
+ const UNSTABLE_VISIBLE_ENTRY = /* @__PURE__ */ Symbol("unstable-visible-entry");
225
+ function hydrationEntriesEqual(left, right) {
226
+ if (!left || !right || left.kind !== right.kind || left.objectId !== right.objectId) return left === right;
227
+ return left.kind === "gitlink" || right.kind === "blob" && left.mode === right.mode;
228
+ }
229
+ function gitObjectFormat(input) {
230
+ const cached = gitObjectFormatCache.get(input.shadowRoot);
231
+ if (cached) return cached;
232
+ const format = runGit(input, input.shadowRoot, ["rev-parse", "--show-object-format"], "inspect workspace object format");
233
+ if (format !== "sha1" && format !== "sha256") throw new Error(`Unsupported Git object format ${JSON.stringify(format)}`);
234
+ gitObjectFormatCache.set(input.shadowRoot, format);
235
+ return format;
236
+ }
237
+ function hashGitBlobBuffer(input, content) {
238
+ return createHash(gitObjectFormat(input)).update(`blob ${content.byteLength}\0`).update(content).digest("hex");
239
+ }
240
+ function hashGitBlobFile(input, filePath, size) {
241
+ const hash = createHash(gitObjectFormat(input));
242
+ hash.update(`blob ${size}\0`);
243
+ const file = fs.openSync(filePath, "r");
244
+ const buffer = Buffer.allocUnsafe(Math.min(MIRROR_COMPARE_BUFFER_BYTES, Math.max(1, size)));
245
+ try {
246
+ let offset = 0;
247
+ while (offset < size) {
248
+ const bytesRead = fs.readSync(file, buffer, 0, Math.min(buffer.length, size - offset), offset);
249
+ if (bytesRead === 0) return null;
250
+ hash.update(buffer.subarray(0, bytesRead));
251
+ offset += bytesRead;
252
+ }
253
+ return hash.digest("hex");
254
+ } finally {
255
+ fs.closeSync(file);
256
+ }
257
+ }
258
+ function filesystemHydrationEntry(input, filePath) {
259
+ const before = lstatOrNull(filePath);
260
+ if (!before) return void 0;
261
+ if (!before.isFile() && !before.isSymbolicLink()) return UNSTABLE_VISIBLE_ENTRY;
262
+ const objectFormat = gitObjectFormat(input);
263
+ const signature = entrySignature(before);
264
+ const cached = hydrationEntryCache.get(filePath);
265
+ if (cached?.signature === signature && cached.objectFormat === objectFormat) return cached.entry;
266
+ let objectId = null;
267
+ if (before.isSymbolicLink()) {
268
+ try {
269
+ objectId = hashGitBlobBuffer(input, Buffer.from(fs.readlinkSync(filePath)));
270
+ } catch {
271
+ return UNSTABLE_VISIBLE_ENTRY;
272
+ }
273
+ } else {
274
+ try {
275
+ objectId = hashGitBlobFile(input, filePath, before.size);
276
+ } catch {
277
+ return UNSTABLE_VISIBLE_ENTRY;
278
+ }
279
+ }
280
+ const after = lstatOrNull(filePath);
281
+ if (!objectId || !after || entrySignature(before) !== entrySignature(after)) return UNSTABLE_VISIBLE_ENTRY;
282
+ const entry = {
283
+ kind: "blob",
284
+ mode: before.isSymbolicLink() ? "120000" : (before.mode & 73) !== 0 ? "100755" : "100644",
285
+ objectId
286
+ };
287
+ if (hydrationEntryCache.size >= MAX_MIRROR_COMPARISON_CACHE_ENTRIES) hydrationEntryCache.clear();
288
+ hydrationEntryCache.set(filePath, { signature, objectFormat, entry });
289
+ return entry;
290
+ }
291
+ function projectVisibleHydrationEntry(input, visibleRoot, filePath, gitlinks) {
292
+ if (!hasSafeDirectoryAncestors(visibleRoot, filePath, input.projectsRoot)) return UNSTABLE_VISIBLE_ENTRY;
293
+ const gitlink = gitlinks.get(filePath);
294
+ if (gitlink) {
295
+ const target = lstatOrNull(path.join(visibleRoot, ...filePath.split("/")));
296
+ if (target && !target.isDirectory()) return UNSTABLE_VISIBLE_ENTRY;
297
+ return { kind: "gitlink", objectId: gitlink.objectId };
298
+ }
299
+ return filesystemHydrationEntry(input, path.join(visibleRoot, ...filePath.split("/")));
300
+ }
301
+ function revisionHydrationPreimage(input, revision, relativeRoot) {
302
+ const result = runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "ls-tree", "-r", "-z", revision, "--", relativeRoot]);
303
+ if (result.exitCode !== 0) {
304
+ throw new Error(
305
+ `Inspect workspace hydration baseline ${revision}:${relativeRoot}: ${result.stderr || `git exited ${result.exitCode}`}`
306
+ );
307
+ }
308
+ if (!result.stdout) return /* @__PURE__ */ new Map();
309
+ const prefix = `${relativeRoot}/`;
310
+ const preimage = /* @__PURE__ */ new Map();
311
+ for (const serialized of result.stdout.split("\0").filter(Boolean)) {
312
+ const separator = serialized.indexOf(" ");
313
+ if (separator === -1) continue;
314
+ const [mode, type, objectId] = serialized.slice(0, separator).split(" ");
315
+ const workspacePath = serialized.slice(separator + 1);
316
+ if (!objectId || !workspacePath.startsWith(prefix)) continue;
317
+ const filePath = workspacePath.slice(prefix.length);
318
+ if (mode === "160000" && type === "commit") {
319
+ preimage.set(filePath, { kind: "gitlink", objectId });
320
+ } else if ((mode === "100644" || mode === "100755" || mode === "120000") && type === "blob") {
321
+ preimage.set(filePath, { kind: "blob", mode, objectId });
322
+ }
323
+ }
324
+ return preimage;
325
+ }
326
+ function indexHydrationPreimage(input, relativeRoot) {
327
+ const prefix = `${relativeRoot}/`;
328
+ const entries = parseGitIndexEntries(
329
+ runGit(input, input.shadowRoot, ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", relativeRoot], "inspect hydrated workspace")
330
+ );
331
+ const unmergedPaths = entries.filter((entry) => entry.stage !== "0").map((entry) => entry.filePath);
332
+ if (unmergedPaths.length > 0) throw new Error(`Canonical workspace has unmerged Git index stages: ${JSON.stringify(unmergedPaths)}`);
333
+ const preimage = /* @__PURE__ */ new Map();
334
+ for (const entry of entries) {
335
+ if (!entry.filePath.startsWith(prefix)) continue;
336
+ const filePath = entry.filePath.slice(prefix.length);
337
+ if (entry.mode === "160000") {
338
+ preimage.set(filePath, { kind: "gitlink", objectId: entry.objectId });
339
+ } else if (entry.mode === "100644" || entry.mode === "100755" || entry.mode === "120000") {
340
+ preimage.set(filePath, { kind: "blob", mode: entry.mode, objectId: entry.objectId });
341
+ }
342
+ }
343
+ return preimage;
344
+ }
222
345
  function worktreeDiffersFromIndex(checkoutPath, filePath) {
223
346
  const result = Bun.spawnSync(
224
347
  ["git", "--literal-pathspecs", "-c", "core.fileMode=true", "diff", "--quiet", "--no-ext-diff", "--", filePath],
@@ -373,11 +496,17 @@ function removeEmptyDirectories(root, opaqueRoots = []) {
373
496
  };
374
497
  visit(root, "");
375
498
  }
376
- function copyEntry(sourceRoot, targetRoot, relativePath) {
499
+ function copyEntry(sourceRoot, targetRoot, relativePath, trustedRoots = {}) {
377
500
  const sourcePath = path.resolve(sourceRoot, ...relativePath.split("/"));
378
501
  const targetPath = path.resolve(targetRoot, ...relativePath.split("/"));
379
502
  assertInside(sourceRoot, sourcePath, "Workspace source path");
380
503
  assertInside(targetRoot, targetPath, "Workspace target path");
504
+ if (!hasSafeDirectoryAncestors(sourceRoot, relativePath, trustedRoots.source ?? sourceRoot)) {
505
+ throw new Error(`Workspace source path has a symlink or non-directory ancestor: ${relativePath}`);
506
+ }
507
+ if (!hasSafeDirectoryAncestors(targetRoot, relativePath, trustedRoots.target ?? targetRoot)) {
508
+ throw new Error(`Workspace target path has a symlink or non-directory ancestor: ${relativePath}`);
509
+ }
381
510
  const stat = fs.lstatSync(sourcePath);
382
511
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
383
512
  fs.rmSync(targetPath, { recursive: true, force: true });
@@ -390,6 +519,22 @@ function copyEntry(sourceRoot, targetRoot, relativePath) {
390
519
  fs.chmodSync(targetPath, stat.mode);
391
520
  }
392
521
  }
522
+ function hasSafeDirectoryAncestors(root, relativePath, trustedRoot = root) {
523
+ const resolvedTrustedRoot = path.resolve(trustedRoot);
524
+ const trustedRootStat = lstatOrNull(resolvedTrustedRoot);
525
+ if (trustedRootStat && (!trustedRootStat.isDirectory() || trustedRootStat.isSymbolicLink())) return false;
526
+ const resolvedRoot = path.resolve(root);
527
+ const targetParent = path.dirname(path.resolve(resolvedRoot, ...relativePath.split("/")));
528
+ const relativeParent = path.relative(resolvedTrustedRoot, targetParent);
529
+ if (relativeParent === ".." || relativeParent.startsWith(`..${path.sep}`) || path.isAbsolute(relativeParent)) return false;
530
+ let current = resolvedTrustedRoot;
531
+ for (const segment of relativeParent.split(path.sep).filter(Boolean)) {
532
+ current = path.join(current, segment);
533
+ const stat = lstatOrNull(current);
534
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink())) return false;
535
+ }
536
+ return true;
537
+ }
393
538
  function entrySignature(stat) {
394
539
  return [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
395
540
  }
@@ -441,7 +586,10 @@ function entriesEqual(sourcePath, targetPath) {
441
586
  }
442
587
  return equal;
443
588
  }
444
- function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles, opaqueTargetRoots = [], opaqueSourceRoots = []) {
589
+ function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles, opaqueTargetRoots = [], opaqueSourceRoots = [], trustedRoots = {}) {
590
+ if (!hasSafeDirectoryAncestors(targetRoot, ".r5d-mirror-root", trustedRoots.target ?? targetRoot)) {
591
+ throw new Error(`Workspace target root has a symlink or non-directory ancestor: ${targetRoot}`);
592
+ }
445
593
  fs.mkdirSync(targetRoot, { recursive: true });
446
594
  const sourceSet = new Set(sourceFiles);
447
595
  for (const relativePath of targetFiles) {
@@ -449,18 +597,27 @@ function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles, opaqueT
449
597
  if (sourceSet.has(relativePath)) continue;
450
598
  const targetPath = path.resolve(targetRoot, ...relativePath.split("/"));
451
599
  assertInside(targetRoot, targetPath, "Workspace deletion path");
600
+ if (!hasSafeDirectoryAncestors(targetRoot, relativePath, trustedRoots.target ?? targetRoot)) {
601
+ throw new Error(`Workspace deletion path has a symlink or non-directory ancestor: ${relativePath}`);
602
+ }
452
603
  fs.rmSync(targetPath, { recursive: true, force: true });
453
604
  }
454
605
  for (const relativePath of sourceFiles) {
455
606
  if (pathIsWithinOpaqueRoot(relativePath, opaqueSourceRoots)) continue;
456
607
  if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
608
+ if (!hasSafeDirectoryAncestors(sourceRoot, relativePath, trustedRoots.source ?? sourceRoot)) {
609
+ throw new Error(`Workspace source path has a symlink or non-directory ancestor: ${relativePath}`);
610
+ }
611
+ if (!hasSafeDirectoryAncestors(targetRoot, relativePath, trustedRoots.target ?? targetRoot)) {
612
+ throw new Error(`Workspace target path has a symlink or non-directory ancestor: ${relativePath}`);
613
+ }
457
614
  const sourcePath = path.resolve(sourceRoot, ...relativePath.split("/"));
458
615
  const stat = lstatOrNull(sourcePath);
459
616
  if (!stat) continue;
460
617
  if (!stat.isFile() && !stat.isSymbolicLink()) continue;
461
618
  const targetPath = path.resolve(targetRoot, ...relativePath.split("/"));
462
619
  if (entriesEqual(sourcePath, targetPath)) continue;
463
- copyEntry(sourceRoot, targetRoot, relativePath);
620
+ copyEntry(sourceRoot, targetRoot, relativePath, trustedRoots);
464
621
  }
465
622
  removeEmptyDirectories(targetRoot, opaqueTargetRoots);
466
623
  }
@@ -472,18 +629,37 @@ function indexPathsUnderCheckoutPath(checkoutPath, filePath) {
472
629
  ).split("\0").filter(Boolean);
473
630
  }
474
631
  function replaceCheckoutIndexPathWithGitlink(checkoutPath, gitlink) {
475
- for (const indexPath of indexPathsUnderCheckoutPath(checkoutPath, gitlink.filePath)) {
476
- runCheckoutGitRaw(
477
- checkoutPath,
478
- ["--literal-pathspecs", "update-index", "--force-remove", "--", indexPath],
479
- `remove index entry beneath gitlink ${JSON.stringify(gitlink.filePath)}`
632
+ const zeroObjectId = "0".repeat(gitlink.objectId.length);
633
+ const records = [
634
+ ...indexPathsUnderCheckoutPath(checkoutPath, gitlink.filePath).map((indexPath) => `0 ${zeroObjectId} ${indexPath}`),
635
+ `160000 ${gitlink.objectId} ${gitlink.filePath}`
636
+ ];
637
+ const result = Bun.spawnSync(
638
+ [
639
+ "git",
640
+ "-c",
641
+ "submodule.recurse=false",
642
+ "-c",
643
+ "fetch.recurseSubmodules=false",
644
+ "-c",
645
+ "push.recurseSubmodules=false",
646
+ "update-index",
647
+ "-z",
648
+ "--index-info"
649
+ ],
650
+ {
651
+ cwd: checkoutPath,
652
+ stdin: Buffer.from(`${records.join("\0")}\0`),
653
+ stdout: "pipe",
654
+ stderr: "pipe",
655
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
656
+ }
657
+ );
658
+ if (result.exitCode !== 0) {
659
+ throw new Error(
660
+ `record gitlink ${JSON.stringify(gitlink.filePath)}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`
480
661
  );
481
662
  }
482
- runCheckoutGitRaw(
483
- checkoutPath,
484
- ["--literal-pathspecs", "update-index", "--add", "--cacheinfo", "160000", gitlink.objectId, gitlink.filePath],
485
- `record gitlink ${JSON.stringify(gitlink.filePath)}`
486
- );
487
663
  }
488
664
  function removeCheckoutIndexPath(checkoutPath, filePath) {
489
665
  runCheckoutGitRaw(
@@ -544,12 +720,15 @@ function mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks) {
544
720
  }
545
721
  for (const gitlink of visibleGitlinks) replaceShadowIndexPathWithGitlink(input, relativeRoot, gitlink);
546
722
  }
547
- function mirrorShadowGitlinksToVisible(desired, visibleRoot) {
723
+ function mirrorShadowGitlinksToVisible(desired, visibleRoot, trustedRoot) {
548
724
  const desiredByPath = new Map(desired.map((entry) => [entry.filePath, entry]));
549
725
  for (const current of checkoutGitlinks(visibleRoot)) {
550
726
  if (!desiredByPath.has(current.filePath)) removeCheckoutIndexPath(visibleRoot, current.filePath);
551
727
  }
552
728
  for (const gitlink of desired) {
729
+ if (!hasSafeDirectoryAncestors(visibleRoot, gitlink.filePath, trustedRoot)) {
730
+ throw new Error(`Visible gitlink path has a symlink or non-directory ancestor: ${gitlink.filePath}`);
731
+ }
553
732
  const visiblePath = path.join(visibleRoot, ...gitlink.filePath.split("/"));
554
733
  const existing = lstatOrNull(visiblePath);
555
734
  if (existing && !existing.isDirectory()) fs.rmSync(visiblePath, { recursive: true, force: true });
@@ -557,7 +736,7 @@ function mirrorShadowGitlinksToVisible(desired, visibleRoot) {
557
736
  replaceCheckoutIndexPathWithGitlink(visibleRoot, gitlink);
558
737
  }
559
738
  }
560
- function assertSafeVisibleGitlinkTransitions(visibleRoot, currentGitlinks, desiredGitlinks, shadowFiles) {
739
+ function assertSafeVisibleGitlinkTransitions(visibleRoot, trustedRoot, currentGitlinks, desiredGitlinks, shadowFiles) {
561
740
  const desiredGitlinkPaths = new Set(desiredGitlinks.map((entry) => entry.filePath));
562
741
  for (const current of currentGitlinks) {
563
742
  if (desiredGitlinkPaths.has(current.filePath)) continue;
@@ -565,6 +744,9 @@ function assertSafeVisibleGitlinkTransitions(visibleRoot, currentGitlinks, desir
565
744
  (filePath) => filePath === current.filePath || filePath.startsWith(`${current.filePath}/`)
566
745
  );
567
746
  if (!becomesOrdinaryPath) continue;
747
+ if (!hasSafeDirectoryAncestors(visibleRoot, current.filePath, trustedRoot)) {
748
+ throw new Error(`Visible gitlink path has a symlink or non-directory ancestor: ${current.filePath}`);
749
+ }
568
750
  const existing = lstatOrNull(path.join(visibleRoot, ...current.filePath.split("/")));
569
751
  if (!existing) continue;
570
752
  if (existing.isDirectory() && fs.readdirSync(path.join(visibleRoot, ...current.filePath.split("/"))).length === 0) {
@@ -593,15 +775,46 @@ function restoreShadowProjectSnapshot(input, relativeRoot) {
593
775
  runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
594
776
  fs.rmSync(shadowRoot, { recursive: true, force: true });
595
777
  }
778
+ function copiedHydrationPreimage(input, shadowRoot, ordinaryFiles, gitlinks = []) {
779
+ const preimage = new Map(
780
+ gitlinks.map((entry) => [entry.filePath, { kind: "gitlink", objectId: entry.objectId }])
781
+ );
782
+ for (const filePath of ordinaryFiles) {
783
+ const entry = filesystemHydrationEntry(input, path.join(shadowRoot, ...filePath.split("/")));
784
+ if (!entry || entry === UNSTABLE_VISIBLE_ENTRY) return null;
785
+ preimage.set(filePath, entry);
786
+ }
787
+ return preimage;
788
+ }
789
+ function visibleProjectMatchesCopiedPreimage(input, visibleRoot, ordinaryFiles, gitlinks, preimage, opaqueRoots) {
790
+ const currentFiles = listGitEligibleFiles(visibleRoot).filter((filePath) => !pathIsWithinOpaqueRoot(filePath, opaqueRoots));
791
+ if (currentFiles.length !== ordinaryFiles.length || currentFiles.some((filePath, index) => filePath !== ordinaryFiles[index]))
792
+ return false;
793
+ const currentGitlinks = checkoutGitlinks(visibleRoot);
794
+ if (currentGitlinks.length !== gitlinks.length || currentGitlinks.some((entry, index) => entry.filePath !== gitlinks[index]?.filePath || entry.objectId !== gitlinks[index]?.objectId)) {
795
+ return false;
796
+ }
797
+ const currentGitlinksByPath = new Map(currentGitlinks.map((entry) => [entry.filePath, entry]));
798
+ return ordinaryFiles.every((filePath) => {
799
+ const current = projectVisibleHydrationEntry(input, visibleRoot, filePath, currentGitlinksByPath);
800
+ return current !== UNSTABLE_VISIBLE_ENTRY && hydrationEntriesEqual(current, preimage.get(filePath));
801
+ });
802
+ }
596
803
  function mirrorVisibleProjectToShadow(input, manifest, branchName) {
597
804
  const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
598
- if (!fs.existsSync(path.join(visibleRoot, ".git"))) return { entries: [], opaqueRoots: [] };
805
+ const unavailableProjection = () => ({
806
+ entries: [],
807
+ opaqueRoots: [],
808
+ visiblePreimages: /* @__PURE__ */ new Map([[visibleRoot, null]])
809
+ });
810
+ if (!hasSafeDirectoryAncestors(visibleRoot, ".r5d-project-scan", input.projectsRoot)) return unavailableProjection();
811
+ if (!fs.existsSync(path.join(visibleRoot, ".git"))) return unavailableProjection();
599
812
  const isCanonicalCheckout = manifest.canonicalCheckouts?.some((checkout) => checkout.branchName === branchName);
600
813
  if (isCanonicalCheckout) {
601
814
  assertCanonicalCheckoutIndexMatchesWorktree(visibleRoot);
602
815
  }
603
816
  const beforeSnapshot = checkoutGitSnapshot(visibleRoot);
604
- if (!beforeSnapshot) return { entries: [], opaqueRoots: [] };
817
+ if (!beforeSnapshot) return unavailableProjection();
605
818
  const visibleGitlinks = checkoutGitlinks(visibleRoot);
606
819
  const visibleIndexEntries = checkoutIndexEntries(visibleRoot).filter((entry) => entry.stage === "0");
607
820
  const indexedGitlinkPaths = new Set(visibleGitlinks.map((entry) => entry.filePath));
@@ -616,21 +829,142 @@ function mirrorVisibleProjectToShadow(input, manifest, branchName) {
616
829
  const visibleFiles = listGitEligibleFiles(visibleRoot).filter((filePath) => !pathIsWithinOpaqueRoot(filePath, visibleGitlinkRoots));
617
830
  const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
618
831
  mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks);
619
- mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot), visibleGitlinkRoots, visibleGitlinkRoots);
620
- if (checkoutGitSnapshot(visibleRoot) !== beforeSnapshot) {
832
+ mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot), visibleGitlinkRoots, visibleGitlinkRoots, {
833
+ source: input.projectsRoot,
834
+ target: input.shadowRoot
835
+ });
836
+ const hydrationPreimage = copiedHydrationPreimage(input, shadowRoot, visibleFiles, visibleGitlinks);
837
+ if (!hydrationPreimage || checkoutGitSnapshot(visibleRoot) !== beforeSnapshot || !visibleProjectMatchesCopiedPreimage(input, visibleRoot, visibleFiles, visibleGitlinks, hydrationPreimage, visibleGitlinkRoots)) {
621
838
  restoreShadowProjectSnapshot(input, relativeRoot);
622
- return { entries: [], opaqueRoots: [] };
839
+ return { entries: [], opaqueRoots: [], visiblePreimages: /* @__PURE__ */ new Map([[visibleRoot, null]]) };
623
840
  }
624
841
  return {
625
842
  entries: visibleGitlinks.map((entry) => ({
626
843
  filePath: path.posix.join(relativeRoot, entry.filePath),
627
844
  objectId: entry.objectId
628
845
  })),
629
- opaqueRoots: visibleGitlinkRoots.map((filePath) => path.posix.join(relativeRoot, filePath))
846
+ opaqueRoots: visibleGitlinkRoots.map((filePath) => path.posix.join(relativeRoot, filePath)),
847
+ visiblePreimages: /* @__PURE__ */ new Map([[visibleRoot, hydrationPreimage]])
630
848
  };
631
849
  }
632
- function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpaqueRoots = []) {
850
+ function pathDepth(filePath) {
851
+ return filePath.split("/").length;
852
+ }
853
+ function pathsOverlap(left, right) {
854
+ return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
855
+ }
856
+ function removeVisibleBlob(visibleRoot, filePath, trustedRoot) {
857
+ if (!hasSafeDirectoryAncestors(visibleRoot, filePath, trustedRoot)) return false;
858
+ const absolutePath = path.join(visibleRoot, ...filePath.split("/"));
859
+ const stat = lstatOrNull(absolutePath);
860
+ if (!stat || !stat.isFile() && !stat.isSymbolicLink()) return false;
861
+ fs.rmSync(absolutePath, { force: true });
862
+ return true;
863
+ }
864
+ function removeEmptyVisibleAncestors(visibleRoot, filePath, trustedRoot, opaqueRoots = []) {
865
+ if (!hasSafeDirectoryAncestors(visibleRoot, filePath, trustedRoot)) return;
866
+ const resolvedRoot = path.resolve(visibleRoot);
867
+ let current = path.dirname(path.join(resolvedRoot, ...filePath.split("/")));
868
+ while (current !== resolvedRoot) {
869
+ const relative = path.relative(resolvedRoot, current).split(path.sep).join("/");
870
+ if (!relative || pathIsWithinOpaqueRoot(relative, opaqueRoots)) return;
871
+ const stat = lstatOrNull(current);
872
+ if (!stat) {
873
+ current = path.dirname(current);
874
+ continue;
875
+ }
876
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(current).length > 0) return;
877
+ try {
878
+ fs.rmdirSync(current);
879
+ } catch (error) {
880
+ if (error.code === "ENOTEMPTY" || error.code === "ENOENT") return;
881
+ throw error;
882
+ }
883
+ current = path.dirname(current);
884
+ }
885
+ }
886
+ function copyShadowBlobToVisible(shadowRoot, visibleRoot, filePath, trustedRoot) {
887
+ if (!hasSafeDirectoryAncestors(visibleRoot, filePath, trustedRoot)) return false;
888
+ const targetPath = path.join(visibleRoot, ...filePath.split("/"));
889
+ const existing = lstatOrNull(targetPath);
890
+ if (existing?.isDirectory()) {
891
+ if (fs.readdirSync(targetPath).length > 0) return false;
892
+ fs.rmdirSync(targetPath);
893
+ } else if (existing) {
894
+ fs.rmSync(targetPath, { force: true });
895
+ }
896
+ try {
897
+ copyEntry(shadowRoot, visibleRoot, filePath, { target: trustedRoot });
898
+ return true;
899
+ } catch (error) {
900
+ if (error.code === "EEXIST" || error.code === "ENOTDIR") return false;
901
+ throw error;
902
+ }
903
+ }
904
+ function hydrateProjectFromShadowOptimistically(input, relativeRoot, visibleRoot, preimage, opaqueVisibleGitlinks) {
905
+ const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
906
+ const desired = indexHydrationPreimage(input, relativeRoot);
907
+ let expectedCheckoutSnapshot = checkoutGitSnapshot(visibleRoot);
908
+ if (!expectedCheckoutSnapshot) return;
909
+ const checkoutIndexIsStable = () => checkoutGitSnapshot(visibleRoot) === expectedCheckoutSnapshot;
910
+ const refreshCheckoutSnapshot = () => {
911
+ expectedCheckoutSnapshot = checkoutGitSnapshot(visibleRoot);
912
+ return expectedCheckoutSnapshot !== null;
913
+ };
914
+ let currentGitlinks = new Map(checkoutGitlinks(visibleRoot).map((entry) => [entry.filePath, entry]));
915
+ const changedPaths = [.../* @__PURE__ */ new Set([...preimage.keys(), ...desired.keys()])].filter(
916
+ (filePath) => !hydrationEntriesEqual(preimage.get(filePath), desired.get(filePath))
917
+ );
918
+ for (const filePath of changedPaths.filter((candidate) => !desired.has(candidate)).sort((left, right) => pathDepth(right) - pathDepth(left) || left.localeCompare(right))) {
919
+ const current = projectVisibleHydrationEntry(input, visibleRoot, filePath, currentGitlinks);
920
+ if (current === UNSTABLE_VISIBLE_ENTRY || !hydrationEntriesEqual(current, preimage.get(filePath))) continue;
921
+ const expected = preimage.get(filePath);
922
+ if (expected?.kind === "gitlink") {
923
+ if (!checkoutIndexIsStable()) return;
924
+ removeCheckoutIndexPath(visibleRoot, filePath);
925
+ if (!refreshCheckoutSnapshot()) return;
926
+ currentGitlinks.delete(filePath);
927
+ } else if (removeVisibleBlob(visibleRoot, filePath, input.projectsRoot)) {
928
+ removeEmptyVisibleAncestors(visibleRoot, filePath, input.projectsRoot, opaqueVisibleGitlinks);
929
+ }
930
+ }
931
+ for (const filePath of changedPaths.filter((candidate) => desired.has(candidate)).sort((left, right) => pathDepth(left) - pathDepth(right) || left.localeCompare(right))) {
932
+ const wanted = desired.get(filePath);
933
+ if (!wanted) continue;
934
+ const current = projectVisibleHydrationEntry(input, visibleRoot, filePath, currentGitlinks);
935
+ if (current === UNSTABLE_VISIBLE_ENTRY || hydrationEntriesEqual(current, wanted)) continue;
936
+ if (!hydrationEntriesEqual(current, preimage.get(filePath))) continue;
937
+ if (wanted.kind === "gitlink") {
938
+ if (!hasSafeDirectoryAncestors(visibleRoot, filePath, input.projectsRoot) || !checkoutIndexIsStable()) return;
939
+ const targetPath = path.join(visibleRoot, ...filePath.split("/"));
940
+ const existing = lstatOrNull(targetPath);
941
+ if (existing && !existing.isDirectory()) fs.rmSync(targetPath, { force: true });
942
+ fs.mkdirSync(targetPath, { recursive: true });
943
+ replaceCheckoutIndexPathWithGitlink(visibleRoot, { filePath, objectId: wanted.objectId });
944
+ if (!refreshCheckoutSnapshot()) return;
945
+ currentGitlinks.set(filePath, { filePath, objectId: wanted.objectId });
946
+ continue;
947
+ }
948
+ const expected = preimage.get(filePath);
949
+ if (expected?.kind === "gitlink") {
950
+ if (!hasSafeDirectoryAncestors(visibleRoot, filePath, input.projectsRoot) || !checkoutIndexIsStable()) return;
951
+ const targetPath = path.join(visibleRoot, ...filePath.split("/"));
952
+ const existing = lstatOrNull(targetPath);
953
+ if (existing?.isDirectory() && fs.readdirSync(targetPath).length > 0) continue;
954
+ removeCheckoutIndexPath(visibleRoot, filePath);
955
+ if (!refreshCheckoutSnapshot()) return;
956
+ currentGitlinks.delete(filePath);
957
+ }
958
+ copyShadowBlobToVisible(shadowRoot, visibleRoot, filePath, input.projectsRoot);
959
+ }
960
+ }
961
+ function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpaqueRoots = [], hydrationPreimage) {
633
962
  const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
963
+ if (hydrationPreimage === null) return;
964
+ if (!hasSafeDirectoryAncestors(visibleRoot, ".r5d-project-hydration", input.projectsRoot)) {
965
+ if (hydrationPreimage) return;
966
+ throw new Error(`Visible project root has a symlink or non-directory ancestor: ${visibleRoot}`);
967
+ }
634
968
  if (!fs.existsSync(path.join(visibleRoot, ".git"))) return;
635
969
  checkoutGitlinks(visibleRoot);
636
970
  const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
@@ -644,6 +978,7 @@ function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpa
644
978
  ].sort();
645
979
  assertSafeVisibleGitlinkTransitions(
646
980
  visibleRoot,
981
+ input.projectsRoot,
647
982
  protectedGitlinkRoots.map((filePath) => ({ filePath, objectId: "" })),
648
983
  desiredGitlinks,
649
984
  shadowFiles
@@ -651,77 +986,166 @@ function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpa
651
986
  const opaqueVisibleGitlinks = protectedGitlinkRoots.filter(
652
987
  (filePath) => desiredGitlinkPaths.has(filePath) || !shadowFiles.some((shadowFile) => shadowFile === filePath || shadowFile.startsWith(`${filePath}/`))
653
988
  );
654
- mirrorFileSet(shadowRoot, visibleRoot, shadowFiles, listGitEligibleFiles(visibleRoot), opaqueVisibleGitlinks);
655
- mirrorShadowGitlinksToVisible(desiredGitlinks, visibleRoot);
989
+ if (hydrationPreimage) {
990
+ hydrateProjectFromShadowOptimistically(input, relativeRoot, visibleRoot, hydrationPreimage, opaqueVisibleGitlinks);
991
+ return;
992
+ }
993
+ mirrorFileSet(shadowRoot, visibleRoot, shadowFiles, listGitEligibleFiles(visibleRoot), opaqueVisibleGitlinks, [], {
994
+ source: input.shadowRoot,
995
+ target: input.projectsRoot
996
+ });
997
+ mirrorShadowGitlinksToVisible(desiredGitlinks, visibleRoot, input.projectsRoot);
998
+ }
999
+ function visibleRegularRootMatchesCopiedPreimage(input, visibleRoot, files, preimage, filter) {
1000
+ const currentFiles = listFilesRecursively(visibleRoot, filter);
1001
+ if (currentFiles.length !== files.length || currentFiles.some((filePath, index) => filePath !== files[index])) return false;
1002
+ return files.every((filePath) => {
1003
+ const current = hasSafeDirectoryAncestors(visibleRoot, filePath, input.plansRoot) ? filesystemHydrationEntry(input, path.join(visibleRoot, ...filePath.split("/"))) : UNSTABLE_VISIBLE_ENTRY;
1004
+ return current !== UNSTABLE_VISIBLE_ENTRY && hydrationEntriesEqual(current, preimage.get(filePath));
1005
+ });
1006
+ }
1007
+ function mirrorRegularRootToShadowWithPreimage(input, sourceRoot, targetRoot, relativeRoot, filter) {
1008
+ if (!hasSafeDirectoryAncestors(sourceRoot, ".r5d-plan-scan", input.plansRoot)) return null;
1009
+ if (!fs.existsSync(sourceRoot)) return /* @__PURE__ */ new Map();
1010
+ const sourceFiles = listFilesRecursively(sourceRoot, filter);
1011
+ mirrorFileSet(sourceRoot, targetRoot, sourceFiles, listFilesRecursively(targetRoot, filter), [], [], {
1012
+ source: input.plansRoot,
1013
+ target: input.shadowRoot
1014
+ });
1015
+ const preimage = copiedHydrationPreimage(input, targetRoot, sourceFiles);
1016
+ if (!preimage || !visibleRegularRootMatchesCopiedPreimage(input, sourceRoot, sourceFiles, preimage, filter)) {
1017
+ restoreShadowProjectSnapshot(input, relativeRoot);
1018
+ return null;
1019
+ }
1020
+ return preimage;
1021
+ }
1022
+ function hydrateRegularRootOptimistically(input, relativeRoot, targetRoot, preimage, filter) {
1023
+ const sourceRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
1024
+ if (!hasSafeDirectoryAncestors(targetRoot, ".r5d-plan-hydration", input.plansRoot)) return;
1025
+ const desired = indexHydrationPreimage(input, relativeRoot);
1026
+ for (const [filePath, entry] of desired) {
1027
+ if (entry.kind === "gitlink" || filter && !filter(filePath)) desired.delete(filePath);
1028
+ }
1029
+ const changedPaths = [.../* @__PURE__ */ new Set([...preimage.keys(), ...desired.keys()])].filter(
1030
+ (filePath) => !hydrationEntriesEqual(preimage.get(filePath), desired.get(filePath))
1031
+ );
1032
+ for (const filePath of changedPaths.filter((candidate) => !desired.has(candidate)).sort((left, right) => pathDepth(right) - pathDepth(left) || left.localeCompare(right))) {
1033
+ const current = hasSafeDirectoryAncestors(targetRoot, filePath, input.plansRoot) ? filesystemHydrationEntry(input, path.join(targetRoot, ...filePath.split("/"))) : UNSTABLE_VISIBLE_ENTRY;
1034
+ if (current !== UNSTABLE_VISIBLE_ENTRY && hydrationEntriesEqual(current, preimage.get(filePath))) {
1035
+ if (removeVisibleBlob(targetRoot, filePath, input.plansRoot)) {
1036
+ removeEmptyVisibleAncestors(targetRoot, filePath, input.plansRoot);
1037
+ }
1038
+ }
1039
+ }
1040
+ for (const filePath of changedPaths.filter((candidate) => desired.has(candidate)).sort((left, right) => pathDepth(left) - pathDepth(right) || left.localeCompare(right))) {
1041
+ const current = hasSafeDirectoryAncestors(targetRoot, filePath, input.plansRoot) ? filesystemHydrationEntry(input, path.join(targetRoot, ...filePath.split("/"))) : UNSTABLE_VISIBLE_ENTRY;
1042
+ const wanted = desired.get(filePath);
1043
+ if (!wanted || current === UNSTABLE_VISIBLE_ENTRY || hydrationEntriesEqual(current, wanted)) continue;
1044
+ if (!hydrationEntriesEqual(current, preimage.get(filePath))) continue;
1045
+ copyShadowBlobToVisible(sourceRoot, targetRoot, filePath, input.plansRoot);
1046
+ }
656
1047
  }
657
1048
  function mirrorLocalPlansToShadow(input, manifest, branchName) {
658
1049
  const sourceRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
659
- if (!fs.existsSync(sourceRoot)) return;
660
- const targetRoot = path.join(input.shadowRoot, ...workspacePlansRelativePath(manifest.projectId, branchName).split("/"));
1050
+ const relativeRoot = workspacePlansRelativePath(manifest.projectId, branchName);
1051
+ const targetRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
661
1052
  const planFilter = (relativePath) => relativePath.endsWith(".plan.md");
662
- mirrorFileSet(sourceRoot, targetRoot, listFilesRecursively(sourceRoot, planFilter), listFilesRecursively(targetRoot));
1053
+ return mirrorRegularRootToShadowWithPreimage(input, sourceRoot, targetRoot, relativeRoot, planFilter);
663
1054
  }
664
- function mirrorShadowPlansToLocal(input, manifest, branchName) {
1055
+ function mirrorShadowPlansToLocal(input, manifest, branchName, hydrationPreimage) {
1056
+ if (hydrationPreimage === null) return;
665
1057
  const relativeRoot = workspacePlansRelativePath(manifest.projectId, branchName);
666
1058
  const sourceRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
667
1059
  const targetRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
668
1060
  const planFilter = (relativePath) => relativePath.endsWith(".plan.md");
1061
+ if (hydrationPreimage) {
1062
+ hydrateRegularRootOptimistically(input, relativeRoot, targetRoot, hydrationPreimage, planFilter);
1063
+ return;
1064
+ }
1065
+ if (!hasSafeDirectoryAncestors(targetRoot, ".r5d-plan-hydration", input.plansRoot)) {
1066
+ throw new Error(`Visible plan root has a symlink or non-directory ancestor: ${targetRoot}`);
1067
+ }
669
1068
  mirrorFileSet(
670
1069
  sourceRoot,
671
1070
  targetRoot,
672
1071
  listShadowTrackedFiles(input, relativeRoot).filter(planFilter),
673
- listFilesRecursively(targetRoot, planFilter)
1072
+ listFilesRecursively(targetRoot, planFilter),
1073
+ [],
1074
+ [],
1075
+ { source: input.shadowRoot, target: input.plansRoot }
674
1076
  );
675
1077
  }
676
1078
  function mirrorLocalWorkspacePlansToShadow(input) {
677
- if (input.trigger.canonicalCheckoutOnly) return;
1079
+ if (input.trigger.canonicalCheckoutOnly) return /* @__PURE__ */ new Map();
678
1080
  const sourceRoot = localWorkspacePlansPath(input.plansRoot);
679
- if (!fs.existsSync(sourceRoot)) return;
680
- const targetRoot = path.join(input.shadowRoot, WORKSPACE_NATIVE_PLANS_RELATIVE_PATH);
1081
+ const relativeRoot = WORKSPACE_NATIVE_PLANS_RELATIVE_PATH;
1082
+ const targetRoot = path.join(input.shadowRoot, relativeRoot);
681
1083
  const planFilter = (relativePath) => relativePath.endsWith(".plan.md");
682
- mirrorFileSet(sourceRoot, targetRoot, listFilesRecursively(sourceRoot, planFilter), listFilesRecursively(targetRoot, planFilter));
1084
+ return mirrorRegularRootToShadowWithPreimage(input, sourceRoot, targetRoot, relativeRoot, planFilter);
683
1085
  }
684
- function mirrorShadowWorkspacePlansToLocal(input) {
1086
+ function mirrorShadowWorkspacePlansToLocal(input, hydrationPreimage) {
685
1087
  if (input.trigger.canonicalCheckoutOnly) return;
1088
+ if (hydrationPreimage === null) return;
686
1089
  const sourceRoot = path.join(input.shadowRoot, WORKSPACE_NATIVE_PLANS_RELATIVE_PATH);
687
1090
  const targetRoot = localWorkspacePlansPath(input.plansRoot);
688
1091
  const planFilter = (relativePath) => relativePath.endsWith(".plan.md");
1092
+ if (hydrationPreimage) {
1093
+ hydrateRegularRootOptimistically(input, WORKSPACE_NATIVE_PLANS_RELATIVE_PATH, targetRoot, hydrationPreimage, planFilter);
1094
+ return;
1095
+ }
1096
+ if (!hasSafeDirectoryAncestors(targetRoot, ".r5d-plan-hydration", input.plansRoot)) {
1097
+ throw new Error(`Visible workspace-plan root has a symlink or non-directory ancestor: ${targetRoot}`);
1098
+ }
689
1099
  mirrorFileSet(
690
1100
  sourceRoot,
691
1101
  targetRoot,
692
1102
  listShadowTrackedFiles(input, WORKSPACE_NATIVE_PLANS_RELATIVE_PATH).filter(planFilter),
693
- listFilesRecursively(targetRoot, planFilter)
1103
+ listFilesRecursively(targetRoot, planFilter),
1104
+ [],
1105
+ [],
1106
+ { source: input.shadowRoot, target: input.plansRoot }
694
1107
  );
695
1108
  }
696
- function reconcileWorkspaceNativePlans(input) {
1109
+ function reconcileWorkspaceNativePlans(input, projection) {
697
1110
  if (input.trigger.canonicalCheckoutOnly) return;
1111
+ const localRoot = localWorkspacePlansPath(input.plansRoot);
1112
+ projection.visiblePreimages ??= /* @__PURE__ */ new Map();
1113
+ projection.visiblePreimages.set(localRoot, /* @__PURE__ */ new Map());
698
1114
  if (fs.existsSync(localWorkspacePlansPath(input.plansRoot))) {
699
- mirrorLocalWorkspacePlansToShadow(input);
1115
+ projection.visiblePreimages.set(localRoot, mirrorLocalWorkspacePlansToShadow(input));
700
1116
  return;
701
1117
  }
702
1118
  if (listShadowTrackedFiles(input, WORKSPACE_NATIVE_PLANS_RELATIVE_PATH).length > 0 || restoreShadowRootFromRemote(input, WORKSPACE_NATIVE_PLANS_RELATIVE_PATH)) {
703
- mirrorShadowWorkspacePlansToLocal(input);
1119
+ mirrorShadowWorkspacePlansToLocal(input, /* @__PURE__ */ new Map());
1120
+ projection.visiblePreimages.set(localRoot, indexHydrationPreimage(input, WORKSPACE_NATIVE_PLANS_RELATIVE_PATH));
704
1121
  }
705
1122
  }
706
1123
  function mergeGitlinkProjection(target, source) {
707
1124
  target.entries.push(...source.entries);
708
1125
  target.opaqueRoots.push(...source.opaqueRoots);
1126
+ if (source.visiblePreimages) {
1127
+ target.visiblePreimages ??= /* @__PURE__ */ new Map();
1128
+ for (const [visibleRoot, preimage] of source.visiblePreimages) target.visiblePreimages.set(visibleRoot, preimage);
1129
+ }
709
1130
  }
710
1131
  function mirrorVisibleWorkspaceToShadow(input, excludedCheckouts = /* @__PURE__ */ new Set()) {
711
- const projection = { entries: [], opaqueRoots: [] };
712
- reconcileWorkspaceNativePlans(input);
1132
+ const projection = { entries: [], opaqueRoots: [], visiblePreimages: /* @__PURE__ */ new Map() };
1133
+ reconcileWorkspaceNativePlans(input, projection);
713
1134
  for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
714
1135
  for (const branchName of [...new Set(manifest.branches)].sort()) {
715
1136
  if (excludedCheckouts.has(`${manifest.projectId}\0${branchName}`)) continue;
716
1137
  mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, branchName));
717
- if (!input.trigger.canonicalCheckoutOnly) mirrorLocalPlansToShadow(input, manifest, branchName);
1138
+ if (!input.trigger.canonicalCheckoutOnly) {
1139
+ const localPlansRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
1140
+ projection.visiblePreimages?.set(localPlansRoot, mirrorLocalPlansToShadow(input, manifest, branchName));
1141
+ }
718
1142
  }
719
1143
  }
720
1144
  projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
721
1145
  return projection;
722
1146
  }
723
1147
  function reconcileNewVisibleCheckouts(input) {
724
- const projection = { entries: [], opaqueRoots: [] };
1148
+ const projection = { entries: [], opaqueRoots: [], visiblePreimages: /* @__PURE__ */ new Map() };
725
1149
  for (const target of input.newVisibleCheckouts ?? []) {
726
1150
  const manifest = input.projects.find((project) => project.projectId === target.projectId);
727
1151
  if (!manifest || !manifest.branches.includes(target.branchName)) continue;
@@ -730,6 +1154,10 @@ function reconcileNewVisibleCheckouts(input) {
730
1154
  mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
731
1155
  } else if (listShadowTrackedFiles(input, projectRoot).length > 0 || restoreShadowRootFromRemote(input, projectRoot)) {
732
1156
  mirrorShadowProjectToVisible(input, manifest, target.branchName);
1157
+ projection.visiblePreimages?.set(
1158
+ visibleProjectBranchPath(input.projectsRoot, manifest, target.branchName),
1159
+ indexHydrationPreimage(input, projectRoot)
1160
+ );
733
1161
  } else {
734
1162
  mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
735
1163
  }
@@ -737,23 +1165,194 @@ function reconcileNewVisibleCheckouts(input) {
737
1165
  const plansRoot = workspacePlansRelativePath(target.projectId, target.branchName);
738
1166
  if (listShadowTrackedFiles(input, plansRoot).length > 0 || restoreShadowRootFromRemote(input, plansRoot)) {
739
1167
  mirrorShadowPlansToLocal(input, manifest, target.branchName);
1168
+ projection.visiblePreimages?.set(
1169
+ localPlansBranchPath(input.plansRoot, manifest.projectId, target.branchName),
1170
+ indexHydrationPreimage(input, plansRoot)
1171
+ );
740
1172
  } else {
741
- mirrorLocalPlansToShadow(input, manifest, target.branchName);
1173
+ projection.visiblePreimages?.set(
1174
+ localPlansBranchPath(input.plansRoot, manifest.projectId, target.branchName),
1175
+ mirrorLocalPlansToShadow(input, manifest, target.branchName)
1176
+ );
742
1177
  }
743
1178
  }
744
1179
  projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
745
1180
  return projection;
746
1181
  }
747
- function mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots = []) {
748
- mirrorShadowWorkspacePlansToLocal(input);
1182
+ function mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots = [], hydrationPreimages) {
1183
+ mirrorShadowWorkspacePlansToLocal(input, hydrationPreimages?.get(localWorkspacePlansPath(input.plansRoot)));
749
1184
  for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
750
1185
  for (const branchName of [...new Set(manifest.branches)].sort()) {
751
1186
  const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
1187
+ const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
752
1188
  const opaqueProjectRoots = opaqueWorkspaceRoots.filter((root) => root.startsWith(`${relativeRoot}/`)).map((root) => root.slice(relativeRoot.length + 1));
753
- mirrorShadowProjectToVisible(input, manifest, branchName, opaqueProjectRoots);
754
- if (!input.trigger.canonicalCheckoutOnly) mirrorShadowPlansToLocal(input, manifest, branchName);
1189
+ mirrorShadowProjectToVisible(input, manifest, branchName, opaqueProjectRoots, hydrationPreimages?.get(visibleRoot));
1190
+ if (!input.trigger.canonicalCheckoutOnly) {
1191
+ const localPlansRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
1192
+ mirrorShadowPlansToLocal(input, manifest, branchName, hydrationPreimages?.get(localPlansRoot));
1193
+ }
1194
+ }
1195
+ }
1196
+ }
1197
+ function hydrationPreimagesFromShadowRevision(input, revision) {
1198
+ const preimages = /* @__PURE__ */ new Map();
1199
+ const fromRevision = (relativeRoot) => revision ? revisionHydrationPreimage(input, revision, relativeRoot) : /* @__PURE__ */ new Map();
1200
+ if (!input.trigger.canonicalCheckoutOnly) {
1201
+ preimages.set(localWorkspacePlansPath(input.plansRoot), fromRevision(WORKSPACE_NATIVE_PLANS_RELATIVE_PATH));
1202
+ }
1203
+ for (const manifest of input.projects) {
1204
+ for (const branchName of manifest.branches) {
1205
+ preimages.set(
1206
+ visibleProjectBranchPath(input.projectsRoot, manifest, branchName),
1207
+ fromRevision(workspaceProjectBranchRelativePath(manifest.projectId, branchName))
1208
+ );
1209
+ if (!input.trigger.canonicalCheckoutOnly) {
1210
+ preimages.set(
1211
+ localPlansBranchPath(input.plansRoot, manifest.projectId, branchName),
1212
+ fromRevision(workspacePlansRelativePath(manifest.projectId, branchName))
1213
+ );
1214
+ }
1215
+ }
1216
+ }
1217
+ return preimages;
1218
+ }
1219
+ function visibleHydrationConflictPaths(input, preimages, desiredRevision) {
1220
+ const conflicts = [];
1221
+ const inspectRegularRoot = (visibleRoot, relativeRoot, filter) => {
1222
+ const preimage = preimages.get(visibleRoot);
1223
+ if (!preimage) return;
1224
+ const desired = revisionHydrationPreimage(input, desiredRevision, relativeRoot);
1225
+ for (const [filePath, entry] of desired) {
1226
+ if (entry.kind === "gitlink" || filter && !filter(filePath)) desired.delete(filePath);
1227
+ }
1228
+ const changedPaths = [.../* @__PURE__ */ new Set([...preimage.keys(), ...desired.keys()])].filter(
1229
+ (filePath) => !hydrationEntriesEqual(preimage.get(filePath), desired.get(filePath))
1230
+ );
1231
+ if (changedPaths.length === 0) return;
1232
+ if (!hasSafeDirectoryAncestors(visibleRoot, ".r5d-hydration-scan", input.plansRoot)) {
1233
+ conflicts.push(...changedPaths.map((filePath) => path.posix.join(relativeRoot, filePath)));
1234
+ return;
1235
+ }
1236
+ const affectsChangedTree = (filePath) => changedPaths.some((changedPath) => pathsOverlap(filePath, changedPath));
1237
+ const currentEntries = /* @__PURE__ */ new Map();
1238
+ const unstablePaths = /* @__PURE__ */ new Set();
1239
+ for (const filePath of listFilesRecursively(visibleRoot).filter(affectsChangedTree)) {
1240
+ const current = hasSafeDirectoryAncestors(visibleRoot, filePath, input.plansRoot) ? filesystemHydrationEntry(input, path.join(visibleRoot, ...filePath.split("/"))) : UNSTABLE_VISIBLE_ENTRY;
1241
+ if (current === UNSTABLE_VISIBLE_ENTRY || !current) unstablePaths.add(filePath);
1242
+ else currentEntries.set(filePath, current);
755
1243
  }
1244
+ for (const filePath of /* @__PURE__ */ new Set([...changedPaths, ...currentEntries.keys(), ...unstablePaths])) {
1245
+ if (!affectsChangedTree(filePath)) continue;
1246
+ const current = unstablePaths.has(filePath) ? UNSTABLE_VISIBLE_ENTRY : currentEntries.get(filePath);
1247
+ if (current === UNSTABLE_VISIBLE_ENTRY || !hydrationEntriesEqual(current, preimage.get(filePath)) && !hydrationEntriesEqual(current, desired.get(filePath))) {
1248
+ conflicts.push(path.posix.join(relativeRoot, filePath));
1249
+ }
1250
+ }
1251
+ };
1252
+ if (!input.trigger.canonicalCheckoutOnly) {
1253
+ inspectRegularRoot(
1254
+ localWorkspacePlansPath(input.plansRoot),
1255
+ WORKSPACE_NATIVE_PLANS_RELATIVE_PATH,
1256
+ (filePath) => filePath.endsWith(".plan.md")
1257
+ );
756
1258
  }
1259
+ for (const manifest of input.projects) {
1260
+ for (const branchName of manifest.branches) {
1261
+ const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
1262
+ const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
1263
+ const preimage = preimages.get(visibleRoot);
1264
+ if (preimage && fs.existsSync(path.join(visibleRoot, ".git"))) {
1265
+ const desired = revisionHydrationPreimage(input, desiredRevision, relativeRoot);
1266
+ const changedPaths = [.../* @__PURE__ */ new Set([...preimage.keys(), ...desired.keys()])].filter(
1267
+ (filePath) => !hydrationEntriesEqual(preimage.get(filePath), desired.get(filePath))
1268
+ );
1269
+ if (!hasSafeDirectoryAncestors(visibleRoot, ".r5d-hydration-scan", input.projectsRoot)) {
1270
+ conflicts.push(...changedPaths.map((filePath) => path.posix.join(relativeRoot, filePath)));
1271
+ continue;
1272
+ }
1273
+ const affectsChangedTree = (filePath) => changedPaths.some((changedPath) => pathsOverlap(filePath, changedPath));
1274
+ const currentGitlinks = new Map(checkoutGitlinks(visibleRoot).map((entry) => [entry.filePath, entry]));
1275
+ const residualOpaqueGitlinks = [...preimage].filter(
1276
+ ([filePath, entry]) => entry.kind === "gitlink" && !desired.has(filePath) && ![...desired.keys()].some((desiredPath) => desiredPath.startsWith(`${filePath}/`))
1277
+ ).map(([filePath]) => filePath);
1278
+ const opaqueRoots = [.../* @__PURE__ */ new Set([...currentGitlinks.keys(), ...residualOpaqueGitlinks])];
1279
+ const currentEntries = /* @__PURE__ */ new Map();
1280
+ const unstablePaths = /* @__PURE__ */ new Set();
1281
+ for (const filePath of [...currentGitlinks.keys()].filter(affectsChangedTree)) {
1282
+ const current = projectVisibleHydrationEntry(input, visibleRoot, filePath, currentGitlinks);
1283
+ if (current === UNSTABLE_VISIBLE_ENTRY || !current) unstablePaths.add(filePath);
1284
+ else currentEntries.set(filePath, current);
1285
+ }
1286
+ for (const filePath of listGitEligibleFiles(visibleRoot).filter(
1287
+ (candidate) => affectsChangedTree(candidate) && !pathIsWithinOpaqueRoot(candidate, opaqueRoots)
1288
+ )) {
1289
+ const current = projectVisibleHydrationEntry(input, visibleRoot, filePath, currentGitlinks);
1290
+ if (current === UNSTABLE_VISIBLE_ENTRY || !current) unstablePaths.add(filePath);
1291
+ else currentEntries.set(filePath, current);
1292
+ }
1293
+ for (const filePath of /* @__PURE__ */ new Set([...changedPaths, ...currentEntries.keys(), ...unstablePaths])) {
1294
+ if (!affectsChangedTree(filePath)) continue;
1295
+ const current = unstablePaths.has(filePath) ? UNSTABLE_VISIBLE_ENTRY : currentEntries.get(filePath);
1296
+ if (current === UNSTABLE_VISIBLE_ENTRY || !hydrationEntriesEqual(current, preimage.get(filePath)) && !hydrationEntriesEqual(current, desired.get(filePath))) {
1297
+ conflicts.push(path.posix.join(relativeRoot, filePath));
1298
+ }
1299
+ }
1300
+ }
1301
+ if (!input.trigger.canonicalCheckoutOnly) {
1302
+ inspectRegularRoot(
1303
+ localPlansBranchPath(input.plansRoot, manifest.projectId, branchName),
1304
+ workspacePlansRelativePath(manifest.projectId, branchName),
1305
+ (filePath) => filePath.endsWith(".plan.md")
1306
+ );
1307
+ }
1308
+ }
1309
+ }
1310
+ return [...new Set(conflicts)].sort();
1311
+ }
1312
+ function unavailableHydrationConflictPaths(input, preimages, baselineRevision, desiredRevision) {
1313
+ const conflicts = [];
1314
+ const inspectRoot = (visibleRoot, relativeRoot, filter) => {
1315
+ if (preimages.get(visibleRoot) !== null) return;
1316
+ const baseline = revisionHydrationPreimage(input, baselineRevision, relativeRoot);
1317
+ const desired = revisionHydrationPreimage(input, desiredRevision, relativeRoot);
1318
+ for (const filePath of /* @__PURE__ */ new Set([...baseline.keys(), ...desired.keys()])) {
1319
+ if (filter && !filter(filePath)) continue;
1320
+ if (!hydrationEntriesEqual(baseline.get(filePath), desired.get(filePath))) {
1321
+ conflicts.push(path.posix.join(relativeRoot, filePath));
1322
+ }
1323
+ }
1324
+ };
1325
+ if (!input.trigger.canonicalCheckoutOnly) {
1326
+ inspectRoot(
1327
+ localWorkspacePlansPath(input.plansRoot),
1328
+ WORKSPACE_NATIVE_PLANS_RELATIVE_PATH,
1329
+ (filePath) => filePath.endsWith(".plan.md")
1330
+ );
1331
+ }
1332
+ for (const manifest of input.projects) {
1333
+ for (const branchName of manifest.branches) {
1334
+ inspectRoot(
1335
+ visibleProjectBranchPath(input.projectsRoot, manifest, branchName),
1336
+ workspaceProjectBranchRelativePath(manifest.projectId, branchName)
1337
+ );
1338
+ if (!input.trigger.canonicalCheckoutOnly) {
1339
+ inspectRoot(
1340
+ localPlansBranchPath(input.plansRoot, manifest.projectId, branchName),
1341
+ workspacePlansRelativePath(manifest.projectId, branchName),
1342
+ (filePath) => filePath.endsWith(".plan.md")
1343
+ );
1344
+ }
1345
+ }
1346
+ }
1347
+ return [...new Set(conflicts)].sort();
1348
+ }
1349
+ function hydrationConflictPaths(input, preimages, baselineRevision, desiredRevision) {
1350
+ return [
1351
+ .../* @__PURE__ */ new Set([
1352
+ ...visibleHydrationConflictPaths(input, preimages, desiredRevision),
1353
+ ...unavailableHydrationConflictPaths(input, preimages, baselineRevision, desiredRevision)
1354
+ ])
1355
+ ].sort();
757
1356
  }
758
1357
  function forceStageCanonicalCheckoutFiles(input, preservedGitlinks = [], opaqueRoots = []) {
759
1358
  for (const manifest of input.projects) {
@@ -834,7 +1433,9 @@ function assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, revision) {
834
1433
  }
835
1434
  function ensureShadowWorkspace(input) {
836
1435
  fs.mkdirSync(path.dirname(input.shadowRoot), { recursive: true });
1436
+ let created = false;
837
1437
  if (!fs.existsSync(path.join(input.shadowRoot, ".git"))) {
1438
+ created = true;
838
1439
  fs.rmSync(input.shadowRoot, { recursive: true, force: true });
839
1440
  const clone = runGitResult(input, path.dirname(input.shadowRoot), ["clone", "--origin", "origin", input.remoteUrl, input.shadowRoot]);
840
1441
  if (clone.exitCode !== 0) {
@@ -856,6 +1457,7 @@ function ensureShadowWorkspace(input) {
856
1457
  } else if (!hasHead) {
857
1458
  runGit(input, input.shadowRoot, ["checkout", "--orphan", WORKSPACE_BRANCH], "create canonical workspace branch");
858
1459
  }
1460
+ return created;
859
1461
  }
860
1462
  function revParse(input, revision) {
861
1463
  const result = runGitResult(input, input.shadowRoot, ["rev-parse", "--verify", revision]);
@@ -932,14 +1534,15 @@ function resetUncommittedShadowSnapshot(input) {
932
1534
  runGit(input, input.shadowRoot, ["clean", "-fd"], "clean interrupted workspace snapshot");
933
1535
  }
934
1536
  function fastForwardCleanShadowForNewCheckouts(input) {
935
- if (!input.newVisibleCheckouts?.length) return;
1537
+ if (!input.newVisibleCheckouts?.length) return null;
936
1538
  runGit(input, input.shadowRoot, ["add", "-A"], "stage existing workspace changes before checkout hydration");
937
- if (gitStatus(input)) return;
1539
+ if (gitStatus(input)) return null;
938
1540
  const localHead = revParse(input, "HEAD");
939
1541
  const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
940
- if (!localHead || !remoteHead || localHead === remoteHead) return;
941
- if (!tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", localHead, remoteHead])) return;
1542
+ if (!localHead || !remoteHead || localHead === remoteHead) return null;
1543
+ if (!tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", localHead, remoteHead])) return null;
942
1544
  runGit(input, input.shadowRoot, ["reset", "--hard", remoteHead], "fast-forward before checkout hydration");
1545
+ return localHead;
943
1546
  }
944
1547
  function restoreShadowRootFromRemote(input, relativeRoot) {
945
1548
  const remoteRevision = `origin/${WORKSPACE_BRANCH}`;
@@ -1136,12 +1739,12 @@ function convergeRedundantInboundCandidate(input, result) {
1136
1739
  );
1137
1740
  if (stagedTree !== remoteTree) return null;
1138
1741
  const localHead = revParse(input, "HEAD");
1742
+ if (localHead === remoteHead) return null;
1139
1743
  runGit(input, input.shadowRoot, ["reset", "--hard", remoteHead], "converge redundant inbound workspace candidate");
1140
1744
  runGit(input, input.shadowRoot, ["clean", "-fd"], "clean redundant inbound workspace candidate");
1141
- mirrorShadowWorkspaceToVisible(input);
1142
1745
  return {
1143
1746
  ...result,
1144
- outcome: localHead === remoteHead ? "no_change" : "updated",
1747
+ outcome: "updated",
1145
1748
  publishedHead: remoteHead,
1146
1749
  diffSizeBytes: 0,
1147
1750
  gitStatus: gitStatus(input),
@@ -1153,10 +1756,13 @@ async function synchronizeWorkspace(rawInput) {
1153
1756
  let input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
1154
1757
  try {
1155
1758
  input = { ...input, projects: workspaceProjectsForSync(input.projects, input.trigger) };
1156
- let mirroredGitlinkProjection = { entries: [], opaqueRoots: [] };
1157
- ensureShadowWorkspace(input);
1759
+ let mirroredGitlinkProjection = { entries: [], opaqueRoots: [], visiblePreimages: /* @__PURE__ */ new Map() };
1760
+ let fastForwardedFromHead = null;
1761
+ const shadowWasCreated = ensureShadowWorkspace(input);
1762
+ let hydrationPreimages = input.skipVisibleMirror ? hydrationPreimagesFromShadowRevision(input, shadowWasCreated ? null : revParse(input, "HEAD")) : /* @__PURE__ */ new Map();
1158
1763
  const remoteHeadAtStart = revParse(input, `origin/${WORKSPACE_BRANCH}`);
1159
1764
  const result = baseResult(input, remoteHeadAtStart);
1765
+ const inboundConflictsAtStart = input.skipVisibleMirror && remoteHeadAtStart ? visibleHydrationConflictPaths(input, hydrationPreimages, `origin/${WORKSPACE_BRANCH}`) : [];
1160
1766
  if (input.resetToCanonical) {
1161
1767
  if (input.trigger.canonicalCheckoutOnly) {
1162
1768
  throw new Error("A canonical-checkout-only synchronization cannot reset the authoritative resolver checkout");
@@ -1175,9 +1781,10 @@ async function synchronizeWorkspace(rawInput) {
1175
1781
  if (!input.skipVisibleMirror) {
1176
1782
  const newCheckoutKeys = new Set((input.newVisibleCheckouts ?? []).map((target) => `${target.projectId}\0${target.branchName}`));
1177
1783
  mirroredGitlinkProjection = mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
1178
- fastForwardCleanShadowForNewCheckouts(input);
1784
+ fastForwardedFromHead = fastForwardCleanShadowForNewCheckouts(input);
1179
1785
  mergeGitlinkProjection(mirroredGitlinkProjection, reconcileNewVisibleCheckouts(input));
1180
1786
  mirroredGitlinkProjection.opaqueRoots = [...new Set(mirroredGitlinkProjection.opaqueRoots)].sort();
1787
+ hydrationPreimages = mirroredGitlinkProjection.visiblePreimages ?? hydrationPreimages;
1181
1788
  }
1182
1789
  const stagePathspecs = mirroredGitlinkProjection.opaqueRoots.flatMap((root) => [`:(exclude,literal)${root}`]);
1183
1790
  runGit(input, input.shadowRoot, ["add", "-A", "--", ".", ...stagePathspecs], "stage workspace changes");
@@ -1218,9 +1825,22 @@ async function synchronizeWorkspace(rawInput) {
1218
1825
  runGit(input, input.shadowRoot, ["commit", "-m", commitMessage(input)], "commit workspace changes");
1219
1826
  }
1220
1827
  let candidateHead = revParse(input, "HEAD");
1828
+ const candidateHeadBeforeRebase = candidateHead;
1221
1829
  if (candidateHead) assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, candidateHead);
1222
1830
  const hasRemoteMain = Boolean(revParse(input, `origin/${WORKSPACE_BRANCH}`));
1223
1831
  const hasUnpushedCommit = candidateHead ? !hasRemoteMain || !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", candidateHead, `origin/${WORKSPACE_BRANCH}`]) : false;
1832
+ if (hasUnpushedCommit && inboundConflictsAtStart.length > 0) {
1833
+ const conflictPaths = [.../* @__PURE__ */ new Set([...paths, ...inboundConflictsAtStart])].sort();
1834
+ return {
1835
+ ...observed,
1836
+ outcome: "failed",
1837
+ candidateHead: candidateHead ?? void 0,
1838
+ affectedProjects: affectedProjects(conflictPaths),
1839
+ affectedPaths: reportedPaths(conflictPaths),
1840
+ error: "Inbound workspace update overlaps local visible changes; the old-base candidate was preserved for reconciliation.",
1841
+ gitStatus: gitStatus(input)
1842
+ };
1843
+ }
1224
1844
  if (!hasUnpushedCommit) {
1225
1845
  const localHead = revParse(input, "HEAD");
1226
1846
  const currentRemoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
@@ -1228,7 +1848,23 @@ async function synchronizeWorkspace(rawInput) {
1228
1848
  runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "update workspace from canonical state");
1229
1849
  runGit(input, input.shadowRoot, ["clean", "-fd"], "clean updated workspace");
1230
1850
  assertPublishedCanonicalCheckoutTree(input, currentRemoteHead, sampledCanonicalCheckoutTreeHash);
1231
- mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
1851
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots, hydrationPreimages);
1852
+ const inboundConflicts = input.trigger.canonicalCheckoutOnly ? [] : localHead ? hydrationConflictPaths(input, hydrationPreimages, localHead, currentRemoteHead) : visibleHydrationConflictPaths(input, hydrationPreimages, currentRemoteHead);
1853
+ if (inboundConflicts.length > 0 && localHead) {
1854
+ runGit(input, input.shadowRoot, ["reset", "--hard", localHead], "preserve old workspace base after inbound conflict");
1855
+ runGit(input, input.shadowRoot, ["clean", "-fd"], "clean preserved old workspace base");
1856
+ const conflictPaths = [.../* @__PURE__ */ new Set([...paths, ...inboundConflicts])].sort();
1857
+ return {
1858
+ ...observed,
1859
+ outcome: "failed",
1860
+ candidateHead: candidateHead ?? void 0,
1861
+ publishedHead: currentRemoteHead,
1862
+ affectedProjects: affectedProjects(conflictPaths),
1863
+ affectedPaths: reportedPaths(conflictPaths),
1864
+ error: "Inbound workspace update overlaps local visible changes; disjoint updates were hydrated and the old base was preserved for reconciliation.",
1865
+ gitStatus: gitStatus(input)
1866
+ };
1867
+ }
1232
1868
  return {
1233
1869
  ...observed,
1234
1870
  outcome: "updated",
@@ -1240,9 +1876,27 @@ async function synchronizeWorkspace(rawInput) {
1240
1876
  const authoritativeHead = currentRemoteHead ?? localHead;
1241
1877
  if (authoritativeHead) {
1242
1878
  assertPublishedCanonicalCheckoutTree(input, authoritativeHead, sampledCanonicalCheckoutTreeHash);
1243
- }
1244
- if (input.skipVisibleMirror) {
1245
- mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
1879
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots, hydrationPreimages);
1880
+ const hydrationConflicts2 = input.trigger.canonicalCheckoutOnly ? [] : fastForwardedFromHead ? hydrationConflictPaths(input, hydrationPreimages, fastForwardedFromHead, authoritativeHead) : visibleHydrationConflictPaths(input, hydrationPreimages, authoritativeHead);
1881
+ if (hydrationConflicts2.length > 0 && fastForwardedFromHead) {
1882
+ runGit(
1883
+ input,
1884
+ input.shadowRoot,
1885
+ ["reset", "--hard", fastForwardedFromHead],
1886
+ "preserve old workspace base after new-checkout fast-forward conflict"
1887
+ );
1888
+ runGit(input, input.shadowRoot, ["clean", "-fd"], "clean preserved new-checkout workspace base");
1889
+ return {
1890
+ ...observed,
1891
+ outcome: "failed",
1892
+ candidateHead: candidateHead ?? void 0,
1893
+ publishedHead: currentRemoteHead ?? void 0,
1894
+ affectedProjects: affectedProjects(hydrationConflicts2),
1895
+ affectedPaths: reportedPaths(hydrationConflicts2),
1896
+ error: "Workspace fast-forward for a new checkout overlaps local visible changes; compatible updates were hydrated and the old base was preserved for reconciliation.",
1897
+ gitStatus: gitStatus(input)
1898
+ };
1899
+ }
1246
1900
  }
1247
1901
  return {
1248
1902
  ...observed,
@@ -1259,7 +1913,7 @@ async function synchronizeWorkspace(rawInput) {
1259
1913
  rebaseCount += 1;
1260
1914
  if (rebase.exitCode !== 0) {
1261
1915
  const conflicted = runGitResult(input, input.shadowRoot, ["diff", "--name-only", "--diff-filter=U", "-z"]);
1262
- const conflictPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
1916
+ const conflictPaths = [.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort();
1263
1917
  runGit(input, input.shadowRoot, ["rebase", "--abort"], "preserve workspace candidate after rebase conflict");
1264
1918
  return {
1265
1919
  ...observed,
@@ -1267,7 +1921,8 @@ async function synchronizeWorkspace(rawInput) {
1267
1921
  expectedHead,
1268
1922
  candidateHead: candidateHead ?? void 0,
1269
1923
  rebaseCount,
1270
- affectedPaths: conflictPaths,
1924
+ affectedProjects: affectedProjects(conflictPaths),
1925
+ affectedPaths: reportedPaths(conflictPaths),
1271
1926
  error: `Workspace candidate conflicts with canonical head ${expectedHead}; local changes were preserved for reconciliation.`,
1272
1927
  gitStatus: gitStatus(input)
1273
1928
  };
@@ -1285,6 +1940,30 @@ async function synchronizeWorkspace(rawInput) {
1285
1940
  gitStatus: gitStatus(input)
1286
1941
  };
1287
1942
  }
1943
+ assertPublishedCanonicalCheckoutTree(input, candidateHead, sampledCanonicalCheckoutTreeHash);
1944
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots, hydrationPreimages);
1945
+ const hydrationConflicts = input.trigger.canonicalCheckoutOnly ? [] : candidateHeadBeforeRebase ? hydrationConflictPaths(input, hydrationPreimages, candidateHeadBeforeRebase, "HEAD") : visibleHydrationConflictPaths(input, hydrationPreimages, "HEAD");
1946
+ if (hydrationConflicts.length > 0 && candidateHeadBeforeRebase) {
1947
+ runGit(
1948
+ input,
1949
+ input.shadowRoot,
1950
+ ["reset", "--hard", candidateHeadBeforeRebase],
1951
+ "preserve old-base workspace candidate after hydration conflict"
1952
+ );
1953
+ runGit(input, input.shadowRoot, ["clean", "-fd"], "clean old-base workspace candidate");
1954
+ const conflictPaths = [.../* @__PURE__ */ new Set([...paths, ...hydrationConflicts])].sort();
1955
+ return {
1956
+ ...observed,
1957
+ outcome: "failed",
1958
+ expectedHead,
1959
+ candidateHead,
1960
+ rebaseCount,
1961
+ affectedProjects: affectedProjects(conflictPaths),
1962
+ affectedPaths: reportedPaths(conflictPaths),
1963
+ error: "Workspace changed while its candidate was reconciling; visible changes and the old-base candidate were preserved.",
1964
+ gitStatus: gitStatus(input)
1965
+ };
1966
+ }
1288
1967
  const upload = uploadWorkspaceCandidate(input, candidateHead);
1289
1968
  if (upload.exitCode !== 0) {
1290
1969
  return {
@@ -1297,8 +1976,6 @@ async function synchronizeWorkspace(rawInput) {
1297
1976
  gitStatus: gitStatus(input)
1298
1977
  };
1299
1978
  }
1300
- assertPublishedCanonicalCheckoutTree(input, candidateHead, sampledCanonicalCheckoutTreeHash);
1301
- mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
1302
1979
  return {
1303
1980
  ...observed,
1304
1981
  outcome: "candidate_ready",