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