@ricsam/r5d-worker 0.0.46 → 0.0.47

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.
@@ -14,10 +14,14 @@ const DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO = 0.8;
14
14
  const ACTIVE_GIT_LOCK_PATHS = ["index.lock", "HEAD.lock", "packed-refs.lock", "shallow.lock"];
15
15
  const mirrorComparisonCache = /* @__PURE__ */ new Map();
16
16
  function gitArgs(input, args) {
17
- return input.authHeader ? ["git", "-c", `http.extraHeader=${input.authHeader}`, ...args] : ["git", ...args];
17
+ const configArgs = ["-c", "submodule.recurse=false", "-c", "fetch.recurseSubmodules=false", "-c", "push.recurseSubmodules=false"];
18
+ return input.authHeader ? ["git", ...configArgs, "-c", `http.extraHeader=${input.authHeader}`, ...args] : ["git", ...configArgs, ...args];
19
+ }
20
+ function withoutSubmoduleRecursion(args) {
21
+ return args[0] === "fetch" || args[0] === "push" ? [args[0], "--no-recurse-submodules", ...args.slice(1)] : args[0] === "clone" ? ["clone", "--no-recurse-submodules", ...args.slice(1)] : args[0] === "checkout" || args[0] === "reset" || args[0] === "restore" ? [args[0], "--no-recurse-submodules", ...args.slice(1)] : args;
18
22
  }
