@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.
@@ -32,6 +32,7 @@ __export(workspace_sync_exports, {
32
32
  WORKSPACE_BRANCH: () => WORKSPACE_BRANCH,
33
33
  WORKSPACE_PERIODIC_SCAN_INTERVAL_MS: () => WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
34
34
  WorkspaceSyncSingleFlight: () => WorkspaceSyncSingleFlight,
35
+ assertCanonicalCheckoutIndexMatchesWorktree: () => assertCanonicalCheckoutIndexMatchesWorktree,
35
36
  calculateWorkspaceDiffFingerprint: () => calculateWorkspaceDiffFingerprint,
36
37
  encodeWorkspaceBranch: () => encodeWorkspaceBranch,
37
38
  mirrorShadowWorkspaceToVisible: () => mirrorShadowWorkspaceToVisible,
@@ -39,7 +40,8 @@ __export(workspace_sync_exports, {
39
40
  synchronizeWorkspace: () => synchronizeWorkspace,
40
41
  visibleProjectBranchPath: () => visibleProjectBranchPath,
41
42
  workspacePlansRelativePath: () => workspacePlansRelativePath,
42
- workspaceProjectBranchRelativePath: () => workspaceProjectBranchRelativePath
43
+ workspaceProjectBranchRelativePath: () => workspaceProjectBranchRelativePath,
44
+ workspaceProjectsForSync: () => workspaceProjectsForSync
43
45
  });
44
46
  module.exports = __toCommonJS(workspace_sync_exports);
45
47
  var import_node_crypto = require("node:crypto");
@@ -58,10 +60,14 @@ const DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO = 0.8;
58
60
  const ACTIVE_GIT_LOCK_PATHS = ["index.lock", "HEAD.lock", "packed-refs.lock", "shallow.lock"];
59
61
  const mirrorComparisonCache = /* @__PURE__ */ new Map();
60
62
  function gitArgs(input, args) {
61
- return input.authHeader ? ["git", "-c", `http.extraHeader=${input.authHeader}`, ...args] : ["git", ...args];
63
+ const configArgs = ["-c", "submodule.recurse=false", "-c", "fetch.recurseSubmodules=false", "-c", "push.recurseSubmodules=false"];
64
+ return input.authHeader ? ["git", ...configArgs, "-c", `http.extraHeader=${input.authHeader}`, ...args] : ["git", ...configArgs, ...args];
65
+ }
66
+ function withoutSubmoduleRecursion(args) {
67
+ 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;
62
68
  }
63
69
  function runGitResult(input, cwd, args) {
64
- const result = Bun.spawnSync(gitArgs(input, args), {
70
+ const result = Bun.spawnSync(gitArgs(input, withoutSubmoduleRecursion(args)), {
65
71
  cwd,
66
72
  stdout: "pipe",
67
73
  stderr: "pipe",
@@ -89,6 +95,35 @@ function tryGit(input, cwd, args) {
89
95
  function encodeWorkspaceBranch(branchName) {
90
96
  return encodeURIComponent(branchName);
91
97
  }
98
+ function workspaceProjectsForSync(projects, trigger) {
99
+ if (trigger.canonicalCheckoutOnly) {
100
+ if (!trigger.projectId || !trigger.branchName) {
101
+ throw new Error("A canonical-checkout-only synchronization requires projectId and branchName");
102
+ }
103
+ const project = projects.find((candidate) => candidate.projectId === trigger.projectId);
104
+ const checkout = project?.canonicalCheckouts.find((candidate) => candidate.branchName === trigger.branchName);
105
+ if (!project || !project.branches.includes(trigger.branchName) || !checkout) {
106
+ throw new Error(`Canonical resolver checkout ${trigger.projectId}/${trigger.branchName} is not present in this worker manifest`);
107
+ }
108
+ return [
109
+ {
110
+ ...project,
111
+ branches: [trigger.branchName],
112
+ canonicalCheckouts: [checkout]
113
+ }
114
+ ];
115
+ }
116
+ return projects.flatMap((project) => {
117
+ const canonicalBranches = new Set(project.canonicalCheckouts.map((checkout) => checkout.branchName));
118
+ const branches = project.branches.filter((branchName) => !canonicalBranches.has(branchName));
119
+ return branches.length > 0 || project.canonicalCheckouts.length > 0 ? [
120
+ {
121
+ ...project,
122
+ branches
123
+ }
124
+ ] : [];
125
+ });
126
+ }
92
127
  function workspaceProjectBranchRelativePath(projectId, branchName) {
93
128
  return import_node_path.default.posix.join("projects", projectId, "branches", encodeWorkspaceBranch(branchName));
94
129
  }
@@ -113,12 +148,30 @@ function listGitEligibleFiles(checkoutPath) {
113
148
  if (!import_node_fs.default.existsSync(import_node_path.default.join(checkoutPath, ".git"))) {
114
149
  return [];
115
150
  }
116
- const result = Bun.spawnSync(["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], {
117
- cwd: checkoutPath,
118
- stdout: "pipe",
119
- stderr: "pipe",
120
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
121
- });
151
+ const result = Bun.spawnSync(
152
+ [
153
+ "git",
154
+ "-c",
155
+ "submodule.recurse=false",
156
+ "-c",
157
+ "fetch.recurseSubmodules=false",
158
+ "-c",
159
+ "push.recurseSubmodules=false",
160
+ "ls-files",
161
+ "-z",
162
+ "--cached",
163
+ "--others",
164
+ "--exclude-standard",
165
+ "--",
166
+ "."
167
+ ],
168
+ {
169
+ cwd: checkoutPath,
170
+ stdout: "pipe",
171
+ stderr: "pipe",
172
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
173
+ }
174
+ );
122
175
  if (result.exitCode !== 0) {
123
176
  throw new Error(`inspect eligible project files: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
124
177
  }
@@ -131,9 +184,144 @@ function listGitEligibleFiles(checkoutPath) {
131
184
  }
132
185
  }).sort();
133
186
  }
187
+ function runCheckoutGitRaw(checkoutPath, args, action) {
188
+ const result = Bun.spawnSync(
189
+ ["git", "-c", "submodule.recurse=false", "-c", "fetch.recurseSubmodules=false", "-c", "push.recurseSubmodules=false", ...args],
190
+ {
191
+ cwd: checkoutPath,
192
+ stdout: "pipe",
193
+ stderr: "pipe",
194
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
195
+ }
196
+ );
197
+ if (result.exitCode !== 0) {
198
+ throw new Error(`${action}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
199
+ }
200
+ return result.stdout.toString();
201
+ }
202
+ function stagedCheckoutPaths(checkoutPath) {
203
+ return runCheckoutGitRaw(
204
+ checkoutPath,
205
+ ["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD"],
206
+ "inspect staged canonical resolver paths"
207
+ ).split("\0").filter(Boolean).sort();
208
+ }
209
+ function parseGitIndexEntries(output) {
210
+ return output.split("\0").filter(Boolean).flatMap((entry) => {
211
+ const separator = entry.indexOf(" ");
212
+ if (separator === -1) return [];
213
+ const [mode, objectId, stage] = entry.slice(0, separator).split(" ");
214
+ const filePath = entry.slice(separator + 1);
215
+ return mode && objectId && stage && filePath ? [{ mode, objectId, stage, filePath }] : [];
216
+ });
217
+ }
218
+ function indexEntriesForPath(checkoutPath, filePath) {
219
+ return parseGitIndexEntries(
220
+ runCheckoutGitRaw(
221
+ checkoutPath,
222
+ ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", filePath],
223
+ `inspect canonical resolver index entry for ${JSON.stringify(filePath)}`
224
+ )
225
+ );
226
+ }
227
+ function checkoutIndexEntries(checkoutPath) {
228
+ return parseGitIndexEntries(runCheckoutGitRaw(checkoutPath, ["ls-files", "--stage", "-z", "--", "."], "inspect project Git index"));
229
+ }
230
+ function checkoutGitlinks(checkoutPath) {
231
+ const entries = checkoutIndexEntries(checkoutPath);
232
+ const unmergedPaths = [...new Set(entries.filter((entry) => entry.stage !== "0").map((entry) => entry.filePath))].sort();
233
+ if (unmergedPaths.length > 0) {
234
+ throw new Error(`Project checkout has unmerged Git index stages: ${JSON.stringify(unmergedPaths)}`);
235
+ }
236
+ return entries.filter((entry) => entry.mode === "160000").map(({ filePath, objectId }) => ({ filePath, objectId })).sort((left, right) => left.filePath.localeCompare(right.filePath));
237
+ }
238
+ function revisionGitlinks(checkoutPath, revision) {
239
+ const output = runCheckoutGitRaw(checkoutPath, ["ls-tree", "-r", "-z", revision, "--", "."], `inspect ${revision} project gitlinks`);
240
+ return output.split("\0").filter(Boolean).flatMap((entry) => {
241
+ const separator = entry.indexOf(" ");
242
+ if (separator === -1) return [];
243
+ const [mode, type, objectId] = entry.slice(0, separator).split(" ");
244
+ const filePath = entry.slice(separator + 1);
245
+ return mode === "160000" && type === "commit" && objectId && filePath ? [{ filePath, objectId }] : [];
246
+ }).sort((left, right) => left.filePath.localeCompare(right.filePath));
247
+ }
248
+ function stagedDeletedGitlinkPaths(checkoutPath) {
249
+ const currentEntries = checkoutIndexEntries(checkoutPath);
250
+ return revisionGitlinks(checkoutPath, "HEAD").filter(
251
+ (entry) => !currentEntries.some((candidate) => candidate.filePath === entry.filePath || candidate.filePath.startsWith(`${entry.filePath}/`))
252
+ ).map((entry) => entry.filePath).sort();
253
+ }
254
+ function lstatOrNull(filePath) {
255
+ try {
256
+ return import_node_fs.default.lstatSync(filePath);
257
+ } catch (error) {
258
+ if (error.code === "ENOENT") return null;
259
+ throw error;
260
+ }
261
+ }
262
+ function worktreeDiffersFromIndex(checkoutPath, filePath) {
263
+ const result = Bun.spawnSync(
264
+ ["git", "--literal-pathspecs", "-c", "core.fileMode=true", "diff", "--quiet", "--no-ext-diff", "--", filePath],
265
+ {
266
+ cwd: checkoutPath,
267
+ stdout: "ignore",
268
+ stderr: "pipe",
269
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
270
+ }
271
+ );
272
+ if (result.exitCode === 0) return false;
273
+ if (result.exitCode === 1) return true;
274
+ throw new Error(
275
+ `compare canonical resolver index and worktree for ${JSON.stringify(filePath)}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`
276
+ );
277
+ }
278
+ function indexEntryTypeMatchesWorktree(entry, stat) {
279
+ if (entry.mode === "120000") return stat.isSymbolicLink();
280
+ if (entry.mode !== "100644" && entry.mode !== "100755") return false;
281
+ if (!stat.isFile()) return false;
282
+ return entry.mode === "100755" === ((stat.mode & 73) !== 0);
283
+ }
284
+ function assertCanonicalCheckoutIndexMatchesWorktree(checkoutPath) {
285
+ checkoutGitlinks(checkoutPath);
286
+ const mismatchedPaths = [];
287
+ for (const filePath of stagedCheckoutPaths(checkoutPath)) {
288
+ const entries = indexEntriesForPath(checkoutPath, filePath);
289
+ const worktreeEntry = lstatOrNull(import_node_path.default.join(checkoutPath, ...filePath.split("/")));
290
+ if (entries.length === 0) {
291
+ if (worktreeEntry && !worktreeEntry.isDirectory()) mismatchedPaths.push(filePath);
292
+ continue;
293
+ }
294
+ if (entries.length === 1 && entries[0]?.stage === "0" && entries[0].mode === "160000") {
295
+ if (worktreeEntry && !worktreeEntry.isDirectory()) mismatchedPaths.push(filePath);
296
+ continue;
297
+ }
298
+ if (entries.length !== 1 || entries[0]?.stage !== "0" || !worktreeEntry || !indexEntryTypeMatchesWorktree(entries[0], worktreeEntry) || worktreeDiffersFromIndex(checkoutPath, filePath)) {
299
+ mismatchedPaths.push(filePath);
300
+ }
301
+ }
302
+ if (mismatchedPaths.length > 0) {
303
+ throw new Error(
304
+ `Canonical resolver checkout has staged Git index state that is not represented by its worktree: ${JSON.stringify(
305
+ mismatchedPaths
306
+ )}. Make the worktree match the index or unstage these paths before synchronizing.`
307
+ );
308
+ }
309
+ }
134
310
  function checkoutGitSnapshot(checkoutPath) {
135
311
  const gitPaths = Bun.spawnSync(
136
- ["git", "rev-parse", "--git-path", "index", ...ACTIVE_GIT_LOCK_PATHS.flatMap((lock) => ["--git-path", lock])],
312
+ [
313
+ "git",
314
+ "-c",
315
+ "submodule.recurse=false",
316
+ "-c",
317
+ "fetch.recurseSubmodules=false",
318
+ "-c",
319
+ "push.recurseSubmodules=false",
320
+ "rev-parse",
321
+ "--git-path",
322
+ "index",
323
+ ...ACTIVE_GIT_LOCK_PATHS.flatMap((lock) => ["--git-path", lock])
324
+ ],
137
325
  {
138
326
  cwd: checkoutPath,
139
327
  stdout: "pipe",
@@ -146,12 +334,26 @@ function checkoutGitSnapshot(checkoutPath) {
146
334
  if (!indexPath || lockPaths.length !== ACTIVE_GIT_LOCK_PATHS.length || lockPaths.some((lockPath) => import_node_fs.default.existsSync(lockPath))) {
147
335
  return null;
148
336
  }
149
- const head = Bun.spawnSync(["git", "rev-parse", "--verify", "HEAD"], {
150
- cwd: checkoutPath,
151
- stdout: "pipe",
152
- stderr: "ignore",
153
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
154
- });
337
+ const head = Bun.spawnSync(
338
+ [
339
+ "git",
340
+ "-c",
341
+ "submodule.recurse=false",
342
+ "-c",
343
+ "fetch.recurseSubmodules=false",
344
+ "-c",
345
+ "push.recurseSubmodules=false",
346
+ "rev-parse",
347
+ "--verify",
348
+ "HEAD"
349
+ ],
350
+ {
351
+ cwd: checkoutPath,
352
+ stdout: "pipe",
353
+ stderr: "ignore",
354
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
355
+ }
356
+ );
155
357
  let indexSignature = "missing";
156
358
  try {
157
359
  const stat = import_node_fs.default.statSync(indexPath);
@@ -167,7 +369,10 @@ function listShadowTrackedFiles(input, relativeRoot) {
167
369
  const prefix = `${normalizedRoot}/`;
168
370
  return output.split("\0").filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length)).filter(Boolean).sort();
169
371
  }
170
- function listFilesRecursively(root, filter) {
372
+ function pathIsWithinOpaqueRoot(relativePath, opaqueRoots) {
373
+ return opaqueRoots.some((root) => relativePath === root || relativePath.startsWith(`${root}/`));
374
+ }
375
+ function listFilesRecursively(root, filter, opaqueRoots = []) {
171
376
  if (!import_node_fs.default.existsSync(root)) return [];
172
377
  const files = [];
173
378
  const visit = (current, relativeDir) => {
@@ -175,6 +380,7 @@ function listFilesRecursively(root, filter) {
175
380
  if (entry.name === ".git") continue;
176
381
  const relativePath = relativeDir ? import_node_path.default.posix.join(relativeDir, entry.name) : entry.name;
177
382
  const absolutePath = import_node_path.default.join(current, entry.name);
383
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) continue;
178
384
  if (entry.isDirectory()) {
179
385
  visit(absolutePath, relativePath);
180
386
  } else if (!filter || filter(relativePath)) {
@@ -185,14 +391,19 @@ function listFilesRecursively(root, filter) {
185
391
  visit(root, "");
186
392
  return files.sort();
187
393
  }
188
- function removeEmptyDirectories(root) {
394
+ function removeEmptyDirectories(root, opaqueRoots = []) {
189
395
  if (!import_node_fs.default.existsSync(root)) return;
190
- const visit = (current) => {
396
+ const visit = (current, relativeDir) => {
191
397
  let empty = true;
192
398
  for (const entry of import_node_fs.default.readdirSync(current, { withFileTypes: true })) {
193
399
  const absolutePath = import_node_path.default.join(current, entry.name);
400
+ const relativePath = relativeDir ? import_node_path.default.posix.join(relativeDir, entry.name) : entry.name;
401
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) {
402
+ empty = false;
403
+ continue;
404
+ }
194
405
  if (entry.isDirectory()) {
195
- if (!visit(absolutePath)) empty = false;
406
+ if (!visit(absolutePath, relativePath)) empty = false;
196
407
  } else {
197
408
  empty = false;
198
409
  }
@@ -200,7 +411,7 @@ function removeEmptyDirectories(root) {
200
411
  if (empty && current !== root) import_node_fs.default.rmdirSync(current);
201
412
  return empty;
202
413
  };
203
- visit(root);
414
+ visit(root, "");
204
415
  }
205
416
  function copyEntry(sourceRoot, targetRoot, relativePath) {
206
417
  const sourcePath = import_node_path.default.resolve(sourceRoot, ...relativePath.split("/"));
@@ -270,56 +481,218 @@ function entriesEqual(sourcePath, targetPath) {
270
481
  }
271
482
  return equal;
272
483
  }
273
- function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles) {
484
+ function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles, opaqueTargetRoots = [], opaqueSourceRoots = []) {
274
485
  import_node_fs.default.mkdirSync(targetRoot, { recursive: true });
275
486
  const sourceSet = new Set(sourceFiles);
276
487
  for (const relativePath of targetFiles) {
488
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
277
489
  if (sourceSet.has(relativePath)) continue;
278
490
  const targetPath = import_node_path.default.resolve(targetRoot, ...relativePath.split("/"));
279
491
  assertInside(targetRoot, targetPath, "Workspace deletion path");
280
492
  import_node_fs.default.rmSync(targetPath, { recursive: true, force: true });
281
493
  }
282
494
  for (const relativePath of sourceFiles) {
495
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueSourceRoots)) continue;
496
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
283
497
  const sourcePath = import_node_path.default.resolve(sourceRoot, ...relativePath.split("/"));
284
- if (!import_node_fs.default.existsSync(sourcePath)) continue;
285
- const stat = import_node_fs.default.lstatSync(sourcePath);
498
+ const stat = lstatOrNull(sourcePath);
499
+ if (!stat) continue;
286
500
  if (!stat.isFile() && !stat.isSymbolicLink()) continue;
287
501
  const targetPath = import_node_path.default.resolve(targetRoot, ...relativePath.split("/"));
288
502
  if (entriesEqual(sourcePath, targetPath)) continue;
289
503
  copyEntry(sourceRoot, targetRoot, relativePath);
290
504
  }
291
- removeEmptyDirectories(targetRoot);
505
+ removeEmptyDirectories(targetRoot, opaqueTargetRoots);
506
+ }
507
+ function indexPathsUnderCheckoutPath(checkoutPath, filePath) {
508
+ return runCheckoutGitRaw(
509
+ checkoutPath,
510
+ ["--literal-pathspecs", "ls-files", "-z", "--", filePath],
511
+ `inspect index paths beneath ${JSON.stringify(filePath)}`
512
+ ).split("\0").filter(Boolean);
513
+ }
514
+ function replaceCheckoutIndexPathWithGitlink(checkoutPath, gitlink) {
515
+ for (const indexPath of indexPathsUnderCheckoutPath(checkoutPath, gitlink.filePath)) {
516
+ runCheckoutGitRaw(
517
+ checkoutPath,
518
+ ["--literal-pathspecs", "update-index", "--force-remove", "--", indexPath],
519
+ `remove index entry beneath gitlink ${JSON.stringify(gitlink.filePath)}`
520
+ );
521
+ }
522
+ runCheckoutGitRaw(
523
+ checkoutPath,
524
+ ["--literal-pathspecs", "update-index", "--add", "--cacheinfo", "160000", gitlink.objectId, gitlink.filePath],
525
+ `record gitlink ${JSON.stringify(gitlink.filePath)}`
526
+ );
527
+ }
528
+ function removeCheckoutIndexPath(checkoutPath, filePath) {
529
+ runCheckoutGitRaw(
530
+ checkoutPath,
531
+ ["--literal-pathspecs", "update-index", "--force-remove", "--", filePath],
532
+ `remove gitlink ${JSON.stringify(filePath)}`
533
+ );
534
+ }
535
+ function shadowProjectGitlinks(input, relativeRoot) {
536
+ const prefix = `${relativeRoot}/`;
537
+ const entries = parseGitIndexEntries(
538
+ runGit(input, input.shadowRoot, ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", relativeRoot], "inspect workspace gitlinks")
539
+ );
540
+ const unmergedPaths = [...new Set(entries.filter((entry) => entry.stage !== "0").map((entry) => entry.filePath))].sort();
541
+ if (unmergedPaths.length > 0) {
542
+ throw new Error(`Canonical workspace has unmerged Git index stages: ${JSON.stringify(unmergedPaths)}`);
543
+ }
544
+ 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));
545
+ }
546
+ function replaceShadowIndexPathWithGitlink(input, relativeRoot, gitlink) {
547
+ const workspacePath = import_node_path.default.posix.join(relativeRoot, gitlink.filePath);
548
+ const existingPaths = runGit(
549
+ input,
550
+ input.shadowRoot,
551
+ ["--literal-pathspecs", "ls-files", "-z", "--", workspacePath],
552
+ `inspect workspace index beneath gitlink ${JSON.stringify(workspacePath)}`
553
+ ).split("\0").filter(Boolean);
554
+ for (const indexPath of existingPaths) {
555
+ runGit(
556
+ input,
557
+ input.shadowRoot,
558
+ ["--literal-pathspecs", "update-index", "--force-remove", "--", indexPath],
559
+ `remove workspace index entry beneath gitlink ${JSON.stringify(workspacePath)}`
560
+ );
561
+ }
562
+ const placeholderPath = import_node_path.default.join(input.shadowRoot, ...workspacePath.split("/"));
563
+ import_node_fs.default.rmSync(placeholderPath, { recursive: true, force: true });
564
+ import_node_fs.default.mkdirSync(placeholderPath, { recursive: true });
565
+ runGit(
566
+ input,
567
+ input.shadowRoot,
568
+ ["--literal-pathspecs", "update-index", "--add", "--cacheinfo", "160000", gitlink.objectId, workspacePath],
569
+ `record workspace gitlink ${JSON.stringify(workspacePath)}`
570
+ );
571
+ }
572
+ function mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks) {
573
+ const desiredByPath = new Map(visibleGitlinks.map((entry) => [entry.filePath, entry]));
574
+ for (const current of shadowProjectGitlinks(input, relativeRoot)) {
575
+ if (desiredByPath.has(current.filePath)) continue;
576
+ const workspacePath = import_node_path.default.posix.join(relativeRoot, current.filePath);
577
+ runGit(
578
+ input,
579
+ input.shadowRoot,
580
+ ["--literal-pathspecs", "update-index", "--force-remove", "--", workspacePath],
581
+ `remove deleted workspace gitlink ${JSON.stringify(workspacePath)}`
582
+ );
583
+ import_node_fs.default.rmSync(import_node_path.default.join(input.shadowRoot, ...workspacePath.split("/")), { recursive: true, force: true });
584
+ }
585
+ for (const gitlink of visibleGitlinks) replaceShadowIndexPathWithGitlink(input, relativeRoot, gitlink);
586
+ }
587
+ function mirrorShadowGitlinksToVisible(desired, visibleRoot) {
588
+ const desiredByPath = new Map(desired.map((entry) => [entry.filePath, entry]));
589
+ for (const current of checkoutGitlinks(visibleRoot)) {
590
+ if (!desiredByPath.has(current.filePath)) removeCheckoutIndexPath(visibleRoot, current.filePath);
591
+ }
592
+ for (const gitlink of desired) {
593
+ const visiblePath = import_node_path.default.join(visibleRoot, ...gitlink.filePath.split("/"));
594
+ const existing = lstatOrNull(visiblePath);
595
+ if (existing && !existing.isDirectory()) import_node_fs.default.rmSync(visiblePath, { recursive: true, force: true });
596
+ import_node_fs.default.mkdirSync(visiblePath, { recursive: true });
597
+ replaceCheckoutIndexPathWithGitlink(visibleRoot, gitlink);
598
+ }
599
+ }
600
+ function assertSafeVisibleGitlinkTransitions(visibleRoot, currentGitlinks, desiredGitlinks, shadowFiles) {
601
+ const desiredGitlinkPaths = new Set(desiredGitlinks.map((entry) => entry.filePath));
602
+ for (const current of currentGitlinks) {
603
+ if (desiredGitlinkPaths.has(current.filePath)) continue;
604
+ const becomesOrdinaryPath = shadowFiles.some(
605
+ (filePath) => filePath === current.filePath || filePath.startsWith(`${current.filePath}/`)
606
+ );
607
+ if (!becomesOrdinaryPath) continue;
608
+ const existing = lstatOrNull(import_node_path.default.join(visibleRoot, ...current.filePath.split("/")));
609
+ if (!existing) continue;
610
+ if (existing.isDirectory() && import_node_fs.default.readdirSync(import_node_path.default.join(visibleRoot, ...current.filePath.split("/"))).length === 0) {
611
+ continue;
612
+ }
613
+ throw new Error(
614
+ `Cannot replace initialized or nonempty gitlink ${JSON.stringify(
615
+ current.filePath
616
+ )} with ordinary workspace content without deleting submodule data`
617
+ );
618
+ }
292
619
  }
293
620
  function restoreShadowProjectSnapshot(input, relativeRoot) {
294
621
  const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
295
622
  const headFiles = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", "HEAD", "--", relativeRoot]);
296
623
  if (headFiles.exitCode === 0 && headFiles.stdout) {
297
- runGit(input, input.shadowRoot, ["restore", "--source=HEAD", "--worktree", "--", relativeRoot], "restore deferred checkout scan");
624
+ runGit(
625
+ input,
626
+ input.shadowRoot,
627
+ ["restore", "--source=HEAD", "--staged", "--worktree", "--", relativeRoot],
628
+ "restore deferred checkout scan"
629
+ );
298
630
  runGit(input, input.shadowRoot, ["clean", "-fd", "--", relativeRoot], "clean deferred checkout scan");
299
631
  return;
300
632
  }
633
+ runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
301
634
  import_node_fs.default.rmSync(shadowRoot, { recursive: true, force: true });
302
635
  }
303
636
  function mirrorVisibleProjectToShadow(input, manifest, branchName) {
304
637
  const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
305
- if (!import_node_fs.default.existsSync(import_node_path.default.join(visibleRoot, ".git"))) return;
638
+ if (!import_node_fs.default.existsSync(import_node_path.default.join(visibleRoot, ".git"))) return { entries: [], opaqueRoots: [] };
639
+ const isCanonicalCheckout = manifest.canonicalCheckouts?.some((checkout) => checkout.branchName === branchName);
640
+ if (isCanonicalCheckout) {
641
+ assertCanonicalCheckoutIndexMatchesWorktree(visibleRoot);
642
+ }
306
643
  const beforeSnapshot = checkoutGitSnapshot(visibleRoot);
307
- if (!beforeSnapshot) return;
308
- const visibleFiles = listGitEligibleFiles(visibleRoot);
644
+ if (!beforeSnapshot) return { entries: [], opaqueRoots: [] };
645
+ const visibleGitlinks = checkoutGitlinks(visibleRoot);
646
+ const visibleIndexEntries = checkoutIndexEntries(visibleRoot).filter((entry) => entry.stage === "0");
647
+ const indexedGitlinkPaths = new Set(visibleGitlinks.map((entry) => entry.filePath));
648
+ const stagedDeletedGitlinkRoots = stagedDeletedGitlinkPaths(visibleRoot);
309
649
  const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
650
+ const shadowGitlinkRoots = shadowProjectGitlinks(input, relativeRoot).filter(
651
+ (entry) => !indexedGitlinkPaths.has(entry.filePath) && !visibleIndexEntries.some(
652
+ (candidate) => candidate.mode !== "160000" && (candidate.filePath === entry.filePath || candidate.filePath.startsWith(`${entry.filePath}/`))
653
+ )
654
+ ).map((entry) => entry.filePath);
655
+ const visibleGitlinkRoots = [.../* @__PURE__ */ new Set([...indexedGitlinkPaths, ...stagedDeletedGitlinkRoots, ...shadowGitlinkRoots])].sort();
656
+ const visibleFiles = listGitEligibleFiles(visibleRoot).filter((filePath) => !pathIsWithinOpaqueRoot(filePath, visibleGitlinkRoots));
310
657
  const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
311
- mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot));
658
+ mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks);
659
+ mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot), visibleGitlinkRoots, visibleGitlinkRoots);
312
660
  if (checkoutGitSnapshot(visibleRoot) !== beforeSnapshot) {
313
661
  restoreShadowProjectSnapshot(input, relativeRoot);
662
+ return { entries: [], opaqueRoots: [] };
314
663
  }
664
+ return {
665
+ entries: visibleGitlinks.map((entry) => ({
666
+ filePath: import_node_path.default.posix.join(relativeRoot, entry.filePath),
667
+ objectId: entry.objectId
668
+ })),
669
+ opaqueRoots: visibleGitlinkRoots.map((filePath) => import_node_path.default.posix.join(relativeRoot, filePath))
670
+ };
315
671
  }
316
- function mirrorShadowProjectToVisible(input, manifest, branchName) {
672
+ function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpaqueRoots = []) {
317
673
  const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
318
674
  if (!import_node_fs.default.existsSync(import_node_path.default.join(visibleRoot, ".git"))) return;
675
+ checkoutGitlinks(visibleRoot);
319
676
  const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
320
677
  const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
321
678
  const shadowFiles = listShadowTrackedFiles(input, relativeRoot);
322
- mirrorFileSet(shadowRoot, visibleRoot, shadowFiles, listGitEligibleFiles(visibleRoot));
679
+ const desiredGitlinks = shadowProjectGitlinks(input, relativeRoot);
680
+ const desiredGitlinkPaths = new Set(desiredGitlinks.map((entry) => entry.filePath));
681
+ const visibleGitlinks = checkoutGitlinks(visibleRoot);
682
+ const protectedGitlinkRoots = [
683
+ .../* @__PURE__ */ new Set([...visibleGitlinks.map((entry) => entry.filePath), ...stagedDeletedGitlinkPaths(visibleRoot), ...additionalOpaqueRoots])
684
+ ].sort();
685
+ assertSafeVisibleGitlinkTransitions(
686
+ visibleRoot,
687
+ protectedGitlinkRoots.map((filePath) => ({ filePath, objectId: "" })),
688
+ desiredGitlinks,
689
+ shadowFiles
690
+ );
691
+ const opaqueVisibleGitlinks = protectedGitlinkRoots.filter(
692
+ (filePath) => desiredGitlinkPaths.has(filePath) || !shadowFiles.some((shadowFile) => shadowFile === filePath || shadowFile.startsWith(`${filePath}/`))
693
+ );
694
+ mirrorFileSet(shadowRoot, visibleRoot, shadowFiles, listGitEligibleFiles(visibleRoot), opaqueVisibleGitlinks);
695
+ mirrorShadowGitlinksToVisible(desiredGitlinks, visibleRoot);
323
696
  }
324
697
  function mirrorLocalPlansToShadow(input, manifest, branchName) {
325
698
  const sourceRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
@@ -340,25 +713,36 @@ function mirrorShadowPlansToLocal(input, manifest, branchName) {
340
713
  listFilesRecursively(targetRoot, planFilter)
341
714
  );
342
715
  }
716
+ function mergeGitlinkProjection(target, source) {
717
+ target.entries.push(...source.entries);
718
+ target.opaqueRoots.push(...source.opaqueRoots);
719
+ }
343
720
  function mirrorVisibleWorkspaceToShadow(input, excludedCheckouts = /* @__PURE__ */ new Set()) {
721
+ const projection = { entries: [], opaqueRoots: [] };
344
722
  for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
345
723
  for (const branchName of [...new Set(manifest.branches)].sort()) {
346
724
  if (excludedCheckouts.has(`${manifest.projectId}\0${branchName}`)) continue;
347
- mirrorVisibleProjectToShadow(input, manifest, branchName);
348
- mirrorLocalPlansToShadow(input, manifest, branchName);
725
+ mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, branchName));
726
+ if (!input.trigger.canonicalCheckoutOnly) mirrorLocalPlansToShadow(input, manifest, branchName);
349
727
  }
350
728
  }
729
+ projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
730
+ return projection;
351
731
  }
352
732
  function reconcileNewVisibleCheckouts(input) {
733
+ const projection = { entries: [], opaqueRoots: [] };
353
734
  for (const target of input.newVisibleCheckouts ?? []) {
354
735
  const manifest = input.projects.find((project) => project.projectId === target.projectId);
355
736
  if (!manifest || !manifest.branches.includes(target.branchName)) continue;
356
737
  const projectRoot = workspaceProjectBranchRelativePath(target.projectId, target.branchName);
357
- if (listShadowTrackedFiles(input, projectRoot).length > 0 || restoreShadowRootFromRemote(input, projectRoot)) {
738
+ if (input.trigger.canonicalCheckoutOnly) {
739
+ mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
740
+ } else if (listShadowTrackedFiles(input, projectRoot).length > 0 || restoreShadowRootFromRemote(input, projectRoot)) {
358
741
  mirrorShadowProjectToVisible(input, manifest, target.branchName);
359
742
  } else {
360
- mirrorVisibleProjectToShadow(input, manifest, target.branchName);
743
+ mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
361
744
  }
745
+ if (input.trigger.canonicalCheckoutOnly) continue;
362
746
  const plansRoot = workspacePlansRelativePath(target.projectId, target.branchName);
363
747
  if (listShadowTrackedFiles(input, plansRoot).length > 0 || restoreShadowRootFromRemote(input, plansRoot)) {
364
748
  mirrorShadowPlansToLocal(input, manifest, target.branchName);
@@ -366,12 +750,93 @@ function reconcileNewVisibleCheckouts(input) {
366
750
  mirrorLocalPlansToShadow(input, manifest, target.branchName);
367
751
  }
368
752
  }
753
+ projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
754
+ return projection;
369
755
  }
370
- function mirrorShadowWorkspaceToVisible(input) {
756
+ function mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots = []) {
371
757
  for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
372
758
  for (const branchName of [...new Set(manifest.branches)].sort()) {
373
- mirrorShadowProjectToVisible(input, manifest, branchName);
374
- mirrorShadowPlansToLocal(input, manifest, branchName);
759
+ const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
760
+ const opaqueProjectRoots = opaqueWorkspaceRoots.filter((root) => root.startsWith(`${relativeRoot}/`)).map((root) => root.slice(relativeRoot.length + 1));
761
+ mirrorShadowProjectToVisible(input, manifest, branchName, opaqueProjectRoots);
762
+ if (!input.trigger.canonicalCheckoutOnly) mirrorShadowPlansToLocal(input, manifest, branchName);
763
+ }
764
+ }
765
+ }
766
+ function forceStageCanonicalCheckoutFiles(input, preservedGitlinks = [], opaqueRoots = []) {
767
+ for (const manifest of input.projects) {
768
+ for (const checkout of manifest.canonicalCheckouts ?? []) {
769
+ if (!manifest.branches.includes(checkout.branchName)) continue;
770
+ const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, checkout.branchName);
771
+ const absoluteRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
772
+ if (!import_node_fs.default.existsSync(absoluteRoot)) continue;
773
+ const scopedOpaqueRoots = opaqueRoots.filter((root) => root.startsWith(`${relativeRoot}/`)).map((root) => root.slice(relativeRoot.length + 1));
774
+ runGit(
775
+ input,
776
+ input.shadowRoot,
777
+ [
778
+ "add",
779
+ "-f",
780
+ "-A",
781
+ "--",
782
+ `:(literal)${relativeRoot}`,
783
+ ...scopedOpaqueRoots.map((root) => `:(exclude,literal)${import_node_path.default.posix.join(relativeRoot, root)}`)
784
+ ],
785
+ `force-stage canonical resolver checkout ${manifest.projectId}/${checkout.branchName}`
786
+ );
787
+ }
788
+ }
789
+ for (const gitlink of preservedGitlinks) {
790
+ replaceShadowIndexPathWithGitlink(input, "", gitlink);
791
+ }
792
+ const preservedPaths = new Set(preservedGitlinks.map((entry) => entry.filePath));
793
+ for (const opaqueRoot of opaqueRoots) {
794
+ if (preservedPaths.has(opaqueRoot)) continue;
795
+ runGit(
796
+ input,
797
+ input.shadowRoot,
798
+ ["--literal-pathspecs", "update-index", "--force-remove", "--", opaqueRoot],
799
+ `remove explicitly deleted workspace gitlink ${JSON.stringify(opaqueRoot)}`
800
+ );
801
+ }
802
+ }
803
+ function canonicalCheckoutRelativeRoots(input) {
804
+ const roots = /* @__PURE__ */ new Set();
805
+ for (const manifest of input.projects) {
806
+ for (const checkout of manifest.canonicalCheckouts) {
807
+ roots.add(workspaceProjectBranchRelativePath(manifest.projectId, checkout.branchName));
808
+ }
809
+ }
810
+ return [...roots].sort();
811
+ }
812
+ function restoreUnscopedCanonicalCheckoutSubtrees(input) {
813
+ if (input.trigger.canonicalCheckoutOnly) return;
814
+ const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
815
+ const emptyTree = emptyTreeHash(input);
816
+ for (const relativeRoot of canonicalCheckoutRelativeRoots(input)) {
817
+ const remoteTree = remoteHead ? workspaceSubtreeTreeHashAtRevision(input, remoteHead, relativeRoot) : emptyTree;
818
+ if (remoteTree === emptyTree) {
819
+ runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
820
+ import_node_fs.default.rmSync(import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/")), { recursive: true, force: true });
821
+ continue;
822
+ }
823
+ runGit(
824
+ input,
825
+ input.shadowRoot,
826
+ ["--literal-pathspecs", "restore", `--source=${remoteHead}`, "--staged", "--worktree", "--", relativeRoot],
827
+ `restore excluded canonical resolver subtree ${relativeRoot}`
828
+ );
829
+ }
830
+ }
831
+ function assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, revision) {
832
+ if (input.trigger.canonicalCheckoutOnly) return;
833
+ const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
834
+ const emptyTree = emptyTreeHash(input);
835
+ for (const relativeRoot of canonicalCheckoutRelativeRoots(input)) {
836
+ const candidateTree = workspaceSubtreeTreeHashAtRevision(input, revision, relativeRoot);
837
+ const remoteTree = remoteHead ? workspaceSubtreeTreeHashAtRevision(input, remoteHead, relativeRoot) : emptyTree;
838
+ if (candidateTree !== remoteTree) {
839
+ throw new Error(`Generic workspace synchronization cannot publish canonical resolver subtree ${relativeRoot}`);
375
840
  }
376
841
  }
377
842
  }
@@ -404,6 +869,64 @@ function revParse(input, revision) {
404
869
  const result = runGitResult(input, input.shadowRoot, ["rev-parse", "--verify", revision]);
405
870
  return result.exitCode === 0 ? result.stdout : null;
406
871
  }
872
+ function emptyTreeHash(input) {
873
+ const result = Bun.spawnSync(gitArgs(input, ["mktree"]), {
874
+ cwd: input.shadowRoot,
875
+ stdin: Buffer.alloc(0),
876
+ stdout: "pipe",
877
+ stderr: "pipe",
878
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
879
+ });
880
+ if (result.exitCode !== 0) {
881
+ throw new Error(`create empty workspace tree: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
882
+ }
883
+ return result.stdout.toString().trim();
884
+ }
885
+ function workspaceSubtreeTreeHashAtRevision(input, revision, relativeRoot) {
886
+ const rootType = runGitResult(input, input.shadowRoot, ["cat-file", "-t", revision]);
887
+ if (rootType.exitCode !== 0 || rootType.stdout !== "commit" && rootType.stdout !== "tree") {
888
+ throw new Error(`Cannot inspect canonical resolver subtree at invalid workspace revision ${revision}`);
889
+ }
890
+ let treeHash = rootType.stdout === "commit" ? runGit(input, input.shadowRoot, ["rev-parse", "--verify", `${revision}^{tree}`], "resolve workspace root tree") : revision;
891
+ for (const segment of relativeRoot.split("/")) {
892
+ const entry = runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "ls-tree", "-z", treeHash, "--", segment]);
893
+ if (entry.exitCode !== 0) {
894
+ throw new Error(`Inspect canonical resolver workspace path ${relativeRoot}: ${entry.stderr || entry.stdout}`);
895
+ }
896
+ if (!entry.stdout) return emptyTreeHash(input);
897
+ const separator = entry.stdout.indexOf(" ");
898
+ const [mode, type, objectId] = separator === -1 ? [] : entry.stdout.slice(0, separator).split(" ");
899
+ const entryName = separator === -1 ? "" : entry.stdout.slice(separator + 1).replace(/\0+$/, "");
900
+ if (!mode || type !== "tree" || !objectId || entryName !== segment) {
901
+ throw new Error(`Canonical resolver workspace path ${relativeRoot} contains a non-tree component ${segment}`);
902
+ }
903
+ treeHash = objectId;
904
+ }
905
+ return treeHash;
906
+ }
907
+ function canonicalCheckoutTreeHashAtRevision(input, revision) {
908
+ if (!input.trigger.canonicalCheckoutOnly || !input.trigger.projectId || !input.trigger.branchName) {
909
+ throw new Error("Canonical checkout tree hashing requires an exact canonical-checkout-only trigger");
910
+ }
911
+ return workspaceSubtreeTreeHashAtRevision(
912
+ input,
913
+ revision,
914
+ workspaceProjectBranchRelativePath(input.trigger.projectId, input.trigger.branchName)
915
+ );
916
+ }
917
+ function sampleCanonicalCheckoutTree(input) {
918
+ if (!input.trigger.canonicalCheckoutOnly) return void 0;
919
+ const stagedWorkspaceTree = runGit(input, input.shadowRoot, ["write-tree"], "write canonical resolver workspace tree");
920
+ return canonicalCheckoutTreeHashAtRevision(input, stagedWorkspaceTree);
921
+ }
922
+ function assertPublishedCanonicalCheckoutTree(input, revision, sampledTreeHash) {
923
+ if (!input.trigger.canonicalCheckoutOnly) return;
924
+ if (!sampledTreeHash) throw new Error("Canonical resolver synchronization did not capture a staged subtree tree hash");
925
+ const publishedTreeHash = canonicalCheckoutTreeHashAtRevision(input, revision);
926
+ if (publishedTreeHash !== sampledTreeHash) {
927
+ throw new Error(`Canonical resolver subtree changed while synchronizing: sampled ${sampledTreeHash}, published ${publishedTreeHash}`);
928
+ }
929
+ }
407
930
  function gitStatus(input) {
408
931
  return runGit(input, input.shadowRoot, ["status", "--porcelain=v1", "--untracked-files=all"], "read workspace status");
409
932
  }
@@ -487,8 +1010,9 @@ function revisionTrackedFileCount(input, revision, relativeRoot) {
487
1010
  return result.stdout.split("\0").filter(Boolean).length;
488
1011
  }
489
1012
  function destructiveCheckoutReductions(input, baseRevision) {
1013
+ const authoritativeRoot = input.trigger.canonicalCheckoutOnly && input.trigger.projectId && input.trigger.branchName ? workspaceProjectBranchRelativePath(input.trigger.projectId, input.trigger.branchName) : null;
490
1014
  const roots = new Set(
491
- stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root))
1015
+ stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root) && root !== authoritativeRoot)
492
1016
  );
493
1017
  const destructive = [];
494
1018
  for (const root of roots) {
@@ -563,14 +1087,14 @@ function isNonFastForward(result) {
563
1087
  ${result.stderr}`.toLowerCase();
564
1088
  return output.includes("non-fast-forward") || output.includes("fetch first") || output.includes("[rejected]");
565
1089
  }
566
- function resetShadowToRemote(input) {
1090
+ function resetShadowToRemote(input, opaqueWorkspaceRoots = []) {
567
1091
  runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch canonical workspace before reset");
568
1092
  const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
569
1093
  if (!remoteHead) return null;
570
1094
  runGitResult(input, input.shadowRoot, ["rebase", "--abort"]);
571
1095
  runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "reset workspace to canonical state");
572
1096
  runGit(input, input.shadowRoot, ["clean", "-fd"], "clean reset workspace");
573
- mirrorShadowWorkspaceToVisible(input);
1097
+ if (!input.trigger.canonicalCheckoutOnly) mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots);
574
1098
  return remoteHead;
575
1099
  }
576
1100
  function baseResult(input, startingHead) {
@@ -590,12 +1114,17 @@ function baseResult(input, startingHead) {
590
1114
  };
591
1115
  }
592
1116
  async function synchronizeWorkspace(rawInput) {
593
- const input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
1117
+ let input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
594
1118
  try {
1119
+ input = { ...input, projects: workspaceProjectsForSync(input.projects, input.trigger) };
1120
+ let mirroredGitlinkProjection = { entries: [], opaqueRoots: [] };
595
1121
  ensureShadowWorkspace(input);
596
1122
  const remoteHeadAtStart = revParse(input, `origin/${WORKSPACE_BRANCH}`);
597
1123
  const result = baseResult(input, remoteHeadAtStart);
598
1124
  if (input.resetToCanonical) {
1125
+ if (input.trigger.canonicalCheckoutOnly) {
1126
+ throw new Error("A canonical-checkout-only synchronization cannot reset the authoritative resolver checkout");
1127
+ }
599
1128
  const publishedHead = resetShadowToRemote(input);
600
1129
  return {
601
1130
  ...result,
@@ -605,14 +1134,19 @@ async function synchronizeWorkspace(rawInput) {
605
1134
  gitStatus: gitStatus(input)
606
1135
  };
607
1136
  }
1137
+ if (!input.skipVisibleMirror) resetUncommittedShadowSnapshot(input);
1138
+ restoreUnscopedCanonicalCheckoutSubtrees(input);
608
1139
  if (!input.skipVisibleMirror) {
609
- resetUncommittedShadowSnapshot(input);
610
1140
  const newCheckoutKeys = new Set((input.newVisibleCheckouts ?? []).map((target) => `${target.projectId}\0${target.branchName}`));
611
- mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
1141
+ mirroredGitlinkProjection = mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
612
1142
  fastForwardCleanShadowForNewCheckouts(input);
613
- reconcileNewVisibleCheckouts(input);
1143
+ mergeGitlinkProjection(mirroredGitlinkProjection, reconcileNewVisibleCheckouts(input));
1144
+ mirroredGitlinkProjection.opaqueRoots = [...new Set(mirroredGitlinkProjection.opaqueRoots)].sort();
614
1145
  }
615
- runGit(input, input.shadowRoot, ["add", "-A"], "stage workspace changes");
1146
+ const stagePathspecs = mirroredGitlinkProjection.opaqueRoots.flatMap((root) => [`:(exclude,literal)${root}`]);
1147
+ runGit(input, input.shadowRoot, ["add", "-A", "--", ".", ...stagePathspecs], "stage workspace changes");
1148
+ forceStageCanonicalCheckoutFiles(input, mirroredGitlinkProjection.entries, mirroredGitlinkProjection.opaqueRoots);
1149
+ const sampledCanonicalCheckoutTreeHash = sampleCanonicalCheckoutTree(input);
616
1150
  const stagedWorkingPaths = stagedPaths(input);
617
1151
  const baseRevision = candidateBaseRevision(input);
618
1152
  const paths = stagedPaths(input, baseRevision);
@@ -623,7 +1157,8 @@ async function synchronizeWorkspace(rawInput) {
623
1157
  diffSizeBytes,
624
1158
  gitStatus: statusBeforeCommit,
625
1159
  affectedPaths: reportedPaths(paths),
626
- affectedProjects: affectedProjects(paths)
1160
+ affectedProjects: affectedProjects(paths),
1161
+ ...sampledCanonicalCheckoutTreeHash ? { sampledCanonicalCheckoutTreeHash } : {}
627
1162
  };
628
1163
  const destructiveReductions = destructiveCheckoutReductions(input, baseRevision);
629
1164
  if (destructiveReductions.length > 0 && !input.confirmedLargeDiff) {
@@ -634,7 +1169,7 @@ async function synchronizeWorkspace(rawInput) {
634
1169
  error: `Workspace safety guard blocked a destructive checkout reduction: ${summary}`
635
1170
  };
636
1171
  }
637
- if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff) {
1172
+ if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff && !input.trigger.canonicalCheckoutOnly) {
638
1173
  return {
639
1174
  ...observed,
640
1175
  outcome: "large_diff_blocked",
@@ -645,6 +1180,7 @@ async function synchronizeWorkspace(rawInput) {
645
1180
  runGit(input, input.shadowRoot, ["commit", "-m", commitMessage(input)], "commit workspace changes");
646
1181
  }
647
1182
  const candidateHead = revParse(input, "HEAD");
1183
+ if (candidateHead) assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, candidateHead);
648
1184
  const hasRemoteMain = Boolean(revParse(input, `origin/${WORKSPACE_BRANCH}`));
649
1185
  const hasUnpushedCommit = candidateHead ? !hasRemoteMain || !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", candidateHead, `origin/${WORKSPACE_BRANCH}`]) : false;
650
1186
  if (!hasUnpushedCommit) {
@@ -653,7 +1189,8 @@ async function synchronizeWorkspace(rawInput) {
653
1189
  if (currentRemoteHead && localHead !== currentRemoteHead) {
654
1190
  runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "update workspace from canonical state");
655
1191
  runGit(input, input.shadowRoot, ["clean", "-fd"], "clean updated workspace");
656
- mirrorShadowWorkspaceToVisible(input);
1192
+ assertPublishedCanonicalCheckoutTree(input, currentRemoteHead, sampledCanonicalCheckoutTreeHash);
1193
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
657
1194
  return {
658
1195
  ...observed,
659
1196
  outcome: "updated",
@@ -662,7 +1199,13 @@ async function synchronizeWorkspace(rawInput) {
662
1199
  gitStatus: gitStatus(input)
663
1200
  };
664
1201
  }
665
- if (input.skipVisibleMirror) mirrorShadowWorkspaceToVisible(input);
1202
+ const authoritativeHead = currentRemoteHead ?? localHead;
1203
+ if (authoritativeHead) {
1204
+ assertPublishedCanonicalCheckoutTree(input, authoritativeHead, sampledCanonicalCheckoutTreeHash);
1205
+ }
1206
+ if (input.skipVisibleMirror) {
1207
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
1208
+ }
666
1209
  return {
667
1210
  ...observed,
668
1211
  outcome: "no_change",
@@ -676,7 +1219,8 @@ async function synchronizeWorkspace(rawInput) {
676
1219
  const push = runGitResult(input, input.shadowRoot, ["push", "origin", `HEAD:refs/heads/${WORKSPACE_BRANCH}`]);
677
1220
  if (push.exitCode === 0) {
678
1221
  const publishedHead = revParse(input, "HEAD") ?? candidateHead;
679
- mirrorShadowWorkspaceToVisible(input);
1222
+ if (publishedHead) assertPublishedCanonicalCheckoutTree(input, publishedHead, sampledCanonicalCheckoutTreeHash);
1223
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
680
1224
  return {
681
1225
  ...observed,
682
1226
  outcome: "published",
@@ -702,7 +1246,7 @@ async function synchronizeWorkspace(rawInput) {
702
1246
  if (rebase.exitCode !== 0) {
703
1247
  const conflicted = runGitResult(input, input.shadowRoot, ["diff", "--name-only", "--diff-filter=U", "-z"]);
704
1248
  const discardedPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
705
- const publishedHead = resetShadowToRemote(input);
1249
+ const publishedHead = resetShadowToRemote(input, mirroredGitlinkProjection.opaqueRoots);
706
1250
  return {
707
1251
  ...observed,
708
1252
  outcome: "conflict_reset",
@@ -714,6 +1258,8 @@ async function synchronizeWorkspace(rawInput) {
714
1258
  gitStatus: gitStatus(input)
715
1259
  };
716
1260
  }
1261
+ assertPublishedCanonicalCheckoutTree(input, "HEAD", sampledCanonicalCheckoutTreeHash);
1262
+ assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, "HEAD");
717
1263
  }
718
1264
  return {
719
1265
  ...observed,
@@ -732,13 +1278,27 @@ async function synchronizeWorkspace(rawInput) {
732
1278
  };
733
1279
  }
734
1280
  }
735
- function calculateWorkspaceDiffFingerprint(input) {
1281
+ function calculateWorkspaceDiffFingerprint(rawInput) {
1282
+ const input = {
1283
+ ...rawInput,
1284
+ projects: workspaceProjectsForSync(rawInput.projects, rawInput.trigger)
1285
+ };
736
1286
  ensureShadowWorkspace(input);
1287
+ let gitlinkProjection = { entries: [], opaqueRoots: [] };
737
1288
  if (!input.skipVisibleMirror) {
738
1289
  resetUncommittedShadowSnapshot(input);
739
- mirrorVisibleWorkspaceToShadow(input);
1290
+ restoreUnscopedCanonicalCheckoutSubtrees(input);
1291
+ gitlinkProjection = mirrorVisibleWorkspaceToShadow(input);
1292
+ } else {
1293
+ restoreUnscopedCanonicalCheckoutSubtrees(input);
740
1294
  }
741
- runGit(input, input.shadowRoot, ["add", "-A"], "stage workspace fingerprint");
1295
+ runGit(
1296
+ input,
1297
+ input.shadowRoot,
1298
+ ["add", "-A", "--", ".", ...gitlinkProjection.opaqueRoots.map((root) => `:(exclude,literal)${root}`)],
1299
+ "stage workspace fingerprint"
1300
+ );
1301
+ forceStageCanonicalCheckoutFiles(input, gitlinkProjection.entries, gitlinkProjection.opaqueRoots);
742
1302
  const stagedTree = runGit(input, input.shadowRoot, ["write-tree"], "write workspace fingerprint tree");
743
1303
  const headTree = revParse(input, "HEAD^{tree}");
744
1304
  const localHead = revParse(input, "HEAD");
@@ -780,6 +1340,7 @@ class WorkspaceSyncSingleFlight {
780
1340
  WORKSPACE_BRANCH,
781
1341
  WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
782
1342
  WorkspaceSyncSingleFlight,
1343
+ assertCanonicalCheckoutIndexMatchesWorktree,
783
1344
  calculateWorkspaceDiffFingerprint,
784
1345
  encodeWorkspaceBranch,
785
1346
  mirrorShadowWorkspaceToVisible,
@@ -787,5 +1348,6 @@ class WorkspaceSyncSingleFlight {
787
1348
  synchronizeWorkspace,
788
1349
  visibleProjectBranchPath,
789
1350
  workspacePlansRelativePath,
790
- workspaceProjectBranchRelativePath
1351
+ workspaceProjectBranchRelativePath,
1352
+ workspaceProjectsForSync
791
1353
  });