@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.
- package/README.md +2 -2
- package/dist/cjs/main.cjs +21 -11
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/workspace-command-sync-policy.cjs +40 -0
- package/dist/cjs/workspace-sync.cjs +741 -64
- package/dist/mjs/main.mjs +21 -11
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/workspace-command-sync-policy.mjs +14 -0
- package/dist/mjs/workspace-sync.mjs +741 -64
- package/dist/types/workspace-command-sync-policy.d.ts +19 -0
- package/dist/types/workspace-mutation-gate.d.ts +3 -1
- package/dist/types/workspace-sync.d.ts +12 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
655
|
-
|
|
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
|
-
|
|
660
|
-
const targetRoot = path.join(input.shadowRoot, ...
|
|
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
|
-
|
|
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
|
-
|
|
680
|
-
const targetRoot = path.join(input.shadowRoot,
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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)
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
1245
|
-
|
|
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 =
|
|
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
|
-
|
|
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",
|