19
23
  function runGitResult(input, cwd, args) {
20
- const result = Bun.spawnSync(gitArgs(input, args), {
24
+ const result = Bun.spawnSync(gitArgs(input, withoutSubmoduleRecursion(args)), {
21
25
  cwd,
22
26
  stdout: "pipe",
23
27
  stderr: "pipe",
@@ -45,6 +49,35 @@ function tryGit(input, cwd, args) {
45
49
  function encodeWorkspaceBranch(branchName) {
46
50
  return encodeURIComponent(branchName);
47
51
  }
52
+ function workspaceProjectsForSync(projects, trigger) {
53
+ if (trigger.canonicalCheckoutOnly) {
54
+ if (!trigger.projectId || !trigger.branchName) {
55
+ throw new Error("A canonical-checkout-only synchronization requires projectId and branchName");
56
+ }
57
+ const project = projects.find((candidate) => candidate.projectId === trigger.projectId);
58
+ const checkout = project?.canonicalCheckouts.find((candidate) => candidate.branchName === trigger.branchName);
59
+ if (!project || !project.branches.includes(trigger.branchName) || !checkout) {
60
+ throw new Error(`Canonical resolver checkout ${trigger.projectId}/${trigger.branchName} is not present in this worker manifest`);
61
+ }
62
+ return [
63
+ {
64
+ ...project,
65
+ branches: [trigger.branchName],
66
+ canonicalCheckouts: [checkout]
67
+ }
68
+ ];
69
+ }
70
+ return projects.flatMap((project) => {
71
+ const canonicalBranches = new Set(project.canonicalCheckouts.map((checkout) => checkout.branchName));
72
+ const branches = project.branches.filter((branchName) => !canonicalBranches.has(branchName));
73
+ return branches.length > 0 || project.canonicalCheckouts.length > 0 ? [
74
+ {
75
+ ...project,
76
+ branches
77
+ }
78
+ ] : [];
79
+ });
80
+ }
48
81
  function workspaceProjectBranchRelativePath(projectId, branchName) {
49
82
  return path.posix.join("projects", projectId, "branches", encodeWorkspaceBranch(branchName));
50
83
  }
@@ -69,12 +102,30 @@ function listGitEligibleFiles(checkoutPath) {
69
102
  if (!fs.existsSync(path.join(checkoutPath, ".git"))) {
70
103
  return [];
71
104
  }
72
- const result = Bun.spawnSync(["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], {
73
- cwd: checkoutPath,
74
- stdout: "pipe",
75
- stderr: "pipe",
76
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
77
- });
105
+ const result = Bun.spawnSync(
106
+ [
107
+ "git",
108
+ "-c",
109
+ "submodule.recurse=false",
110
+ "-c",
111
+ "fetch.recurseSubmodules=false",
112
+ "-c",
113
+ "push.recurseSubmodules=false",
114
+ "ls-files",
115
+ "-z",
116
+ "--cached",
117
+ "--others",
118
+ "--exclude-standard",
119
+ "--",
120
+ "."
121
+ ],
122
+ {
123
+ cwd: checkoutPath,
124
+ stdout: "pipe",
125
+ stderr: "pipe",
126
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
127
+ }
128
+ );
78
129
  if (result.exitCode !== 0) {
79
130
  throw new Error(`inspect eligible project files: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
80
131
  }
@@ -87,9 +138,144 @@ function listGitEligibleFiles(checkoutPath) {
87
138
  }
88
139
  }).sort();
89
140
  }
141
+ function runCheckoutGitRaw(checkoutPath, args, action) {
142
+ const result = Bun.spawnSync(
143
+ ["git", "-c", "submodule.recurse=false", "-c", "fetch.recurseSubmodules=false", "-c", "push.recurseSubmodules=false", ...args],
144
+ {
145
+ cwd: checkoutPath,
146
+ stdout: "pipe",
147
+ stderr: "pipe",
148
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
149
+ }
150
+ );
151
+ if (result.exitCode !== 0) {
152
+ throw new Error(`${action}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
153
+ }
154
+ return result.stdout.toString();
155
+ }
156
+ function stagedCheckoutPaths(checkoutPath) {
157
+ return runCheckoutGitRaw(
158
+ checkoutPath,
159
+ ["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD"],
160
+ "inspect staged canonical resolver paths"
161
+ ).split("\0").filter(Boolean).sort();
162
+ }
163
+ function parseGitIndexEntries(output) {
164
+ return output.split("\0").filter(Boolean).flatMap((entry) => {
165
+ const separator = entry.indexOf(" ");
166
+ if (separator === -1) return [];
167
+ const [mode, objectId, stage] = entry.slice(0, separator).split(" ");
168
+ const filePath = entry.slice(separator + 1);
169
+ return mode && objectId && stage && filePath ? [{ mode, objectId, stage, filePath }] : [];
170
+ });
171
+ }
172
+ function indexEntriesForPath(checkoutPath, filePath) {
173
+ return parseGitIndexEntries(
174
+ runCheckoutGitRaw(
175
+ checkoutPath,
176
+ ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", filePath],
177
+ `inspect canonical resolver index entry for ${JSON.stringify(filePath)}`
178
+ )
179
+ );
180
+ }
181
+ function checkoutIndexEntries(checkoutPath) {
182
+ return parseGitIndexEntries(runCheckoutGitRaw(checkoutPath, ["ls-files", "--stage", "-z", "--", "."], "inspect project Git index"));
183
+ }
184
+ function checkoutGitlinks(checkoutPath) {
185
+ const entries = checkoutIndexEntries(checkoutPath);
186
+ const unmergedPaths = [...new Set(entries.filter((entry) => entry.stage !== "0").map((entry) => entry.filePath))].sort();
187
+ if (unmergedPaths.length > 0) {
188
+ throw new Error(`Project checkout has unmerged Git index stages: ${JSON.stringify(unmergedPaths)}`);
189
+ }
190
+ return entries.filter((entry) => entry.mode === "160000").map(({ filePath, objectId }) => ({ filePath, objectId })).sort((left, right) => left.filePath.localeCompare(right.filePath));
191
+ }
192
+ function revisionGitlinks(checkoutPath, revision) {
193
+ const output = runCheckoutGitRaw(checkoutPath, ["ls-tree", "-r", "-z", revision, "--", "."], `inspect ${revision} project gitlinks`);
194
+ return output.split("\0").filter(Boolean).flatMap((entry) => {
195
+ const separator = entry.indexOf(" ");
196
+ if (separator === -1) return [];
197
+ const [mode, type, objectId] = entry.slice(0, separator).split(" ");
198
+ const filePath = entry.slice(separator + 1);
199
+ return mode === "160000" && type === "commit" && objectId && filePath ? [{ filePath, objectId }] : [];
200
+ }).sort((left, right) => left.filePath.localeCompare(right.filePath));
201
+ }
202
+ function stagedDeletedGitlinkPaths(checkoutPath) {
203
+ const currentEntries = checkoutIndexEntries(checkoutPath);
204
+ return revisionGitlinks(checkoutPath, "HEAD").filter(
205
+ (entry) => !currentEntries.some((candidate) => candidate.filePath === entry.filePath || candidate.filePath.startsWith(`${entry.filePath}/`))
206
+ ).map((entry) => entry.filePath).sort();
207
+ }
208
+ function lstatOrNull(filePath) {
209
+ try {
210
+ return fs.lstatSync(filePath);
211
+ } catch (error) {
212
+ if (error.code === "ENOENT") return null;
213
+ throw error;
214
+ }
215
+ }
216
+ function worktreeDiffersFromIndex(checkoutPath, filePath) {
217
+ const result = Bun.spawnSync(
218
+ ["git", "--literal-pathspecs", "-c", "core.fileMode=true", "diff", "--quiet", "--no-ext-diff", "--", filePath],
219
+ {
220
+ cwd: checkoutPath,
221
+ stdout: "ignore",
222
+ stderr: "pipe",
223
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
224
+ }
225
+ );
226
+ if (result.exitCode === 0) return false;
227
+ if (result.exitCode === 1) return true;
228
+ throw new Error(
229
+ `compare canonical resolver index and worktree for ${JSON.stringify(filePath)}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`
230
+ );
231
+ }
232
+ function indexEntryTypeMatchesWorktree(entry, stat) {
233
+ if (entry.mode === "120000") return stat.isSymbolicLink();
234
+ if (entry.mode !== "100644" && entry.mode !== "100755") return false;
235
+ if (!stat.isFile()) return false;
236
+ return entry.mode === "100755" === ((stat.mode & 73) !== 0);
237
+ }
238
+ function assertCanonicalCheckoutIndexMatchesWorktree(checkoutPath) {
239
+ checkoutGitlinks(checkoutPath);
240
+ const mismatchedPaths = [];
241
+ for (const filePath of stagedCheckoutPaths(checkoutPath)) {
242
+ const entries = indexEntriesForPath(checkoutPath, filePath);
243
+ const worktreeEntry = lstatOrNull(path.join(checkoutPath, ...filePath.split("/")));
244
+ if (entries.length === 0) {
245
+ if (worktreeEntry && !worktreeEntry.isDirectory()) mismatchedPaths.push(filePath);
246
+ continue;
247
+ }
248
+ if (entries.length === 1 && entries[0]?.stage === "0" && entries[0].mode === "160000") {
249
+ if (worktreeEntry && !worktreeEntry.isDirectory()) mismatchedPaths.push(filePath);
250
+ continue;
251
+ }
252
+ if (entries.length !== 1 || entries[0]?.stage !== "0" || !worktreeEntry || !indexEntryTypeMatchesWorktree(entries[0], worktreeEntry) || worktreeDiffersFromIndex(checkoutPath, filePath)) {
253
+ mismatchedPaths.push(filePath);
254
+ }
255
+ }
256
+ if (mismatchedPaths.length > 0) {
257
+ throw new Error(
258
+ `Canonical resolver checkout has staged Git index state that is not represented by its worktree: ${JSON.stringify(
259
+ mismatchedPaths
260
+ )}. Make the worktree match the index or unstage these paths before synchronizing.`
261
+ );
262
+ }
263
+ }
90
264
  function checkoutGitSnapshot(checkoutPath) {
91
265
  const gitPaths = Bun.spawnSync(
92
- ["git", "rev-parse", "--git-path", "index", ...ACTIVE_GIT_LOCK_PATHS.flatMap((lock) => ["--git-path", lock])],
266
+ [
267
+ "git",
268
+ "-c",
269
+ "submodule.recurse=false",
270
+ "-c",
271
+ "fetch.recurseSubmodules=false",
272
+ "-c",
273
+ "push.recurseSubmodules=false",
274
+ "rev-parse",
275
+ "--git-path",
276
+ "index",
277
+ ...ACTIVE_GIT_LOCK_PATHS.flatMap((lock) => ["--git-path", lock])
278
+ ],
93
279
  {
94
280
  cwd: checkoutPath,
95
281
  stdout: "pipe",
@@ -102,12 +288,26 @@ function checkoutGitSnapshot(checkoutPath) {
102
288
  if (!indexPath || lockPaths.length !== ACTIVE_GIT_LOCK_PATHS.length || lockPaths.some((lockPath) => fs.existsSync(lockPath))) {
103
289
  return null;
104
290
  }
105
- const head = Bun.spawnSync(["git", "rev-parse", "--verify", "HEAD"], {
106
- cwd: checkoutPath,
107
- stdout: "pipe",
108
- stderr: "ignore",
109
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
110
- });
291
+ const head = Bun.spawnSync(
292
+ [
293
+ "git",
294
+ "-c",
295
+ "submodule.recurse=false",
296
+ "-c",
297
+ "fetch.recurseSubmodules=false",
298
+ "-c",
299
+ "push.recurseSubmodules=false",
300
+ "rev-parse",
301
+ "--verify",
302
+ "HEAD"
303
+ ],
304
+ {
305
+ cwd: checkoutPath,
306
+ stdout: "pipe",
307
+ stderr: "ignore",
308
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
309
+ }
310
+ );
111
311
  let indexSignature = "missing";
112
312
  try {
113
313
  const stat = fs.statSync(indexPath);
@@ -123,7 +323,10 @@ function listShadowTrackedFiles(input, relativeRoot) {
123
323
  const prefix = `${normalizedRoot}/`;
124
324
  return output.split("\0").filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length)).filter(Boolean).sort();
125
325
  }
126
- function listFilesRecursively(root, filter) {
326
+ function pathIsWithinOpaqueRoot(relativePath, opaqueRoots) {
327
+ return opaqueRoots.some((root) => relativePath === root || relativePath.startsWith(`${root}/`));
328
+ }
329
+ function listFilesRecursively(root, filter, opaqueRoots = []) {
127
330
  if (!fs.existsSync(root)) return [];
128
331
  const files = [];
129
332
  const visit = (current, relativeDir) => {
@@ -131,6 +334,7 @@ function listFilesRecursively(root, filter) {
131
334
  if (entry.name === ".git") continue;
132
335
  const relativePath = relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name;
133
336
  const absolutePath = path.join(current, entry.name);
337
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) continue;
134
338
  if (entry.isDirectory()) {
135
339
  visit(absolutePath, relativePath);
136
340
  } else if (!filter || filter(relativePath)) {
@@ -141,14 +345,19 @@ function listFilesRecursively(root, filter) {
141
345
  visit(root, "");
142
346
  return files.sort();
143
347
  }
144
- function removeEmptyDirectories(root) {
348
+ function removeEmptyDirectories(root, opaqueRoots = []) {
145
349
  if (!fs.existsSync(root)) return;
146
- const visit = (current) => {
350
+ const visit = (current, relativeDir) => {
147
351
  let empty = true;
148
352
  for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
149
353
  const absolutePath = path.join(current, entry.name);
354
+ const relativePath = relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name;
355
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) {
356
+ empty = false;
357
+ continue;
358
+ }
150
359
  if (entry.isDirectory()) {
151
- if (!visit(absolutePath)) empty = false;
360
+ if (!visit(absolutePath, relativePath)) empty = false;
152
361
  } else {
153
362
  empty = false;
154
363
  }
@@ -156,7 +365,7 @@ function removeEmptyDirectories(root) {
156
365
  if (empty && current !== root) fs.rmdirSync(current);
157
366
  return empty;
158
367
  };
159
- visit(root);
368
+ visit(root, "");
160
369
  }
161
370
  function copyEntry(sourceRoot, targetRoot, relativePath) {
162
371
  const sourcePath = path.resolve(sourceRoot, ...relativePath.split("/"));
@@ -226,56 +435,218 @@ function entriesEqual(sourcePath, targetPath) {
226
435
  }
227
436
  return equal;
228
437
  }
229
- function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles) {
438
+ function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles, opaqueTargetRoots = [], opaqueSourceRoots = []) {
230
439
  fs.mkdirSync(targetRoot, { recursive: true });
231
440
  const sourceSet = new Set(sourceFiles);
232
441
  for (const relativePath of targetFiles) {
442
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
233
443
  if (sourceSet.has(relativePath)) continue;
234
444
  const targetPath = path.resolve(targetRoot, ...relativePath.split("/"));
235
445
  assertInside(targetRoot, targetPath, "Workspace deletion path");
236
446
  fs.rmSync(targetPath, { recursive: true, force: true });
237
447
  }
238
448
  for (const relativePath of sourceFiles) {
449
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueSourceRoots)) continue;
450
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
239
451
  const sourcePath = path.resolve(sourceRoot, ...relativePath.split("/"));
240
- if (!fs.existsSync(sourcePath)) continue;
241
- const stat = fs.lstatSync(sourcePath);
452
+ const stat = lstatOrNull(sourcePath);
453
+ if (!stat) continue;
242
454
  if (!stat.isFile() && !stat.isSymbolicLink()) continue;
243
455
  const targetPath = path.resolve(targetRoot, ...relativePath.split("/"));
244
456
  if (entriesEqual(sourcePath, targetPath)) continue;
245
457
  copyEntry(sourceRoot, targetRoot, relativePath);
246
458
  }
247
- removeEmptyDirectories(targetRoot);
459
+ removeEmptyDirectories(targetRoot, opaqueTargetRoots);
460
+ }
461
+ function indexPathsUnderCheckoutPath(checkoutPath, filePath) {
462
+ return runCheckoutGitRaw(
463
+ checkoutPath,
464
+ ["--literal-pathspecs", "ls-files", "-z", "--", filePath],
465
+ `inspect index paths beneath ${JSON.stringify(filePath)}`
466
+ ).split("\0").filter(Boolean);
467
+ }
468
+ function replaceCheckoutIndexPathWithGitlink(checkoutPath, gitlink) {
469
+ for (const indexPath of indexPathsUnderCheckoutPath(checkoutPath, gitlink.filePath)) {
470
+ runCheckoutGitRaw(
471
+ checkoutPath,
472
+ ["--literal-pathspecs", "update-index", "--force-remove", "--", indexPath],
473
+ `remove index entry beneath gitlink ${JSON.stringify(gitlink.filePath)}`
474
+ );
475
+ }
476
+ runCheckoutGitRaw(
477
+ checkoutPath,
478
+ ["--literal-pathspecs", "update-index", "--add", "--cacheinfo", "160000", gitlink.objectId, gitlink.filePath],
479
+ `record gitlink ${JSON.stringify(gitlink.filePath)}`
480
+ );
481
+ }
482
+ function removeCheckoutIndexPath(checkoutPath, filePath) {
483
+ runCheckoutGitRaw(
484
+ checkoutPath,
485
+ ["--literal-pathspecs", "update-index", "--force-remove", "--", filePath],
486
+ `remove gitlink ${JSON.stringify(filePath)}`
487
+ );
488
+ }
489
+ function shadowProjectGitlinks(input, relativeRoot) {
490
+ const prefix = `${relativeRoot}/`;
491
+ const entries = parseGitIndexEntries(
492
+ runGit(input, input.shadowRoot, ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", relativeRoot], "inspect workspace gitlinks")
493
+ );
494
+ const unmergedPaths = [...new Set(entries.filter((entry) => entry.stage !== "0").map((entry) => entry.filePath))].sort();
495
+ if (unmergedPaths.length > 0) {
496
+ throw new Error(`Canonical workspace has unmerged Git index stages: ${JSON.stringify(unmergedPaths)}`);
497
+ }
498
+ return entries.filter((entry) => entry.mode === "160000" && entry.filePath.startsWith(prefix)).map((entry) => ({ filePath: entry.filePath.slice(prefix.length), objectId: entry.objectId })).sort((left, right) => left.filePath.localeCompare(right.filePath));
499
+ }
500
+ function replaceShadowIndexPathWithGitlink(input, relativeRoot, gitlink) {
501
+ const workspacePath = path.posix.join(relativeRoot, gitlink.filePath);
502
+ const existingPaths = runGit(
503
+ input,
504
+ input.shadowRoot,
505
+ ["--literal-pathspecs", "ls-files", "-z", "--", workspacePath],
506
+ `inspect workspace index beneath gitlink ${JSON.stringify(workspacePath)}`
507
+ ).split("\0").filter(Boolean);
508
+ for (const indexPath of existingPaths) {
509
+ runGit(
510
+ input,
511
+ input.shadowRoot,
512
+ ["--literal-pathspecs", "update-index", "--force-remove", "--", indexPath],
513
+ `remove workspace index entry beneath gitlink ${JSON.stringify(workspacePath)}`
514
+ );
515
+ }
516
+ const placeholderPath = path.join(input.shadowRoot, ...workspacePath.split("/"));
517
+ fs.rmSync(placeholderPath, { recursive: true, force: true });
518
+ fs.mkdirSync(placeholderPath, { recursive: true });
519
+ runGit(
520
+ input,
521
+ input.shadowRoot,
522
+ ["--literal-pathspecs", "update-index", "--add", "--cacheinfo", "160000", gitlink.objectId, workspacePath],
523
+ `record workspace gitlink ${JSON.stringify(workspacePath)}`
524
+ );
525
+ }
526
+ function mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks) {
527
+ const desiredByPath = new Map(visibleGitlinks.map((entry) => [entry.filePath, entry]));
528
+ for (const current of shadowProjectGitlinks(input, relativeRoot)) {
529
+ if (desiredByPath.has(current.filePath)) continue;
530
+ const workspacePath = path.posix.join(relativeRoot, current.filePath);
531
+ runGit(
532
+ input,
533
+ input.shadowRoot,
534
+ ["--literal-pathspecs", "update-index", "--force-remove", "--", workspacePath],
535
+ `remove deleted workspace gitlink ${JSON.stringify(workspacePath)}`
536
+ );
537
+ fs.rmSync(path.join(input.shadowRoot, ...workspacePath.split("/")), { recursive: true, force: true });
538
+ }
539
+ for (const gitlink of visibleGitlinks) replaceShadowIndexPathWithGitlink(input, relativeRoot, gitlink);
540
+ }
541
+ function mirrorShadowGitlinksToVisible(desired, visibleRoot) {
542
+ const desiredByPath = new Map(desired.map((entry) => [entry.filePath, entry]));
543
+ for (const current of checkoutGitlinks(visibleRoot)) {
544
+ if (!desiredByPath.has(current.filePath)) removeCheckoutIndexPath(visibleRoot, current.filePath);
545
+ }
546
+ for (const gitlink of desired) {
547
+ const visiblePath = path.join(visibleRoot, ...gitlink.filePath.split("/"));
548
+ const existing = lstatOrNull(visiblePath);
549
+ if (existing && !existing.isDirectory()) fs.rmSync(visiblePath, { recursive: true, force: true });
550
+ fs.mkdirSync(visiblePath, { recursive: true });
551
+ replaceCheckoutIndexPathWithGitlink(visibleRoot, gitlink);
552
+ }
553
+ }
554
+ function assertSafeVisibleGitlinkTransitions(visibleRoot, currentGitlinks, desiredGitlinks, shadowFiles) {
555
+ const desiredGitlinkPaths = new Set(desiredGitlinks.map((entry) => entry.filePath));
556
+ for (const current of currentGitlinks) {
557
+ if (desiredGitlinkPaths.has(current.filePath)) continue;
558
+ const becomesOrdinaryPath = shadowFiles.some(
559
+ (filePath) => filePath === current.filePath || filePath.startsWith(`${current.filePath}/`)
560
+ );
561
+ if (!becomesOrdinaryPath) continue;
562
+ const existing = lstatOrNull(path.join(visibleRoot, ...current.filePath.split("/")));
563
+ if (!existing) continue;
564
+ if (existing.isDirectory() && fs.readdirSync(path.join(visibleRoot, ...current.filePath.split("/"))).length === 0) {
565
+ continue;
566
+ }
567
+ throw new Error(
568
+ `Cannot replace initialized or nonempty gitlink ${JSON.stringify(
569
+ current.filePath
570
+ )} with ordinary workspace content without deleting submodule data`
571
+ );
572
+ }
248
573
  }
249
574
  function restoreShadowProjectSnapshot(input, relativeRoot) {
250
575
  const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
251
576
  const headFiles = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", "HEAD", "--", relativeRoot]);
252
577
  if (headFiles.exitCode === 0 && headFiles.stdout) {
253
- runGit(input, input.shadowRoot, ["restore", "--source=HEAD", "--worktree", "--", relativeRoot], "restore deferred checkout scan");
578
+ runGit(
579
+ input,
580
+ input.shadowRoot,
581
+ ["restore", "--source=HEAD", "--staged", "--worktree", "--", relativeRoot],
582
+ "restore deferred checkout scan"
583
+ );
254
584
  runGit(input, input.shadowRoot, ["clean", "-fd", "--", relativeRoot], "clean deferred checkout scan");
255
585
  return;
256
586
  }
587
+ runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
257
588
  fs.rmSync(shadowRoot, { recursive: true, force: true });
258
589
  }
259
590
  function mirrorVisibleProjectToShadow(input, manifest, branchName) {
260
591
  const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
261
- if (!fs.existsSync(path.join(visibleRoot, ".git"))) return;
592
+ if (!fs.existsSync(path.join(visibleRoot, ".git"))) return { entries: [], opaqueRoots: [] };
593
+ const isCanonicalCheckout = manifest.canonicalCheckouts?.some((checkout) => checkout.branchName === branchName);
594
+ if (isCanonicalCheckout) {
595
+ assertCanonicalCheckoutIndexMatchesWorktree(visibleRoot);
596
+ }
262
597
  const beforeSnapshot = checkoutGitSnapshot(visibleRoot);
263
- if (!beforeSnapshot) return;
264
- const visibleFiles = listGitEligibleFiles(visibleRoot);
598
+ if (!beforeSnapshot) return { entries: [], opaqueRoots: [] };
599
+ const visibleGitlinks = checkoutGitlinks(visibleRoot);
600
+ const visibleIndexEntries = checkoutIndexEntries(visibleRoot).filter((entry) => entry.stage === "0");
601
+ const indexedGitlinkPaths = new Set(visibleGitlinks.map((entry) => entry.filePath));
602
+ const stagedDeletedGitlinkRoots = stagedDeletedGitlinkPaths(visibleRoot);
265
603
  const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
604
+ const shadowGitlinkRoots = shadowProjectGitlinks(input, relativeRoot).filter(
605
+ (entry) => !indexedGitlinkPaths.has(entry.filePath) && !visibleIndexEntries.some(
606
+ (candidate) => candidate.mode !== "160000" && (candidate.filePath === entry.filePath || candidate.filePath.startsWith(`${entry.filePath}/`))
607
+ )
608
+ ).map((entry) => entry.filePath);
609
+ const visibleGitlinkRoots = [.../* @__PURE__ */ new Set([...indexedGitlinkPaths, ...stagedDeletedGitlinkRoots, ...shadowGitlinkRoots])].sort();
610
+ const visibleFiles = listGitEligibleFiles(visibleRoot).filter((filePath) => !pathIsWithinOpaqueRoot(filePath, visibleGitlinkRoots));
266
611
  const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
267
- mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot));
612
+ mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks);
613
+ mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot), visibleGitlinkRoots, visibleGitlinkRoots);
268
614
  if (checkoutGitSnapshot(visibleRoot) !== beforeSnapshot) {
269
615
  restoreShadowProjectSnapshot(input, relativeRoot);
616
+ return { entries: [], opaqueRoots: [] };
270
617
  }
618
+ return {
619
+ entries: visibleGitlinks.map((entry) => ({
620
+ filePath: path.posix.join(relativeRoot, entry.filePath),
621
+ objectId: entry.objectId
622
+ })),
623
+ opaqueRoots: visibleGitlinkRoots.map((filePath) => path.posix.join(relativeRoot, filePath))
624
+ };
271
625
  }
272
- function mirrorShadowProjectToVisible(input, manifest, branchName) {
626
+ function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpaqueRoots = []) {
273
627
  const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
274
628
  if (!fs.existsSync(path.join(visibleRoot, ".git"))) return;
629
+ checkoutGitlinks(visibleRoot);
275
630
  const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
276
631
  const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
277
632
  const shadowFiles = listShadowTrackedFiles(input, relativeRoot);
278
- mirrorFileSet(shadowRoot, visibleRoot, shadowFiles, listGitEligibleFiles(visibleRoot));
633
+ const desiredGitlinks = shadowProjectGitlinks(input, relativeRoot);
634
+ const desiredGitlinkPaths = new Set(desiredGitlinks.map((entry) => entry.filePath));
635
+ const visibleGitlinks = checkoutGitlinks(visibleRoot);
636
+ const protectedGitlinkRoots = [
637
+ .../* @__PURE__ */ new Set([...visibleGitlinks.map((entry) => entry.filePath), ...stagedDeletedGitlinkPaths(visibleRoot), ...additionalOpaqueRoots])
638
+ ].sort();
639
+ assertSafeVisibleGitlinkTransitions(
640
+ visibleRoot,
641
+ protectedGitlinkRoots.map((filePath) => ({ filePath, objectId: "" })),
642
+ desiredGitlinks,
643
+ shadowFiles
644
+ );
645
+ const opaqueVisibleGitlinks = protectedGitlinkRoots.filter(
646
+ (filePath) => desiredGitlinkPaths.has(filePath) || !shadowFiles.some((shadowFile) => shadowFile === filePath || shadowFile.startsWith(`${filePath}/`))
647
+ );
648
+ mirrorFileSet(shadowRoot, visibleRoot, shadowFiles, listGitEligibleFiles(visibleRoot), opaqueVisibleGitlinks);
649
+ mirrorShadowGitlinksToVisible(desiredGitlinks, visibleRoot);
279
650
  }
280
651
  function mirrorLocalPlansToShadow(input, manifest, branchName) {
281
652
  const sourceRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
@@ -296,25 +667,36 @@ function mirrorShadowPlansToLocal(input, manifest, branchName) {
296
667
  listFilesRecursively(targetRoot, planFilter)
297
668
  );
298
669
  }
670
+ function mergeGitlinkProjection(target, source) {
671
+ target.entries.push(...source.entries);
672
+ target.opaqueRoots.push(...source.opaqueRoots);
673
+ }
299
674
  function mirrorVisibleWorkspaceToShadow(input, excludedCheckouts = /* @__PURE__ */ new Set()) {
675
+ const projection = { entries: [], opaqueRoots: [] };
300
676
  for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
301
677
  for (const branchName of [...new Set(manifest.branches)].sort()) {
302
678
  if (excludedCheckouts.has(`${manifest.projectId}\0${branchName}`)) continue;
303
- mirrorVisibleProjectToShadow(input, manifest, branchName);
304
- mirrorLocalPlansToShadow(input, manifest, branchName);
679
+ mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, branchName));
680
+ if (!input.trigger.canonicalCheckoutOnly) mirrorLocalPlansToShadow(input, manifest, branchName);
305
681
  }
306
682
  }
683
+ projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
684
+ return projection;
307
685
  }
308
686
  function reconcileNewVisibleCheckouts(input) {
687
+ const projection = { entries: [], opaqueRoots: [] };
309
688
  for (const target of input.newVisibleCheckouts ?? []) {
310
689
  const manifest = input.projects.find((project) => project.projectId === target.projectId);
311
690
  if (!manifest || !manifest.branches.includes(target.branchName)) continue;
312
691
  const projectRoot = workspaceProjectBranchRelativePath(target.projectId, target.branchName);
313
- if (listShadowTrackedFiles(input, projectRoot).length > 0 || restoreShadowRootFromRemote(input, projectRoot)) {
692
+ if (input.trigger.canonicalCheckoutOnly) {
693
+ mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
694
+ } else if (listShadowTrackedFiles(input, projectRoot).length > 0 || restoreShadowRootFromRemote(input, projectRoot)) {
314
695
  mirrorShadowProjectToVisible(input, manifest, target.branchName);
315
696
  } else {
316
- mirrorVisibleProjectToShadow(input, manifest, target.branchName);
697
+ mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
317
698
  }
699
+ if (input.trigger.canonicalCheckoutOnly) continue;
318
700
  const plansRoot = workspacePlansRelativePath(target.projectId, target.branchName);
319
701
  if (listShadowTrackedFiles(input, plansRoot).length > 0 || restoreShadowRootFromRemote(input, plansRoot)) {
320
702
  mirrorShadowPlansToLocal(input, manifest, target.branchName);
@@ -322,12 +704,93 @@ function reconcileNewVisibleCheckouts(input) {
322
704
  mirrorLocalPlansToShadow(input, manifest, target.branchName);
323
705
  }
324
706
  }
707
+ projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
708
+ return projection;
325
709
  }
326
- function mirrorShadowWorkspaceToVisible(input) {
710
+ function mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots = []) {
327
711
  for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
328
712
  for (const branchName of [...new Set(manifest.branches)].sort()) {
329
- mirrorShadowProjectToVisible(input, manifest, branchName);
330
- mirrorShadowPlansToLocal(input, manifest, branchName);
713
+ const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
714
+ const opaqueProjectRoots = opaqueWorkspaceRoots.filter((root) => root.startsWith(`${relativeRoot}/`)).map((root) => root.slice(relativeRoot.length + 1));
715
+ mirrorShadowProjectToVisible(input, manifest, branchName, opaqueProjectRoots);
716
+ if (!input.trigger.canonicalCheckoutOnly) mirrorShadowPlansToLocal(input, manifest, branchName);
717
+ }
718
+ }
719
+ }
720
+ function forceStageCanonicalCheckoutFiles(input, preservedGitlinks = [], opaqueRoots = []) {
721
+ for (const manifest of input.projects) {
722
+ for (const checkout of manifest.canonicalCheckouts ?? []) {
723
+ if (!manifest.branches.includes(checkout.branchName)) continue;
724
+ const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, checkout.branchName);
725
+ const absoluteRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
726
+ if (!fs.existsSync(absoluteRoot)) continue;
727
+ const scopedOpaqueRoots = opaqueRoots.filter((root) => root.startsWith(`${relativeRoot}/`)).map((root) => root.slice(relativeRoot.length + 1));
728
+ runGit(
729
+ input,
730
+ input.shadowRoot,
731
+ [
732
+ "add",
733
+ "-f",
734
+ "-A",
735
+ "--",
736
+ `:(literal)${relativeRoot}`,
737
+ ...scopedOpaqueRoots.map((root) => `:(exclude,literal)${path.posix.join(relativeRoot, root)}`)
738
+ ],
739
+ `force-stage canonical resolver checkout ${manifest.projectId}/${checkout.branchName}`
740
+ );
741
+ }
742
+ }
743
+ for (const gitlink of preservedGitlinks) {
744
+ replaceShadowIndexPathWithGitlink(input, "", gitlink);
745
+ }
746
+ const preservedPaths = new Set(preservedGitlinks.map((entry) => entry.filePath));
747
+ for (const opaqueRoot of opaqueRoots) {
748
+ if (preservedPaths.has(opaqueRoot)) continue;
749
+ runGit(
750
+ input,
751
+ input.shadowRoot,
752
+ ["--literal-pathspecs", "update-index", "--force-remove", "--", opaqueRoot],
753
+ `remove explicitly deleted workspace gitlink ${JSON.stringify(opaqueRoot)}`
754
+ );
755
+ }
756
+ }
757
+ function canonicalCheckoutRelativeRoots(input) {
758
+ const roots = /* @__PURE__ */ new Set();
759
+ for (const manifest of input.projects) {
760
+ for (const checkout of manifest.canonicalCheckouts) {
761
+ roots.add(workspaceProjectBranchRelativePath(manifest.projectId, checkout.branchName));
762
+ }
763
+ }
764
+ return [...roots].sort();
765
+ }
766
+ function restoreUnscopedCanonicalCheckoutSubtrees(input) {
767
+ if (input.trigger.canonicalCheckoutOnly) return;
768
+ const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
769
+ const emptyTree = emptyTreeHash(input);
770
+ for (const relativeRoot of canonicalCheckoutRelativeRoots(input)) {
771
+ const remoteTree = remoteHead ? workspaceSubtreeTreeHashAtRevision(input, remoteHead, relativeRoot) : emptyTree;
772
+ if (remoteTree === emptyTree) {
773
+ runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
774
+ fs.rmSync(path.join(input.shadowRoot, ...relativeRoot.split("/")), { recursive: true, force: true });
775
+ continue;
776
+ }
777
+ runGit(
778
+ input,
779
+ input.shadowRoot,
780
+ ["--literal-pathspecs", "restore", `--source=${remoteHead}`, "--staged", "--worktree", "--", relativeRoot],
781
+ `restore excluded canonical resolver subtree ${relativeRoot}`
782
+ );
783
+ }
784
+ }
785
+ function assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, revision) {
786
+ if (input.trigger.canonicalCheckoutOnly) return;
787
+ const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
788
+ const emptyTree = emptyTreeHash(input);
789
+ for (const relativeRoot of canonicalCheckoutRelativeRoots(input)) {
790
+ const candidateTree = workspaceSubtreeTreeHashAtRevision(input, revision, relativeRoot);
791
+ const remoteTree = remoteHead ? workspaceSubtreeTreeHashAtRevision(input, remoteHead, relativeRoot) : emptyTree;
792
+ if (candidateTree !== remoteTree) {
793
+ throw new Error(`Generic workspace synchronization cannot publish canonical resolver subtree ${relativeRoot}`);
331
794
  }
332
795
  }
333
796
  }
@@ -360,6 +823,64 @@ function revParse(input, revision) {
360
823
  const result = runGitResult(input, input.shadowRoot, ["rev-parse", "--verify", revision]);
361
824
  return result.exitCode === 0 ? result.stdout : null;
362
825
  }
826
+ function emptyTreeHash(input) {
827
+ const result = Bun.spawnSync(gitArgs(input, ["mktree"]), {
828
+ cwd: input.shadowRoot,
829
+ stdin: Buffer.alloc(0),
830
+ stdout: "pipe",
831
+ stderr: "pipe",
832
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
833
+ });
834
+ if (result.exitCode !== 0) {
835
+ throw new Error(`create empty workspace tree: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
836
+ }
837
+ return result.stdout.toString().trim();
838
+ }
839
+ function workspaceSubtreeTreeHashAtRevision(input, revision, relativeRoot) {
840
+ const rootType = runGitResult(input, input.shadowRoot, ["cat-file", "-t", revision]);
841
+ if (rootType.exitCode !== 0 || rootType.stdout !== "commit" && rootType.stdout !== "tree") {
842
+ throw new Error(`Cannot inspect canonical resolver subtree at invalid workspace revision ${revision}`);
843
+ }
844
+ let treeHash = rootType.stdout === "commit" ? runGit(input, input.shadowRoot, ["rev-parse", "--verify", `${revision}^{tree}`], "resolve workspace root tree") : revision;
845
+ for (const segment of relativeRoot.split("/")) {
846
+ const entry = runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "ls-tree", "-z", treeHash, "--", segment]);
847
+ if (entry.exitCode !== 0) {
848
+ throw new Error(`Inspect canonical resolver workspace path ${relativeRoot}: ${entry.stderr || entry.stdout}`);
849
+ }
850
+ if (!entry.stdout) return emptyTreeHash(input);
851
+ const separator = entry.stdout.indexOf(" ");
852
+ const [mode, type, objectId] = separator === -1 ? [] : entry.stdout.slice(0, separator).split(" ");
853
+ const entryName = separator === -1 ? "" : entry.stdout.slice(separator + 1).replace(/\0+$/, "");
854
+ if (!mode || type !== "tree" || !objectId || entryName !== segment) {
855
+ throw new Error(`Canonical resolver workspace path ${relativeRoot} contains a non-tree component ${segment}`);
856
+ }
857
+ treeHash = objectId;
858
+ }
859
+ return treeHash;
860
+ }
861
+ function canonicalCheckoutTreeHashAtRevision(input, revision) {
862
+ if (!input.trigger.canonicalCheckoutOnly || !input.trigger.projectId || !input.trigger.branchName) {
863
+ throw new Error("Canonical checkout tree hashing requires an exact canonical-checkout-only trigger");
864
+ }
865
+ return workspaceSubtreeTreeHashAtRevision(
866
+ input,
867
+ revision,
868
+ workspaceProjectBranchRelativePath(input.trigger.projectId, input.trigger.branchName)
869
+ );
870
+ }
871
+ function sampleCanonicalCheckoutTree(input) {
872
+ if (!input.trigger.canonicalCheckoutOnly) return void 0;
873
+ const stagedWorkspaceTree = runGit(input, input.shadowRoot, ["write-tree"], "write canonical resolver workspace tree");
874
+ return canonicalCheckoutTreeHashAtRevision(input, stagedWorkspaceTree);
875
+ }
876
+ function assertPublishedCanonicalCheckoutTree(input, revision, sampledTreeHash) {
877
+ if (!input.trigger.canonicalCheckoutOnly) return;
878
+ if (!sampledTreeHash) throw new Error("Canonical resolver synchronization did not capture a staged subtree tree hash");
879
+ const publishedTreeHash = canonicalCheckoutTreeHashAtRevision(input, revision);
880
+ if (publishedTreeHash !== sampledTreeHash) {
881
+ throw new Error(`Canonical resolver subtree changed while synchronizing: sampled ${sampledTreeHash}, published ${publishedTreeHash}`);
882
+ }
883
+ }
363
884
  function gitStatus(input) {
364
885
  return runGit(input, input.shadowRoot, ["status", "--porcelain=v1", "--untracked-files=all"], "read workspace status");
365
886
  }
@@ -443,8 +964,9 @@ function revisionTrackedFileCount(input, revision, relativeRoot) {
443
964
  return result.stdout.split("\0").filter(Boolean).length;
444
965
  }
445
966
  function destructiveCheckoutReductions(input, baseRevision) {
967
+ const authoritativeRoot = input.trigger.canonicalCheckoutOnly && input.trigger.projectId && input.trigger.branchName ? workspaceProjectBranchRelativePath(input.trigger.projectId, input.trigger.branchName) : null;
446
968
  const roots = new Set(
447
- stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root))
969
+ stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root) && root !== authoritativeRoot)
448
970
  );
449
971
  const destructive = [];
450
972
  for (const root of roots) {
@@ -519,14 +1041,14 @@ function isNonFastForward(result) {
519
1041
  ${result.stderr}`.toLowerCase();
520
1042
  return output.includes("non-fast-forward") || output.includes("fetch first") || output.includes("[rejected]");
521
1043
  }
522
- function resetShadowToRemote(input) {
1044
+ function resetShadowToRemote(input, opaqueWorkspaceRoots = []) {
523
1045
  runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch canonical workspace before reset");
524
1046
  const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
525
1047
  if (!remoteHead) return null;
526
1048
  runGitResult(input, input.shadowRoot, ["rebase", "--abort"]);
527
1049
  runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "reset workspace to canonical state");
528
1050
  runGit(input, input.shadowRoot, ["clean", "-fd"], "clean reset workspace");
529
- mirrorShadowWorkspaceToVisible(input);
1051
+ if (!input.trigger.canonicalCheckoutOnly) mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots);
530
1052
  return remoteHead;
531
1053
  }
532
1054
  function baseResult(input, startingHead) {
@@ -546,12 +1068,17 @@ function baseResult(input, startingHead) {
546
1068
  };
547
1069
  }
548
1070
  async function synchronizeWorkspace(rawInput) {
549
- const input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
1071
+ let input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
550
1072
  try {
1073
+ input = { ...input, projects: workspaceProjectsForSync(input.projects, input.trigger) };
1074
+ let mirroredGitlinkProjection = { entries: [], opaqueRoots: [] };
551
1075
  ensureShadowWorkspace(input);
552
1076
  const remoteHeadAtStart = revParse(input, `origin/${WORKSPACE_BRANCH}`);
553
1077
  const result = baseResult(input, remoteHeadAtStart);
554
1078
  if (input.resetToCanonical) {
1079
+ if (input.trigger.canonicalCheckoutOnly) {
1080
+ throw new Error("A canonical-checkout-only synchronization cannot reset the authoritative resolver checkout");
1081
+ }
555
1082
  const publishedHead = resetShadowToRemote(input);
556
1083
  return {
557
1084
  ...result,
@@ -561,14 +1088,19 @@ async function synchronizeWorkspace(rawInput) {
561
1088
  gitStatus: gitStatus(input)
562
1089
  };
563
1090
  }
1091
+ if (!input.skipVisibleMirror) resetUncommittedShadowSnapshot(input);
1092
+ restoreUnscopedCanonicalCheckoutSubtrees(input);
564
1093
  if (!input.skipVisibleMirror) {
565
- resetUncommittedShadowSnapshot(input);
566
1094
  const newCheckoutKeys = new Set((input.newVisibleCheckouts ?? []).map((target) => `${target.projectId}\0${target.branchName}`));
567
- mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
1095
+ mirroredGitlinkProjection = mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
568
1096
  fastForwardCleanShadowForNewCheckouts(input);
569
- reconcileNewVisibleCheckouts(input);
1097
+ mergeGitlinkProjection(mirroredGitlinkProjection, reconcileNewVisibleCheckouts(input));
1098
+ mirroredGitlinkProjection.opaqueRoots = [...new Set(mirroredGitlinkProjection.opaqueRoots)].sort();
570
1099
  }
571
- runGit(input, input.shadowRoot, ["add", "-A"], "stage workspace changes");
1100
+ const stagePathspecs = mirroredGitlinkProjection.opaqueRoots.flatMap((root) => [`:(exclude,literal)${root}`]);
1101
+ runGit(input, input.shadowRoot, ["add", "-A", "--", ".", ...stagePathspecs], "stage workspace changes");
1102
+ forceStageCanonicalCheckoutFiles(input, mirroredGitlinkProjection.entries, mirroredGitlinkProjection.opaqueRoots);
1103
+ const sampledCanonicalCheckoutTreeHash = sampleCanonicalCheckoutTree(input);
572
1104
  const stagedWorkingPaths = stagedPaths(input);
573
1105
  const baseRevision = candidateBaseRevision(input);
574
1106
  const paths = stagedPaths(input, baseRevision);
@@ -579,7 +1111,8 @@ async function synchronizeWorkspace(rawInput) {
579
1111
  diffSizeBytes,
580
1112
  gitStatus: statusBeforeCommit,
581
1113
  affectedPaths: reportedPaths(paths),
582
- affectedProjects: affectedProjects(paths)
1114
+ affectedProjects: affectedProjects(paths),
1115
+ ...sampledCanonicalCheckoutTreeHash ? { sampledCanonicalCheckoutTreeHash } : {}
583
1116
  };
584
1117
  const destructiveReductions = destructiveCheckoutReductions(input, baseRevision);
585
1118
  if (destructiveReductions.length > 0 && !input.confirmedLargeDiff) {
@@ -590,7 +1123,7 @@ async function synchronizeWorkspace(rawInput) {
590
1123
  error: `Workspace safety guard blocked a destructive checkout reduction: ${summary}`
591
1124
  };
592
1125
  }
593
- if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff) {
1126
+ if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff && !input.trigger.canonicalCheckoutOnly) {
594
1127
  return {
595
1128
  ...observed,
596
1129
  outcome: "large_diff_blocked",
@@ -601,6 +1134,7 @@ async function synchronizeWorkspace(rawInput) {
601
1134
  runGit(input, input.shadowRoot, ["commit", "-m", commitMessage(input)], "commit workspace changes");
602
1135
  }
603
1136
  const candidateHead = revParse(input, "HEAD");
1137
+ if (candidateHead) assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, candidateHead);
604
1138
  const hasRemoteMain = Boolean(revParse(input, `origin/${WORKSPACE_BRANCH}`));
605
1139
  const hasUnpushedCommit = candidateHead ? !hasRemoteMain || !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", candidateHead, `origin/${WORKSPACE_BRANCH}`]) : false;
606
1140
  if (!hasUnpushedCommit) {
@@ -609,7 +1143,8 @@ async function synchronizeWorkspace(rawInput) {
609
1143
  if (currentRemoteHead && localHead !== currentRemoteHead) {
610
1144
  runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "update workspace from canonical state");
611
1145
  runGit(input, input.shadowRoot, ["clean", "-fd"], "clean updated workspace");
612
- mirrorShadowWorkspaceToVisible(input);
1146
+ assertPublishedCanonicalCheckoutTree(input, currentRemoteHead, sampledCanonicalCheckoutTreeHash);
1147
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
613
1148
  return {
614
1149
  ...observed,
615
1150
  outcome: "updated",
@@ -618,7 +1153,13 @@ async function synchronizeWorkspace(rawInput) {
618
1153
  gitStatus: gitStatus(input)
619
1154
  };
620
1155
  }
621
- if (input.skipVisibleMirror) mirrorShadowWorkspaceToVisible(input);
1156
+ const authoritativeHead = currentRemoteHead ?? localHead;
1157
+ if (authoritativeHead) {
1158
+ assertPublishedCanonicalCheckoutTree(input, authoritativeHead, sampledCanonicalCheckoutTreeHash);
1159
+ }
1160
+ if (input.skipVisibleMirror) {
1161
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
1162
+ }
622
1163
  return {
623
1164
  ...observed,
624
1165
  outcome: "no_change",
@@ -632,7 +1173,8 @@ async function synchronizeWorkspace(rawInput) {
632
1173
  const push = runGitResult(input, input.shadowRoot, ["push", "origin", `HEAD:refs/heads/${WORKSPACE_BRANCH}`]);
633
1174
  if (push.exitCode === 0) {
634
1175
  const publishedHead = revParse(input, "HEAD") ?? candidateHead;
635
- mirrorShadowWorkspaceToVisible(input);
1176
+ if (publishedHead) assertPublishedCanonicalCheckoutTree(input, publishedHead, sampledCanonicalCheckoutTreeHash);
1177
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
636
1178
  return {
637
1179
  ...observed,
638
1180
  outcome: "published",
@@ -658,7 +1200,7 @@ async function synchronizeWorkspace(rawInput) {
658
1200
  if (rebase.exitCode !== 0) {
659
1201
  const conflicted = runGitResult(input, input.shadowRoot, ["diff", "--name-only", "--diff-filter=U", "-z"]);
660
1202
  const discardedPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
661
- const publishedHead = resetShadowToRemote(input);
1203
+ const publishedHead = resetShadowToRemote(input, mirroredGitlinkProjection.opaqueRoots);
662
1204
  return {
663
1205
  ...observed,
664
1206
  outcome: "conflict_reset",
@@ -670,6 +1212,8 @@ async function synchronizeWorkspace(rawInput) {
670
1212
  gitStatus: gitStatus(input)
671
1213
  };
672
1214
  }
1215
+ assertPublishedCanonicalCheckoutTree(input, "HEAD", sampledCanonicalCheckoutTreeHash);
1216
+ assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, "HEAD");
673
1217
  }
674
1218
  return {
675
1219
  ...observed,
@@ -688,13 +1232,27 @@ async function synchronizeWorkspace(rawInput) {
688
1232
  };
689
1233
  }
690
1234
  }
691
- function calculateWorkspaceDiffFingerprint(input) {
1235
+ function calculateWorkspaceDiffFingerprint(rawInput) {
1236
+ const input = {
1237
+ ...rawInput,
1238
+ projects: workspaceProjectsForSync(rawInput.projects, rawInput.trigger)
1239
+ };
692
1240
  ensureShadowWorkspace(input);
1241
+ let gitlinkProjection = { entries: [], opaqueRoots: [] };
693
1242
  if (!input.skipVisibleMirror) {
694
1243
  resetUncommittedShadowSnapshot(input);
695
- mirrorVisibleWorkspaceToShadow(input);
1244
+ restoreUnscopedCanonicalCheckoutSubtrees(input);
1245
+ gitlinkProjection = mirrorVisibleWorkspaceToShadow(input);
1246
+ } else {
1247
+ restoreUnscopedCanonicalCheckoutSubtrees(input);
696
1248
  }
697
- runGit(input, input.shadowRoot, ["add", "-A"], "stage workspace fingerprint");
1249
+ runGit(
1250
+ input,
1251
+ input.shadowRoot,
1252
+ ["add", "-A", "--", ".", ...gitlinkProjection.opaqueRoots.map((root) => `:(exclude,literal)${root}`)],
1253
+ "stage workspace fingerprint"
1254
+ );
1255
+ forceStageCanonicalCheckoutFiles(input, gitlinkProjection.entries, gitlinkProjection.opaqueRoots);
698
1256
  const stagedTree = runGit(input, input.shadowRoot, ["write-tree"], "write workspace fingerprint tree");
699
1257
  const headTree = revParse(input, "HEAD^{tree}");
700
1258
  const localHead = revParse(input, "HEAD");
@@ -735,6 +1293,7 @@ export {
735
1293
  WORKSPACE_BRANCH,
736
1294
  WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
737
1295
  WorkspaceSyncSingleFlight,
1296
+ assertCanonicalCheckoutIndexMatchesWorktree,
738
1297
  calculateWorkspaceDiffFingerprint,
739
1298
  encodeWorkspaceBranch,
740
1299
  mirrorShadowWorkspaceToVisible,
@@ -742,5 +1301,6 @@ export {
742
1301
  synchronizeWorkspace,
743
1302
  visibleProjectBranchPath,
744
1303
  workspacePlansRelativePath,
745
- workspaceProjectBranchRelativePath
1304
+ workspaceProjectBranchRelativePath,
1305
+ workspaceProjectsForSync
746
1306
  };