@ricsam/r5d-worker 0.0.45 → 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");
@@ -53,12 +55,19 @@ const MAX_REPORTED_WORKSPACE_PATHS = 2e3;
53
55
  const MAX_REPORTED_WORKSPACE_STATUS_BYTES = 256 * 1024;
54
56
  const MAX_MIRROR_COMPARISON_CACHE_ENTRIES = 2e5;
55
57
  const MIRROR_COMPARE_BUFFER_BYTES = 64 * 1024;
58
+ const DESTRUCTIVE_CHECKOUT_MIN_TRACKED_FILES = 20;
59
+ const DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO = 0.8;
60
+ const ACTIVE_GIT_LOCK_PATHS = ["index.lock", "HEAD.lock", "packed-refs.lock", "shallow.lock"];
56
61
  const mirrorComparisonCache = /* @__PURE__ */ new Map();
57
62
  function gitArgs(input, args) {
58
- 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;
59
68
  }
60
69
  function runGitResult(input, cwd, args) {
61
- const result = Bun.spawnSync(gitArgs(input, args), {
70
+ const result = Bun.spawnSync(gitArgs(input, withoutSubmoduleRecursion(args)), {
62
71
  cwd,
63
72
  stdout: "pipe",
64
73
  stderr: "pipe",
@@ -86,6 +95,35 @@ function tryGit(input, cwd, args) {
86
95
  function encodeWorkspaceBranch(branchName) {
87
96
  return encodeURIComponent(branchName);
88
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
+ }
89
127
  function workspaceProjectBranchRelativePath(projectId, branchName) {
90
128
  return import_node_path.default.posix.join("projects", projectId, "branches", encodeWorkspaceBranch(branchName));
91
129
  }
@@ -110,12 +148,30 @@ function listGitEligibleFiles(checkoutPath) {
110
148
  if (!import_node_fs.default.existsSync(import_node_path.default.join(checkoutPath, ".git"))) {
111
149
  return [];
112
150
  }
113
- const result = Bun.spawnSync(["git", "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", "."], {
114
- cwd: checkoutPath,
115
- stdout: "pipe",
116
- stderr: "pipe",
117
- env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
118
- });
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
+ );
119
175
  if (result.exitCode !== 0) {
120
176
  throw new Error(`inspect eligible project files: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
121
177
  }
@@ -128,13 +184,195 @@ function listGitEligibleFiles(checkoutPath) {
128
184
  }
129
185
  }).sort();
130
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
+ }
310
+ function checkoutGitSnapshot(checkoutPath) {
311
+ const gitPaths = Bun.spawnSync(
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
+ ],
325
+ {
326
+ cwd: checkoutPath,
327
+ stdout: "pipe",
328
+ stderr: "ignore",
329
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
330
+ }
331
+ );
332
+ if (gitPaths.exitCode !== 0) return null;
333
+ const [indexPath, ...lockPaths] = gitPaths.stdout.toString().trim().split(/\r?\n/).map((gitPath) => import_node_path.default.resolve(checkoutPath, gitPath));
334
+ if (!indexPath || lockPaths.length !== ACTIVE_GIT_LOCK_PATHS.length || lockPaths.some((lockPath) => import_node_fs.default.existsSync(lockPath))) {
335
+ return null;
336
+ }
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
+ );
357
+ let indexSignature = "missing";
358
+ try {
359
+ const stat = import_node_fs.default.statSync(indexPath);
360
+ indexSignature = entrySignature(stat);
361
+ } catch {
362
+ }
363
+ if (lockPaths.some((lockPath) => import_node_fs.default.existsSync(lockPath))) return null;
364
+ return `${head.exitCode === 0 ? head.stdout.toString().trim() : "unborn"}\0${indexSignature}`;
365
+ }
131
366
  function listShadowTrackedFiles(input, relativeRoot) {
132
367
  const normalizedRoot = relativeRoot.split(import_node_path.default.sep).join("/").replace(/^\/+|\/+$/g, "");
133
368
  const output = runGit(input, input.shadowRoot, ["ls-files", "-z", "--cached", "--", normalizedRoot], "list canonical workspace files");
134
369
  const prefix = `${normalizedRoot}/`;
135
370
  return output.split("\0").filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length)).filter(Boolean).sort();
136
371
  }
137
- 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 = []) {
138
376
  if (!import_node_fs.default.existsSync(root)) return [];
139
377
  const files = [];
140
378
  const visit = (current, relativeDir) => {
@@ -142,6 +380,7 @@ function listFilesRecursively(root, filter) {
142
380
  if (entry.name === ".git") continue;
143
381
  const relativePath = relativeDir ? import_node_path.default.posix.join(relativeDir, entry.name) : entry.name;
144
382
  const absolutePath = import_node_path.default.join(current, entry.name);
383
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) continue;
145
384
  if (entry.isDirectory()) {
146
385
  visit(absolutePath, relativePath);
147
386
  } else if (!filter || filter(relativePath)) {
@@ -152,14 +391,19 @@ function listFilesRecursively(root, filter) {
152
391
  visit(root, "");
153
392
  return files.sort();
154
393
  }
155
- function removeEmptyDirectories(root) {
394
+ function removeEmptyDirectories(root, opaqueRoots = []) {
156
395
  if (!import_node_fs.default.existsSync(root)) return;
157
- const visit = (current) => {
396
+ const visit = (current, relativeDir) => {
158
397
  let empty = true;
159
398
  for (const entry of import_node_fs.default.readdirSync(current, { withFileTypes: true })) {
160
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
+ }
161
405
  if (entry.isDirectory()) {
162
- if (!visit(absolutePath)) empty = false;
406
+ if (!visit(absolutePath, relativePath)) empty = false;
163
407
  } else {
164
408
  empty = false;
165
409
  }
@@ -167,7 +411,7 @@ function removeEmptyDirectories(root) {
167
411
  if (empty && current !== root) import_node_fs.default.rmdirSync(current);
168
412
  return empty;
169
413
  };
170
- visit(root);
414
+ visit(root, "");
171
415
  }
172
416
  function copyEntry(sourceRoot, targetRoot, relativePath) {
173
417
  const sourcePath = import_node_path.default.resolve(sourceRoot, ...relativePath.split("/"));
@@ -237,39 +481,218 @@ function entriesEqual(sourcePath, targetPath) {
237
481
  }
238
482
  return equal;
239
483
  }
240
- function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles) {
484
+ function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles, opaqueTargetRoots = [], opaqueSourceRoots = []) {
241
485
  import_node_fs.default.mkdirSync(targetRoot, { recursive: true });
242
486
  const sourceSet = new Set(sourceFiles);
243
487
  for (const relativePath of targetFiles) {
488
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
244
489
  if (sourceSet.has(relativePath)) continue;
245
490
  const targetPath = import_node_path.default.resolve(targetRoot, ...relativePath.split("/"));
246
491
  assertInside(targetRoot, targetPath, "Workspace deletion path");
247
492
  import_node_fs.default.rmSync(targetPath, { recursive: true, force: true });
248
493
  }
249
494
  for (const relativePath of sourceFiles) {
495
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueSourceRoots)) continue;
496
+ if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
250
497
  const sourcePath = import_node_path.default.resolve(sourceRoot, ...relativePath.split("/"));
251
- if (!import_node_fs.default.existsSync(sourcePath)) continue;
252
- const stat = import_node_fs.default.lstatSync(sourcePath);
498
+ const stat = lstatOrNull(sourcePath);
499
+ if (!stat) continue;
253
500
  if (!stat.isFile() && !stat.isSymbolicLink()) continue;
254
501
  const targetPath = import_node_path.default.resolve(targetRoot, ...relativePath.split("/"));
255
502
  if (entriesEqual(sourcePath, targetPath)) continue;
256
503
  copyEntry(sourceRoot, targetRoot, relativePath);
257
504
  }
258
- 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
+ }
619
+ }
620
+ function restoreShadowProjectSnapshot(input, relativeRoot) {
621
+ const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
622
+ const headFiles = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", "HEAD", "--", relativeRoot]);
623
+ if (headFiles.exitCode === 0 && headFiles.stdout) {
624
+ runGit(
625
+ input,
626
+ input.shadowRoot,
627
+ ["restore", "--source=HEAD", "--staged", "--worktree", "--", relativeRoot],
628
+ "restore deferred checkout scan"
629
+ );
630
+ runGit(input, input.shadowRoot, ["clean", "-fd", "--", relativeRoot], "clean deferred checkout scan");
631
+ return;
632
+ }
633
+ runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
634
+ import_node_fs.default.rmSync(shadowRoot, { recursive: true, force: true });
259
635
  }
260
636
  function mirrorVisibleProjectToShadow(input, manifest, branchName) {
261
637
  const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
262
- if (!import_node_fs.default.existsSync(import_node_path.default.join(visibleRoot, ".git"))) return;
263
- const shadowRoot = import_node_path.default.join(input.shadowRoot, ...workspaceProjectBranchRelativePath(manifest.projectId, branchName).split("/"));
264
- mirrorFileSet(visibleRoot, shadowRoot, listGitEligibleFiles(visibleRoot), listFilesRecursively(shadowRoot));
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
+ }
643
+ const beforeSnapshot = checkoutGitSnapshot(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);
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));
657
+ const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
658
+ mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks);
659
+ mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot), visibleGitlinkRoots, visibleGitlinkRoots);
660
+ if (checkoutGitSnapshot(visibleRoot) !== beforeSnapshot) {
661
+ restoreShadowProjectSnapshot(input, relativeRoot);
662
+ return { entries: [], opaqueRoots: [] };
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
+ };
265
671
  }
266
- function mirrorShadowProjectToVisible(input, manifest, branchName) {
672
+ function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpaqueRoots = []) {
267
673
  const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
268
674
  if (!import_node_fs.default.existsSync(import_node_path.default.join(visibleRoot, ".git"))) return;
675
+ checkoutGitlinks(visibleRoot);
269
676
  const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
270
677
  const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
271
678
  const shadowFiles = listShadowTrackedFiles(input, relativeRoot);
272
- 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);
273
696
  }
274
697
  function mirrorLocalPlansToShadow(input, manifest, branchName) {
275
698
  const sourceRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
@@ -290,60 +713,130 @@ function mirrorShadowPlansToLocal(input, manifest, branchName) {
290
713
  listFilesRecursively(targetRoot, planFilter)
291
714
  );
292
715
  }
293
- function pruneDirectoryChildren(root, allowedNames) {
294
- if (!import_node_fs.default.existsSync(root)) return;
295
- for (const entry of import_node_fs.default.readdirSync(root)) {
296
- if (allowedNames.has(entry)) continue;
297
- const target = import_node_path.default.resolve(root, entry);
298
- assertInside(root, target, "Workspace manifest prune path");
299
- import_node_fs.default.rmSync(target, { recursive: true, force: true });
300
- }
301
- }
302
- function pruneShadowToManifest(input) {
303
- const projectIds = new Set(input.projects.map((project) => project.projectId));
304
- const projectsRoot = import_node_path.default.join(input.shadowRoot, "projects");
305
- const plansRoot = import_node_path.default.join(input.shadowRoot, "plans");
306
- pruneDirectoryChildren(projectsRoot, projectIds);
307
- pruneDirectoryChildren(plansRoot, projectIds);
308
- for (const manifest of input.projects) {
309
- const encodedBranches = new Set(manifest.branches.map(encodeWorkspaceBranch));
310
- pruneDirectoryChildren(import_node_path.default.join(projectsRoot, manifest.projectId, "branches"), encodedBranches);
311
- pruneDirectoryChildren(import_node_path.default.join(plansRoot, manifest.projectId), encodedBranches);
312
- }
716
+ function mergeGitlinkProjection(target, source) {
717
+ target.entries.push(...source.entries);
718
+ target.opaqueRoots.push(...source.opaqueRoots);
313
719
  }
314
720
  function mirrorVisibleWorkspaceToShadow(input, excludedCheckouts = /* @__PURE__ */ new Set()) {
315
- pruneShadowToManifest(input);
721
+ const projection = { entries: [], opaqueRoots: [] };
316
722
  for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
317
723
  for (const branchName of [...new Set(manifest.branches)].sort()) {
318
724
  if (excludedCheckouts.has(`${manifest.projectId}\0${branchName}`)) continue;
319
- mirrorVisibleProjectToShadow(input, manifest, branchName);
320
- mirrorLocalPlansToShadow(input, manifest, branchName);
725
+ mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, branchName));
726
+ if (!input.trigger.canonicalCheckoutOnly) mirrorLocalPlansToShadow(input, manifest, branchName);
321
727
  }
322
728
  }
729
+ projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
730
+ return projection;
323
731
  }
324
732
  function reconcileNewVisibleCheckouts(input) {
733
+ const projection = { entries: [], opaqueRoots: [] };
325
734
  for (const target of input.newVisibleCheckouts ?? []) {
326
735
  const manifest = input.projects.find((project) => project.projectId === target.projectId);
327
736
  if (!manifest || !manifest.branches.includes(target.branchName)) continue;
328
737
  const projectRoot = workspaceProjectBranchRelativePath(target.projectId, target.branchName);
329
- if (listShadowTrackedFiles(input, projectRoot).length > 0) {
738
+ if (input.trigger.canonicalCheckoutOnly) {
739
+ mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
740
+ } else if (listShadowTrackedFiles(input, projectRoot).length > 0 || restoreShadowRootFromRemote(input, projectRoot)) {
330
741
  mirrorShadowProjectToVisible(input, manifest, target.branchName);
331
742
  } else {
332
- mirrorVisibleProjectToShadow(input, manifest, target.branchName);
743
+ mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
333
744
  }
745
+ if (input.trigger.canonicalCheckoutOnly) continue;
334
746
  const plansRoot = workspacePlansRelativePath(target.projectId, target.branchName);
335
- if (listShadowTrackedFiles(input, plansRoot).length > 0) {
747
+ if (listShadowTrackedFiles(input, plansRoot).length > 0 || restoreShadowRootFromRemote(input, plansRoot)) {
336
748
  mirrorShadowPlansToLocal(input, manifest, target.branchName);
337
749
  } else {
338
750
  mirrorLocalPlansToShadow(input, manifest, target.branchName);
339
751
  }
340
752
  }
753
+ projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
754
+ return projection;
341
755
  }
342
- function mirrorShadowWorkspaceToVisible(input) {
756
+ function mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots = []) {
343
757
  for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
344
758
  for (const branchName of [...new Set(manifest.branches)].sort()) {
345
- mirrorShadowProjectToVisible(input, manifest, branchName);
346
- 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}`);
347
840
  }
348
841
  }
349
842
  }
@@ -376,14 +869,165 @@ function revParse(input, revision) {
376
869
  const result = runGitResult(input, input.shadowRoot, ["rev-parse", "--verify", revision]);
377
870
  return result.exitCode === 0 ? result.stdout : null;
378
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
+ }
379
930
  function gitStatus(input) {
380
931
  return runGit(input, input.shadowRoot, ["status", "--porcelain=v1", "--untracked-files=all"], "read workspace status");
381
932
  }
382
- function stagedPaths(input) {
383
- return runGit(input, input.shadowRoot, ["diff", "--cached", "--name-only", "-z"], "list workspace changes").split("\0").filter(Boolean).sort();
933
+ function resetUncommittedShadowSnapshot(input) {
934
+ if (!gitStatus(input)) return;
935
+ if (tryGit(input, input.shadowRoot, ["rev-parse", "--verify", "HEAD"])) {
936
+ runGit(input, input.shadowRoot, ["reset", "--hard", "HEAD"], "reset interrupted workspace snapshot");
937
+ } else {
938
+ runGit(input, input.shadowRoot, ["read-tree", "--empty"], "reset interrupted unborn workspace snapshot");
939
+ }
940
+ runGit(input, input.shadowRoot, ["clean", "-fd"], "clean interrupted workspace snapshot");
941
+ }
942
+ function fastForwardCleanShadowForNewCheckouts(input) {
943
+ if (!input.newVisibleCheckouts?.length) return;
944
+ runGit(input, input.shadowRoot, ["add", "-A"], "stage existing workspace changes before checkout hydration");
945
+ if (gitStatus(input)) return;
946
+ const localHead = revParse(input, "HEAD");
947
+ const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
948
+ if (!localHead || !remoteHead || localHead === remoteHead) return;
949
+ if (!tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", localHead, remoteHead])) return;
950
+ runGit(input, input.shadowRoot, ["reset", "--hard", remoteHead], "fast-forward before checkout hydration");
951
+ }
952
+ function restoreShadowRootFromRemote(input, relativeRoot) {
953
+ const remoteRevision = `origin/${WORKSPACE_BRANCH}`;
954
+ const remoteFiles = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", remoteRevision, "--", relativeRoot]);
955
+ if (remoteFiles.exitCode !== 0 || !remoteFiles.stdout) return false;
956
+ runGit(
957
+ input,
958
+ input.shadowRoot,
959
+ ["restore", `--source=${remoteRevision}`, "--staged", "--worktree", "--", relativeRoot],
960
+ `hydrate ${relativeRoot} from canonical workspace`
961
+ );
962
+ return true;
963
+ }
964
+ function candidateBaseRevision(input) {
965
+ const localHead = revParse(input, "HEAD");
966
+ if (!localHead) return null;
967
+ const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
968
+ if (!remoteHead) {
969
+ const emptyTree = Bun.spawnSync(gitArgs(input, ["mktree"]), {
970
+ cwd: input.shadowRoot,
971
+ stdin: Buffer.alloc(0),
972
+ stdout: "pipe",
973
+ stderr: "pipe",
974
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
975
+ });
976
+ if (emptyTree.exitCode !== 0) {
977
+ throw new Error(
978
+ `create empty workspace comparison tree: ${emptyTree.stderr.toString().trim() || `git exited ${emptyTree.exitCode}`}`
979
+ );
980
+ }
981
+ return emptyTree.stdout.toString().trim();
982
+ }
983
+ const mergeBase = runGitResult(input, input.shadowRoot, ["merge-base", localHead, remoteHead]);
984
+ return mergeBase.exitCode === 0 && mergeBase.stdout ? mergeBase.stdout : remoteHead;
384
985
  }
385
- async function stagedDiffSizeBytes(input) {
386
- const subprocess = Bun.spawn(gitArgs(input, ["diff", "--cached", "--binary", "--no-ext-diff"]), {
986
+ function stagedPaths(input, baseRevision) {
987
+ return runGit(
988
+ input,
989
+ input.shadowRoot,
990
+ ["diff", "--cached", "--name-only", "-z", ...baseRevision ? [baseRevision] : []],
991
+ "list workspace changes"
992
+ ).split("\0").filter(Boolean).sort();
993
+ }
994
+ function stagedDeletedPaths(input, baseRevision) {
995
+ return runGit(
996
+ input,
997
+ input.shadowRoot,
998
+ ["diff", "--cached", "--diff-filter=D", "--name-only", "-z", ...baseRevision ? [baseRevision] : []],
999
+ "list workspace deletions"
1000
+ ).split("\0").filter(Boolean).sort();
1001
+ }
1002
+ function managedCheckoutRoot(filePath) {
1003
+ const projectMatch = /^projects\/([^/]+)\/branches\/([^/]+)\//.exec(filePath);
1004
+ return projectMatch ? `projects/${projectMatch[1]}/branches/${projectMatch[2]}` : null;
1005
+ }
1006
+ function revisionTrackedFileCount(input, revision, relativeRoot) {
1007
+ if (!revision) return 0;
1008
+ const result = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", revision, "--", relativeRoot]);
1009
+ if (result.exitCode !== 0) return 0;
1010
+ return result.stdout.split("\0").filter(Boolean).length;
1011
+ }
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;
1014
+ const roots = new Set(
1015
+ stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root) && root !== authoritativeRoot)
1016
+ );
1017
+ const destructive = [];
1018
+ for (const root of roots) {
1019
+ const trackedBefore = revisionTrackedFileCount(input, baseRevision, root);
1020
+ if (trackedBefore === 0) continue;
1021
+ const trackedAfter = listShadowTrackedFiles(input, root).length;
1022
+ const removedRatio = (trackedBefore - trackedAfter) / trackedBefore;
1023
+ if (trackedAfter === 0 || trackedBefore >= DESTRUCTIVE_CHECKOUT_MIN_TRACKED_FILES && removedRatio >= DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO) {
1024
+ destructive.push({ root, trackedBefore, trackedAfter });
1025
+ }
1026
+ }
1027
+ return destructive.sort((left, right) => left.root.localeCompare(right.root));
1028
+ }
1029
+ async function stagedDiffSizeBytes(input, baseRevision) {
1030
+ const subprocess = Bun.spawn(gitArgs(input, ["diff", "--cached", "--binary", "--no-ext-diff", ...baseRevision ? [baseRevision] : []]), {
387
1031
  cwd: input.shadowRoot,
388
1032
  stdout: "pipe",
389
1033
  stderr: "pipe",
@@ -443,14 +1087,14 @@ function isNonFastForward(result) {
443
1087
  ${result.stderr}`.toLowerCase();
444
1088
  return output.includes("non-fast-forward") || output.includes("fetch first") || output.includes("[rejected]");
445
1089
  }
446
- function resetShadowToRemote(input) {
1090
+ function resetShadowToRemote(input, opaqueWorkspaceRoots = []) {
447
1091
  runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch canonical workspace before reset");
448
1092
  const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
449
1093
  if (!remoteHead) return null;
450
1094
  runGitResult(input, input.shadowRoot, ["rebase", "--abort"]);
451
1095
  runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "reset workspace to canonical state");
452
1096
  runGit(input, input.shadowRoot, ["clean", "-fd"], "clean reset workspace");
453
- mirrorShadowWorkspaceToVisible(input);
1097
+ if (!input.trigger.canonicalCheckoutOnly) mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots);
454
1098
  return remoteHead;
455
1099
  }
456
1100
  function baseResult(input, startingHead) {
@@ -470,12 +1114,17 @@ function baseResult(input, startingHead) {
470
1114
  };
471
1115
  }
472
1116
  async function synchronizeWorkspace(rawInput) {
473
- const input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
1117
+ let input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
474
1118
  try {
1119
+ input = { ...input, projects: workspaceProjectsForSync(input.projects, input.trigger) };
1120
+ let mirroredGitlinkProjection = { entries: [], opaqueRoots: [] };
475
1121
  ensureShadowWorkspace(input);
476
1122
  const remoteHeadAtStart = revParse(input, `origin/${WORKSPACE_BRANCH}`);
477
1123
  const result = baseResult(input, remoteHeadAtStart);
478
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
+ }
479
1128
  const publishedHead = resetShadowToRemote(input);
480
1129
  return {
481
1130
  ...result,
@@ -485,29 +1134,53 @@ async function synchronizeWorkspace(rawInput) {
485
1134
  gitStatus: gitStatus(input)
486
1135
  };
487
1136
  }
1137
+ if (!input.skipVisibleMirror) resetUncommittedShadowSnapshot(input);
1138
+ restoreUnscopedCanonicalCheckoutSubtrees(input);
488
1139
  if (!input.skipVisibleMirror) {
489
1140
  const newCheckoutKeys = new Set((input.newVisibleCheckouts ?? []).map((target) => `${target.projectId}\0${target.branchName}`));
490
- mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
491
- reconcileNewVisibleCheckouts(input);
1141
+ mirroredGitlinkProjection = mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
1142
+ fastForwardCleanShadowForNewCheckouts(input);
1143
+ mergeGitlinkProjection(mirroredGitlinkProjection, reconcileNewVisibleCheckouts(input));
1144
+ mirroredGitlinkProjection.opaqueRoots = [...new Set(mirroredGitlinkProjection.opaqueRoots)].sort();
492
1145
  }
493
- runGit(input, input.shadowRoot, ["add", "-A"], "stage workspace changes");
494
- const paths = stagedPaths(input);
495
- const diffSizeBytes = await stagedDiffSizeBytes(input);
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);
1150
+ const stagedWorkingPaths = stagedPaths(input);
1151
+ const baseRevision = candidateBaseRevision(input);
1152
+ const paths = stagedPaths(input, baseRevision);
1153
+ const diffSizeBytes = await stagedDiffSizeBytes(input, baseRevision);
496
1154
  const statusBeforeCommit = reportedStatus(gitStatus(input));
497
1155
  const observed = {
498
1156
  ...result,
499
1157
  diffSizeBytes,
500
1158
  gitStatus: statusBeforeCommit,
501
1159
  affectedPaths: reportedPaths(paths),
502
- affectedProjects: affectedProjects(paths)
1160
+ affectedProjects: affectedProjects(paths),
1161
+ ...sampledCanonicalCheckoutTreeHash ? { sampledCanonicalCheckoutTreeHash } : {}
503
1162
  };
504
- if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff) {
505
- return { ...observed, outcome: "large_diff_blocked" };
1163
+ const destructiveReductions = destructiveCheckoutReductions(input, baseRevision);
1164
+ if (destructiveReductions.length > 0 && !input.confirmedLargeDiff) {
1165
+ const summary = destructiveReductions.map(({ root, trackedBefore, trackedAfter }) => `${root} (${trackedBefore} -> ${trackedAfter} tracked files)`).join(", ");
1166
+ return {
1167
+ ...observed,
1168
+ outcome: "large_diff_blocked",
1169
+ error: `Workspace safety guard blocked a destructive checkout reduction: ${summary}`
1170
+ };
1171
+ }
1172
+ if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff && !input.trigger.canonicalCheckoutOnly) {
1173
+ return {
1174
+ ...observed,
1175
+ outcome: "large_diff_blocked",
1176
+ error: `Workspace safety guard blocked a ${diffSizeBytes}-byte diff above the ${MAX_WORKSPACE_SYNC_DIFF_BYTES}-byte automatic publication limit.`
1177
+ };
506
1178
  }
507
- if (paths.length > 0) {
1179
+ if (stagedWorkingPaths.length > 0) {
508
1180
  runGit(input, input.shadowRoot, ["commit", "-m", commitMessage(input)], "commit workspace changes");
509
1181
  }
510
1182
  const candidateHead = revParse(input, "HEAD");
1183
+ if (candidateHead) assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, candidateHead);
511
1184
  const hasRemoteMain = Boolean(revParse(input, `origin/${WORKSPACE_BRANCH}`));
512
1185
  const hasUnpushedCommit = candidateHead ? !hasRemoteMain || !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", candidateHead, `origin/${WORKSPACE_BRANCH}`]) : false;
513
1186
  if (!hasUnpushedCommit) {
@@ -516,7 +1189,8 @@ async function synchronizeWorkspace(rawInput) {
516
1189
  if (currentRemoteHead && localHead !== currentRemoteHead) {
517
1190
  runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "update workspace from canonical state");
518
1191
  runGit(input, input.shadowRoot, ["clean", "-fd"], "clean updated workspace");
519
- mirrorShadowWorkspaceToVisible(input);
1192
+ assertPublishedCanonicalCheckoutTree(input, currentRemoteHead, sampledCanonicalCheckoutTreeHash);
1193
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
520
1194
  return {
521
1195
  ...observed,
522
1196
  outcome: "updated",
@@ -525,7 +1199,13 @@ async function synchronizeWorkspace(rawInput) {
525
1199
  gitStatus: gitStatus(input)
526
1200
  };
527
1201
  }
528
- 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
+ }
529
1209
  return {
530
1210
  ...observed,
531
1211
  outcome: "no_change",
@@ -539,7 +1219,8 @@ async function synchronizeWorkspace(rawInput) {
539
1219
  const push = runGitResult(input, input.shadowRoot, ["push", "origin", `HEAD:refs/heads/${WORKSPACE_BRANCH}`]);
540
1220
  if (push.exitCode === 0) {
541
1221
  const publishedHead = revParse(input, "HEAD") ?? candidateHead;
542
- mirrorShadowWorkspaceToVisible(input);
1222
+ if (publishedHead) assertPublishedCanonicalCheckoutTree(input, publishedHead, sampledCanonicalCheckoutTreeHash);
1223
+ mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
543
1224
  return {
544
1225
  ...observed,
545
1226
  outcome: "published",
@@ -565,7 +1246,7 @@ async function synchronizeWorkspace(rawInput) {
565
1246
  if (rebase.exitCode !== 0) {
566
1247
  const conflicted = runGitResult(input, input.shadowRoot, ["diff", "--name-only", "--diff-filter=U", "-z"]);
567
1248
  const discardedPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
568
- const publishedHead = resetShadowToRemote(input);
1249
+ const publishedHead = resetShadowToRemote(input, mirroredGitlinkProjection.opaqueRoots);
569
1250
  return {
570
1251
  ...observed,
571
1252
  outcome: "conflict_reset",
@@ -577,6 +1258,8 @@ async function synchronizeWorkspace(rawInput) {
577
1258
  gitStatus: gitStatus(input)
578
1259
  };
579
1260
  }
1261
+ assertPublishedCanonicalCheckoutTree(input, "HEAD", sampledCanonicalCheckoutTreeHash);
1262
+ assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, "HEAD");
580
1263
  }
581
1264
  return {
582
1265
  ...observed,
@@ -595,10 +1278,27 @@ async function synchronizeWorkspace(rawInput) {
595
1278
  };
596
1279
  }
597
1280
  }
598
- function calculateWorkspaceDiffFingerprint(input) {
1281
+ function calculateWorkspaceDiffFingerprint(rawInput) {
1282
+ const input = {
1283
+ ...rawInput,
1284
+ projects: workspaceProjectsForSync(rawInput.projects, rawInput.trigger)
1285
+ };
599
1286
  ensureShadowWorkspace(input);
600
- if (!input.skipVisibleMirror) mirrorVisibleWorkspaceToShadow(input);
601
- runGit(input, input.shadowRoot, ["add", "-A"], "stage workspace fingerprint");
1287
+ let gitlinkProjection = { entries: [], opaqueRoots: [] };
1288
+ if (!input.skipVisibleMirror) {
1289
+ resetUncommittedShadowSnapshot(input);
1290
+ restoreUnscopedCanonicalCheckoutSubtrees(input);
1291
+ gitlinkProjection = mirrorVisibleWorkspaceToShadow(input);
1292
+ } else {
1293
+ restoreUnscopedCanonicalCheckoutSubtrees(input);
1294
+ }
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);
602
1302
  const stagedTree = runGit(input, input.shadowRoot, ["write-tree"], "write workspace fingerprint tree");
603
1303
  const headTree = revParse(input, "HEAD^{tree}");
604
1304
  const localHead = revParse(input, "HEAD");
@@ -610,27 +1310,25 @@ function calculateWorkspaceDiffFingerprint(input) {
610
1310
  }
611
1311
  class WorkspaceSyncSingleFlight {
612
1312
  queue = Promise.resolve();
613
- run(input) {
614
- const queued = this.queue.then(
615
- () => synchronizeWorkspace(input),
616
- () => synchronizeWorkspace(input)
617
- );
1313
+ enqueue(task) {
1314
+ const queued = this.queue.then(task, task);
618
1315
  this.queue = queued.then(
619
1316
  () => void 0,
620
1317
  () => void 0
621
1318
  );
622
1319
  return queued;
623
1320
  }
1321
+ run(input) {
1322
+ return this.enqueue(() => synchronizeWorkspace(input));
1323
+ }
1324
+ runPrepared(prepare) {
1325
+ return this.enqueue(() => synchronizeWorkspace(prepare()));
1326
+ }
624
1327
  fingerprint(input) {
625
- const queued = this.queue.then(
626
- () => calculateWorkspaceDiffFingerprint(input),
627
- () => calculateWorkspaceDiffFingerprint(input)
628
- );
629
- this.queue = queued.then(
630
- () => void 0,
631
- () => void 0
632
- );
633
- return queued;
1328
+ return this.enqueue(() => calculateWorkspaceDiffFingerprint(input));
1329
+ }
1330
+ fingerprintPrepared(prepare) {
1331
+ return this.enqueue(() => calculateWorkspaceDiffFingerprint(prepare()));
634
1332
  }
635
1333
  afterCurrent() {
636
1334
  return this.queue;
@@ -642,6 +1340,7 @@ class WorkspaceSyncSingleFlight {
642
1340
  WORKSPACE_BRANCH,
643
1341
  WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
644
1342
  WorkspaceSyncSingleFlight,
1343
+ assertCanonicalCheckoutIndexMatchesWorktree,
645
1344
  calculateWorkspaceDiffFingerprint,
646
1345
  encodeWorkspaceBranch,
647
1346
  mirrorShadowWorkspaceToVisible,
@@ -649,5 +1348,6 @@ class WorkspaceSyncSingleFlight {
649
1348
  synchronizeWorkspace,
650
1349
  visibleProjectBranchPath,
651
1350
  workspacePlansRelativePath,
652
- workspaceProjectBranchRelativePath
1351
+ workspaceProjectBranchRelativePath,
1352
+ workspaceProjectsForSync
653
1353
  });