@ricsam/r5d-worker 0.0.100 → 0.0.103

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.
Files changed (33) hide show
  1. package/README.md +16 -3
  2. package/dist/cjs/main.cjs +390 -322
  3. package/dist/cjs/package.json +1 -1
  4. package/dist/cjs/project-mirror-fetch-policy.cjs +48 -7
  5. package/dist/cjs/project-mirror-refs-token.cjs +36 -2
  6. package/dist/cjs/project-worktrees.cjs +49 -32
  7. package/dist/cjs/working-tree-mirror.cjs +347 -99
  8. package/dist/cjs/workspace-git-sync.cjs +189 -28
  9. package/dist/cjs/workspace-merge-projection.cjs +5 -19
  10. package/dist/cjs/workspace-mutation-gate.cjs +35 -0
  11. package/dist/cjs/workspace-projection-ledger.cjs +81 -0
  12. package/dist/cjs/workspace-sync-coalescer.cjs +58 -0
  13. package/dist/mjs/main.mjs +395 -324
  14. package/dist/mjs/package.json +1 -1
  15. package/dist/mjs/project-mirror-fetch-policy.mjs +46 -6
  16. package/dist/mjs/project-mirror-refs-token.mjs +30 -1
  17. package/dist/mjs/project-worktrees.mjs +52 -33
  18. package/dist/mjs/working-tree-mirror.mjs +347 -99
  19. package/dist/mjs/workspace-git-sync.mjs +189 -28
  20. package/dist/mjs/workspace-merge-projection.mjs +5 -19
  21. package/dist/mjs/workspace-mutation-gate.mjs +35 -0
  22. package/dist/mjs/workspace-projection-ledger.mjs +57 -0
  23. package/dist/mjs/workspace-sync-coalescer.mjs +34 -0
  24. package/dist/types/main.d.ts +29 -17
  25. package/dist/types/project-mirror-fetch-policy.d.ts +70 -15
  26. package/dist/types/project-mirror-refs-token.d.ts +34 -0
  27. package/dist/types/project-worktrees.d.ts +26 -8
  28. package/dist/types/working-tree-mirror.d.ts +56 -11
  29. package/dist/types/workspace-git-sync.d.ts +41 -4
  30. package/dist/types/workspace-mutation-gate.d.ts +24 -3
  31. package/dist/types/workspace-projection-ledger.d.ts +59 -0
  32. package/dist/types/workspace-sync-coalescer.d.ts +28 -0
  33. package/package.json +1 -1
@@ -44,6 +44,16 @@ var import_node_crypto = require("node:crypto");
44
44
  var import_git_process_environment = require("./git-process-environment.cjs");
45
45
  const HFS_IGNORED_CODE_POINTS = /[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/gu;
46
46
  const MIRROR_TEMPORARY_FILE_PREFIX = ".r5d-working-tree-mirror-";
47
+ const OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
48
+ const GITLINK_STALE_HEADS_FILE = "gitlink-heads.json";
49
+ const NON_RECURSIVE_GIT_CONFIG = [
50
+ "-c",
51
+ "submodule.recurse=false",
52
+ "-c",
53
+ "fetch.recurseSubmodules=false",
54
+ "-c",
55
+ "push.recurseSubmodules=false"
56
+ ];
47
57
  function isPortableGitMetadataSegment(segment) {
48
58
  const hfsFolded = segment.replace(HFS_IGNORED_CODE_POINTS, "").toLowerCase();
49
59
  if (hfsFolded === ".git") return true;
@@ -119,42 +129,145 @@ function inspectDirectoryRoot(root, options) {
119
129
  }
120
130
  return true;
121
131
  }
122
- function gitEligibleRoots(root) {
123
- const result = Bun.spawnSync(
124
- [
125
- "git",
126
- "-c",
127
- "submodule.recurse=false",
128
- "-c",
129
- "fetch.recurseSubmodules=false",
130
- "-c",
131
- "push.recurseSubmodules=false",
132
- "ls-files",
133
- "-z",
134
- "--cached",
135
- "--others",
136
- "--exclude-standard",
137
- "--",
138
- "."
139
- ],
140
- {
141
- cwd: root,
142
- stdout: "pipe",
143
- stderr: "pipe",
144
- env: (0, import_git_process_environment.workerGitProcessEnvironment)()
145
- }
146
- );
132
+ function lstatIfExists(targetPath) {
133
+ try {
134
+ return import_node_fs.default.lstatSync(targetPath);
135
+ } catch (error) {
136
+ if (error.code === "ENOENT") return null;
137
+ throw error;
138
+ }
139
+ }
140
+ function nulRecords(content) {
141
+ const records = [];
142
+ let start = 0;
143
+ for (let index = 0; index < content.length; index += 1) {
144
+ if (content[index] !== 0) continue;
145
+ if (index > start) records.push(content.subarray(start, index));
146
+ start = index + 1;
147
+ }
148
+ if (start < content.length) records.push(content.subarray(start));
149
+ return records;
150
+ }
151
+ function gitOutput(cwd, args, action, options = {}) {
152
+ const result = Bun.spawnSync(["git", ...NON_RECURSIVE_GIT_CONFIG, ...args], {
153
+ cwd,
154
+ ...options.stdin ? { stdin: options.stdin } : {},
155
+ stdout: "pipe",
156
+ stderr: "pipe",
157
+ env: options.environment ?? (0, import_git_process_environment.workerGitProcessEnvironment)()
158
+ });
147
159
  if (result.exitCode !== 0) {
148
- throw new Error(`Inspect Git working tree: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
160
+ throw new Error(`${action}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
149
161
  }
150
- return result.stdout.toString().split("\0").flatMap((entry) => {
151
- const normalized = normalizeRelativePath(entry);
152
- return normalized ? [normalized] : [];
153
- }).sort();
162
+ return Buffer.from(result.stdout);
154
163
  }
155
- function addEntry(entries, root, relativePath, recurseDirectories) {
164
+ function listGitCheckout(root) {
165
+ const output = gitOutput(
166
+ root,
167
+ ["ls-files", "-z", "-t", "--stage", "--cached", "--others", "--exclude-standard", "--", "."],
168
+ "Inspect Git working tree"
169
+ );
170
+ const roots = /* @__PURE__ */ new Map();
171
+ const indexRecords = [];
172
+ for (const record of nulRecords(output)) {
173
+ const text = record.toString();
174
+ const other = /^\? (.*)$/su.exec(text);
175
+ if (other) {
176
+ const normalized2 = normalizeRelativePath(other[1]);
177
+ if (normalized2 && !roots.has(normalized2)) roots.set(normalized2, other[1].endsWith("/"));
178
+ continue;
179
+ }
180
+ const staged = /^[A-Z] ([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t(.*)$/su.exec(text);
181
+ if (!staged) continue;
182
+ const normalized = normalizeRelativePath(staged[4]);
183
+ if (!normalized) continue;
184
+ indexRecords.push({ mode: staged[1], objectId: staged[2], stage: Number(staged[3]), relativePath: normalized });
185
+ if (!roots.has(normalized)) roots.set(normalized, false);
186
+ }
187
+ return {
188
+ roots: [...roots].map(([relativePath, nestedRepository]) => ({ relativePath, nestedRepository })).sort((left, right) => left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0),
189
+ indexRecords
190
+ };
191
+ }
192
+ function populatedCheckoutHead(root, absolutePath) {
193
+ if (!lstatIfExists(import_node_path.default.join(absolutePath, ".git"))) return null;
194
+ const result = Bun.spawnSync(["git", ...NON_RECURSIVE_GIT_CONFIG, "rev-parse", "--verify", "HEAD^{commit}"], {
195
+ cwd: absolutePath,
196
+ stdout: "pipe",
197
+ stderr: "pipe",
198
+ env: { ...(0, import_git_process_environment.workerGitProcessEnvironment)(), GIT_CEILING_DIRECTORIES: root }
199
+ });
200
+ if (result.exitCode !== 0) return null;
201
+ const head = result.stdout.toString().trim();
202
+ return OBJECT_ID_PATTERN.test(head) ? head : null;
203
+ }
204
+ function checkoutGitDirectory(root) {
205
+ const dotGit = import_node_path.default.join(root, ".git");
206
+ const status = lstatIfExists(dotGit);
207
+ if (!status) return null;
208
+ if (status.isDirectory()) return dotGit;
209
+ if (!status.isFile()) return null;
210
+ const match = /^gitdir:[ \t]*(.+?)\s*$/u.exec(import_node_fs.default.readFileSync(dotGit, "utf8").split(/\r?\n/u)[0] ?? "");
211
+ return match ? import_node_path.default.resolve(root, match[1]) : null;
212
+ }
213
+ function gitlinkStaleHeadsPath(root) {
214
+ const gitDirectory = checkoutGitDirectory(root);
215
+ return gitDirectory ? import_node_path.default.join(gitDirectory, "r5d", GITLINK_STALE_HEADS_FILE) : null;
216
+ }
217
+ function readGitlinkStaleHeads(root) {
218
+ const heads = /* @__PURE__ */ new Map();
219
+ const markerPath = gitlinkStaleHeadsPath(root);
220
+ if (!markerPath || !lstatIfExists(markerPath)?.isFile()) return heads;
221
+ let parsed;
222
+ try {
223
+ parsed = JSON.parse(import_node_fs.default.readFileSync(markerPath, "utf8"));
224
+ } catch {
225
+ return heads;
226
+ }
227
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1) return heads;
228
+ const recorded = parsed.heads;
229
+ if (!recorded || typeof recorded !== "object") return heads;
230
+ for (const [relativePath, objectId] of Object.entries(recorded)) {
231
+ const normalized = normalizeRelativePath(relativePath);
232
+ if (normalized && typeof objectId === "string" && OBJECT_ID_PATTERN.test(objectId)) heads.set(normalized, objectId);
233
+ }
234
+ return heads;
235
+ }
236
+ function writeGitlinkStaleHeads(root, heads) {
237
+ const markerPath = gitlinkStaleHeadsPath(root);
238
+ if (!markerPath) return;
239
+ const directory = import_node_path.default.dirname(markerPath);
240
+ if (heads.size === 0) {
241
+ if (!lstatIfExists(markerPath)) return;
242
+ import_node_fs.default.rmSync(markerPath, { force: true });
243
+ fsyncPath(directory, import_node_fs.default.constants.O_RDONLY);
244
+ return;
245
+ }
246
+ const directoryStatus = lstatIfExists(directory);
247
+ if (!directoryStatus) import_node_fs.default.mkdirSync(directory, { mode: 448 });
248
+ else if (!directoryStatus.isDirectory() || directoryStatus.isSymbolicLink()) {
249
+ throw new Error(`Checkout private state is not a regular directory: ${directory}`);
250
+ }
251
+ const temporaryPath = import_node_path.default.join(directory, `.${GITLINK_STALE_HEADS_FILE}.${(0, import_node_crypto.randomUUID)()}.tmp`);
252
+ const descriptor = import_node_fs.default.openSync(temporaryPath, import_node_fs.default.constants.O_WRONLY | import_node_fs.default.constants.O_CREAT | import_node_fs.default.constants.O_EXCL, 384);
253
+ try {
254
+ import_node_fs.default.writeFileSync(descriptor, `${JSON.stringify({ version: 1, heads: Object.fromEntries([...heads].sort()) })}
255
+ `);
256
+ import_node_fs.default.fsyncSync(descriptor);
257
+ } finally {
258
+ import_node_fs.default.closeSync(descriptor);
259
+ }
260
+ import_node_fs.default.renameSync(temporaryPath, markerPath);
261
+ fsyncPath(directory, import_node_fs.default.constants.O_RDONLY);
262
+ }
263
+ function addEntry(entries, root, relativePath, recurseDirectories, gitlinks) {
156
264
  const normalized = normalizeRelativePath(relativePath);
157
265
  if (!normalized || entries.has(normalized)) return;
266
+ const gitlink = gitlinks?.get(normalized);
267
+ if (gitlink !== void 0) {
268
+ entries.set(normalized, { kind: "gitlink", objectId: gitlink });
269
+ return;
270
+ }
158
271
  const inspected = inspectRelativePathWithoutFollowingAncestors(root, normalized);
159
272
  if (inspected.kind !== "entry") return;
160
273
  const { absolutePath, stat } = inspected;
@@ -173,7 +286,7 @@ function addEntry(entries, root, relativePath, recurseDirectories) {
173
286
  entries.set(normalized, { kind: "directory", mode: stat.mode });
174
287
  if (!recurseDirectories) return;
175
288
  for (const child of import_node_fs.default.readdirSync(absolutePath).sort()) {
176
- addEntry(entries, root, import_node_path.default.posix.join(normalized, child), true);
289
+ addEntry(entries, root, import_node_path.default.posix.join(normalized, child), true, gitlinks);
177
290
  }
178
291
  }
179
292
  function addParentDirectories(entries, root) {
@@ -191,57 +304,59 @@ function addParentDirectories(entries, root) {
191
304
  }
192
305
  }
193
306
  }
194
- function gitlinkPaths(root) {
195
- const result = Bun.spawnSync(
196
- [
197
- "git",
198
- "-c",
199
- "submodule.recurse=false",
200
- "-c",
201
- "fetch.recurseSubmodules=false",
202
- "-c",
203
- "push.recurseSubmodules=false",
204
- "ls-files",
205
- "-z",
206
- "--stage",
207
- "--",
208
- "."
209
- ],
210
- {
211
- cwd: root,
212
- stdout: "pipe",
213
- stderr: "pipe",
214
- env: (0, import_git_process_environment.workerGitProcessEnvironment)()
307
+ function inspectGitWorkingTree(root) {
308
+ const entries = /* @__PURE__ */ new Map();
309
+ const listing = listGitCheckout(root);
310
+ const indexGitlinks = /* @__PURE__ */ new Map();
311
+ for (const record of listing.indexRecords) {
312
+ if (record.mode === "160000" && !indexGitlinks.has(record.relativePath)) indexGitlinks.set(record.relativePath, record.objectId);
313
+ }
314
+ const submodules = /* @__PURE__ */ new Map();
315
+ let staleHeads = null;
316
+ for (const { relativePath, nestedRepository } of listing.roots) {
317
+ if (entries.has(relativePath)) continue;
318
+ const indexObjectId = indexGitlinks.get(relativePath);
319
+ if (indexObjectId === void 0 && !nestedRepository) {
320
+ addEntry(entries, root, relativePath, true);
321
+ continue;
215
322
  }
216
- );
217
- if (result.exitCode !== 0) {
218
- throw new Error(`Inspect Git index gitlinks: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
219
- }
220
- const gitlinks = /* @__PURE__ */ new Set();
221
- for (const record of result.stdout.toString().split("\0")) {
222
- const match = /^160000 [0-9a-f]+ [0-3]\t(.+)$/su.exec(record);
223
- if (!match) continue;
224
- const normalized = normalizeRelativePath(match[1]);
225
- if (normalized) gitlinks.add(normalized);
323
+ const inspected = inspectRelativePathWithoutFollowingAncestors(root, relativePath);
324
+ if (inspected.kind !== "entry") continue;
325
+ if (!inspected.stat.isDirectory() || inspected.stat.isSymbolicLink()) {
326
+ addEntry(entries, root, relativePath, true);
327
+ continue;
328
+ }
329
+ const head = populatedCheckoutHead(root, inspected.absolutePath);
330
+ submodules.set(relativePath, head);
331
+ let objectId = indexObjectId ?? head;
332
+ if (head && indexObjectId !== void 0 && head !== indexObjectId) {
333
+ staleHeads ??= readGitlinkStaleHeads(root);
334
+ objectId = staleHeads.get(relativePath) === head ? indexObjectId : head;
335
+ }
336
+ if (objectId) entries.set(relativePath, { kind: "gitlink", objectId });
226
337
  }
227
- return gitlinks;
338
+ addParentDirectories(entries, root);
339
+ return { entries, indexRecords: listing.indexRecords, submodules };
228
340
  }
229
- function directoryIsEmpty(root, relativePath) {
230
- const inspected = inspectRelativePathWithoutFollowingAncestors(root, relativePath);
231
- return inspected.kind === "entry" && inspected.stat.isDirectory() && import_node_fs.default.readdirSync(inspected.absolutePath).length === 0;
341
+ function normalizedGitlinks(gitlinks) {
342
+ const normalized = /* @__PURE__ */ new Map();
343
+ for (const [relativePath, objectId] of gitlinks ?? []) {
344
+ const relative = normalizeRelativePath(relativePath);
345
+ if (!relative) throw new Error(`Invalid working-tree gitlink path: ${relativePath}`);
346
+ if (!OBJECT_ID_PATTERN.test(objectId)) throw new Error(`Invalid working-tree gitlink object id for ${relativePath}: ${objectId}`);
347
+ normalized.set(relative, objectId);
348
+ }
349
+ return normalized;
232
350
  }
233
351
  function inspectWorkingTree(root, mode, options = {}) {
234
352
  const entries = /* @__PURE__ */ new Map();
235
353
  root = import_node_path.default.resolve(root);
236
354
  if (!inspectDirectoryRoot(root, { allowMissing: true, label: "Working-tree root" })) return entries;
237
- if (mode === "all") {
238
- for (const child of import_node_fs.default.readdirSync(root).sort()) addEntry(entries, root, child, true);
239
- } else {
240
- const omittedGitlinks = options.omitUnpopulatedGitlinks ? gitlinkPaths(root) : null;
241
- for (const relativePath of gitEligibleRoots(root)) {
242
- if (omittedGitlinks?.has(relativePath) && directoryIsEmpty(root, relativePath)) continue;
243
- addEntry(entries, root, relativePath, true);
244
- }
355
+ if (mode === "git") return inspectGitWorkingTree(root).entries;
356
+ const gitlinks = normalizedGitlinks(options.gitlinks);
357
+ for (const child of import_node_fs.default.readdirSync(root).sort()) addEntry(entries, root, child, true, gitlinks);
358
+ for (const [relativePath, objectId] of gitlinks) {
359
+ if (entries.get(relativePath)?.kind !== "gitlink") entries.set(relativePath, { kind: "gitlink", objectId });
245
360
  }
246
361
  addParentDirectories(entries, root);
247
362
  return entries;
@@ -391,6 +506,9 @@ function copyEntry(sourceRoot, targetRoot, relativePath, entry, durability) {
391
506
  }
392
507
  return;
393
508
  }
509
+ if (entry.kind === "gitlink") {
510
+ throw new Error(`Working-tree gitlink cannot be copied as content: ${relativePath}`);
511
+ }
394
512
  if (!source.stat.isFile() || source.stat.isSymbolicLink() || source.stat.size !== entry.size) {
395
513
  throw new Error(`Working-tree source changed while it was being mirrored: ${sourcePath}`);
396
514
  }
@@ -404,6 +522,110 @@ function copyEntry(sourceRoot, targetRoot, relativePath, entry, durability) {
404
522
  copyRegularFile(sourcePath, targetPath, entry, durability);
405
523
  }
406
524
  }
525
+ function inspectMirrorTarget(targetRoot, deletionMode) {
526
+ if (deletionMode !== "git") return { existing: inspectWorkingTree(targetRoot, "all"), git: null };
527
+ const inspection = inspectGitWorkingTree(targetRoot);
528
+ const occupied = /* @__PURE__ */ new Set();
529
+ for (const relativePath of inspection.submodules.keys()) {
530
+ const inspected = inspectRelativePathWithoutFollowingAncestors(targetRoot, relativePath);
531
+ if (inspected.kind === "entry" && inspected.stat.isDirectory() && import_node_fs.default.readdirSync(inspected.absolutePath).length > 0) {
532
+ occupied.add(relativePath);
533
+ }
534
+ }
535
+ return { existing: inspection.entries, git: { indexRecords: inspection.indexRecords, submodules: inspection.submodules, occupied } };
536
+ }
537
+ function shieldedDesiredPaths(desired, git) {
538
+ const shielded = /* @__PURE__ */ new Set();
539
+ if (git.submodules.size === 0) return shielded;
540
+ const roots = new Set(git.submodules.keys());
541
+ for (const [relativePath, entry] of desired) {
542
+ if (hasAncestorIn(relativePath, roots)) {
543
+ shielded.add(relativePath);
544
+ continue;
545
+ }
546
+ if (!roots.has(relativePath) || entry.kind === "gitlink") continue;
547
+ if (entry.kind === "directory" || git.occupied.has(relativePath)) shielded.add(relativePath);
548
+ }
549
+ return shielded;
550
+ }
551
+ function directoryShieldsSubmodule(relativePath, entry, git) {
552
+ if (!git || entry.kind !== "directory") return false;
553
+ const prefix = `${relativePath}/`;
554
+ for (const root of git.submodules.keys()) if (root.startsWith(prefix)) return true;
555
+ return false;
556
+ }
557
+ function planIndexChanges(desired, shielded, git) {
558
+ const removed = [];
559
+ const written = [];
560
+ const desiredGitlinks = /* @__PURE__ */ new Map();
561
+ for (const [relativePath, entry] of desired) {
562
+ if (entry.kind === "gitlink" && !shielded.has(relativePath)) desiredGitlinks.set(relativePath, entry.objectId);
563
+ }
564
+ const desiredGitlinkRoots = new Set(desiredGitlinks.keys());
565
+ const currentGitlinks = /* @__PURE__ */ new Map();
566
+ for (const record of git.indexRecords) {
567
+ const desiredObjectId = desiredGitlinks.get(record.relativePath);
568
+ if (record.mode === "160000") {
569
+ if (record.stage === 0) currentGitlinks.set(record.relativePath, record.objectId);
570
+ if (desiredObjectId !== void 0) {
571
+ if (desiredObjectId !== record.objectId) removed.push(record);
572
+ continue;
573
+ }
574
+ const desiredEntry = desired.get(record.relativePath);
575
+ if (desiredEntry?.kind !== "directory" && desiredEntry?.kind !== "gitlink" && !git.occupied.has(record.relativePath)) {
576
+ removed.push(record);
577
+ }
578
+ continue;
579
+ }
580
+ if (desiredObjectId !== void 0 || hasAncestorIn(record.relativePath, desiredGitlinkRoots)) removed.push(record);
581
+ }
582
+ for (const [relativePath, objectId] of desiredGitlinks) {
583
+ if (currentGitlinks.get(relativePath) !== objectId) written.push({ relativePath, objectId });
584
+ }
585
+ return { removed, written };
586
+ }
587
+ function indexInfoRecord(record) {
588
+ const stage = record.stage === 0 ? "" : ` ${record.stage}`;
589
+ return Buffer.concat([Buffer.from(`${record.mode} ${record.objectId}${stage} `), Buffer.from(record.relativePath), Buffer.from([0])]);
590
+ }
591
+ function updateIndex(targetRoot, changes) {
592
+ if (changes.drop.length === 0 && changes.put.length === 0) return;
593
+ const feed = Buffer.concat([
594
+ ...changes.drop.map(
595
+ ({ relativePath, objectId }) => indexInfoRecord({ mode: "0", objectId: "0".repeat(objectId.length), stage: 0, relativePath })
596
+ ),
597
+ ...changes.put.map(indexInfoRecord)
598
+ ]);
599
+ gitOutput(
600
+ targetRoot,
601
+ ["-c", "core.fsync=index", "-c", "core.fsyncMethod=fsync", "update-index", "-z", "--index-info"],
602
+ "Update Git working-tree index",
603
+ { stdin: feed }
604
+ );
605
+ const gitDirectory = checkoutGitDirectory(targetRoot);
606
+ if (!gitDirectory) return;
607
+ const indexPath = import_node_path.default.join(gitDirectory, "index");
608
+ if (lstatIfExists(indexPath)?.isFile()) fsyncPath(indexPath, import_node_fs.default.constants.O_RDONLY | import_node_fs.default.constants.O_NOFOLLOW);
609
+ fsyncPath(gitDirectory, import_node_fs.default.constants.O_RDONLY);
610
+ }
611
+ function recordGitlinkStaleHeads(targetRoot, desired, shielded, git) {
612
+ const heads = readGitlinkStaleHeads(targetRoot);
613
+ let changed = false;
614
+ for (const [relativePath, head] of git.submodules) {
615
+ const entry = desired.get(relativePath);
616
+ const next = entry?.kind === "gitlink" && !shielded.has(relativePath) && head && head !== entry.objectId ? head : null;
617
+ if ((heads.get(relativePath) ?? null) === next) continue;
618
+ if (next) heads.set(relativePath, next);
619
+ else heads.delete(relativePath);
620
+ changed = true;
621
+ }
622
+ for (const relativePath of [...heads.keys()]) {
623
+ if (git.submodules.has(relativePath)) continue;
624
+ heads.delete(relativePath);
625
+ changed = true;
626
+ }
627
+ if (changed) writeGitlinkStaleHeads(targetRoot, heads);
628
+ }
407
629
  function mirrorWorkingTree(input) {
408
630
  const sourceRoot = import_node_path.default.resolve(input.sourceRoot);
409
631
  const targetRoot = import_node_path.default.resolve(input.targetRoot);
@@ -412,19 +634,34 @@ function mirrorWorkingTree(input) {
412
634
  import_node_fs.default.mkdirSync(targetRoot, { recursive: true });
413
635
  }
414
636
  inspectDirectoryRoot(targetRoot, { allowMissing: false, label: "Working-tree target root" });
415
- const desired = inspectWorkingTree(sourceRoot, input.sourceMode);
416
- const existing = inspectWorkingTree(targetRoot, input.deletionMode === "git" ? "git" : "all", {
417
- omitUnpopulatedGitlinks: input.deletionMode === "git"
418
- });
419
- for (const relativePath of [...existing.keys()].sort((left, right) => right.split("/").length - left.split("/").length)) {
420
- if (!desired.has(relativePath)) removeEntry(targetRoot, relativePath);
421
- }
422
- for (const [relativePath, entry] of [...desired.entries()].sort(
423
- ([left], [right]) => left.split("/").length - right.split("/").length || left.localeCompare(right)
424
- )) {
637
+ const desired = inspectWorkingTree(sourceRoot, input.sourceMode, { gitlinks: input.sourceGitlinks });
638
+ const target = inspectMirrorTarget(targetRoot, input.deletionMode);
639
+ const shielded = target.git ? shieldedDesiredPaths(desired, target.git) : /* @__PURE__ */ new Set();
640
+ const index = target.git ? planIndexChanges(desired, shielded, target.git) : null;
641
+ for (const relativePath of [...target.existing.keys()].sort(deepestFirst)) {
642
+ if (desired.has(relativePath)) continue;
643
+ const entry = target.existing.get(relativePath);
644
+ if (entry.kind === "gitlink" || directoryShieldsSubmodule(relativePath, entry, target.git)) continue;
645
+ removeEntry(targetRoot, relativePath);
646
+ }
647
+ const gitlinks = [];
648
+ for (const [relativePath, entry] of [...desired.entries()].sort(([left], [right]) => shallowestFirst(left, right))) {
649
+ if (shielded.has(relativePath)) continue;
650
+ if (entry.kind === "gitlink") {
651
+ ensureDirectory(targetRoot, relativePath, 493);
652
+ gitlinks.push({ relativePath, objectId: entry.objectId });
653
+ continue;
654
+ }
425
655
  copyEntry(sourceRoot, targetRoot, relativePath, entry, durability);
426
656
  }
427
- return { paths: [...desired.keys()].filter((relativePath) => desired.get(relativePath)?.kind !== "directory").sort() };
657
+ if (index && target.git) {
658
+ updateIndex(targetRoot, { drop: index.removed, put: index.written.map((gitlink) => ({ mode: "160000", stage: 0, ...gitlink })) });
659
+ recordGitlinkStaleHeads(targetRoot, desired, shielded, target.git);
660
+ }
661
+ return {
662
+ paths: [...desired.keys()].filter((relativePath) => !shielded.has(relativePath) && desired.get(relativePath)?.kind !== "directory").sort(),
663
+ gitlinks
664
+ };
428
665
  }
429
666
  function normalizeWorkingTreeRelativePath(value) {
430
667
  return normalizeRelativePath(value);
@@ -464,23 +701,25 @@ function fsyncPath(targetPath, flags) {
464
701
  function planWorkingTreeMirror(input) {
465
702
  const sourceRoot = import_node_path.default.resolve(input.sourceRoot);
466
703
  const targetRoot = import_node_path.default.resolve(input.targetRoot);
467
- const desired = inspectWorkingTree(sourceRoot, input.sourceMode);
468
- const existing = inspectWorkingTree(targetRoot, input.deletionMode === "git" ? "git" : "all", {
469
- omitUnpopulatedGitlinks: input.deletionMode === "git"
470
- });
704
+ const inspectedDesired = inspectWorkingTree(sourceRoot, input.sourceMode, { gitlinks: input.sourceGitlinks });
705
+ const target = inspectMirrorTarget(targetRoot, input.deletionMode);
706
+ const shielded = target.git ? shieldedDesiredPaths(inspectedDesired, target.git) : /* @__PURE__ */ new Set();
707
+ const desired = new Map([...inspectedDesired].filter(([relativePath]) => !shielded.has(relativePath)));
471
708
  const replaced = /* @__PURE__ */ new Map();
472
709
  const absent = [];
473
- for (const [relativePath, entry] of existing) {
474
- if (!desired.has(relativePath)) replaced.set(relativePath, entry);
710
+ for (const [relativePath, entry] of target.existing) {
711
+ if (inspectedDesired.has(relativePath) || entry.kind === "gitlink") continue;
712
+ if (directoryShieldsSubmodule(relativePath, entry, target.git)) continue;
713
+ replaced.set(relativePath, entry);
475
714
  }
476
715
  for (const [relativePath, entry] of desired) {
477
- const target = inspectRelativePathWithoutFollowingAncestors(targetRoot, relativePath);
478
- if (target.kind !== "entry") {
716
+ const targetEntry = inspectRelativePathWithoutFollowingAncestors(targetRoot, relativePath);
717
+ if (targetEntry.kind !== "entry") {
479
718
  absent.push(relativePath);
480
719
  continue;
481
720
  }
482
- const { absolutePath, stat } = target;
483
- if (entry.kind === "directory") {
721
+ const { absolutePath, stat } = targetEntry;
722
+ if (entry.kind === "directory" || entry.kind === "gitlink") {
484
723
  if (stat.isDirectory()) continue;
485
724
  } else if (entry.kind === "symlink") {
486
725
  if (stat.isSymbolicLink() && import_node_fs.default.readlinkSync(absolutePath) === entry.target) continue;
@@ -500,7 +739,8 @@ function planWorkingTreeMirror(input) {
500
739
  return {
501
740
  desired,
502
741
  replaced,
503
- absent: absent.filter((relativePath) => !hasAncestorIn(relativePath, replacedDirectories)).sort(shallowestFirst)
742
+ absent: absent.filter((relativePath) => !hasAncestorIn(relativePath, replacedDirectories)).sort(shallowestFirst),
743
+ index: target.git ? planIndexChanges(inspectedDesired, shielded, target.git) : { removed: [], written: [] }
504
744
  };
505
745
  }
506
746
  function captureWorkingTreeMirrorPlan(input) {
@@ -513,6 +753,7 @@ function captureWorkingTreeMirrorPlan(input) {
513
753
  }
514
754
  const trees = [];
515
755
  for (const [relativePath, entry] of [...input.plan.replaced.entries()].sort(([left], [right]) => shallowestFirst(left, right))) {
756
+ if (entry.kind === "gitlink") continue;
516
757
  if (entry.kind !== "directory") {
517
758
  copyEntry(targetRoot, snapshotRoot, relativePath, entry, durability);
518
759
  continue;
@@ -531,7 +772,12 @@ function captureWorkingTreeMirrorPlan(input) {
531
772
  });
532
773
  trees.push(relativePath);
533
774
  }
534
- return { absent: [...input.plan.absent].sort(shallowestFirst), trees: trees.sort(shallowestFirst) };
775
+ const { index } = input.plan;
776
+ return {
777
+ absent: [...input.plan.absent].sort(shallowestFirst),
778
+ trees: trees.sort(shallowestFirst),
779
+ ...index.removed.length > 0 || index.written.length > 0 ? { index } : {}
780
+ };
535
781
  }
536
782
  function restoreWorkingTreeMirrorScope(input) {
537
783
  const snapshotRoot = import_node_path.default.resolve(input.snapshotRoot);
@@ -556,6 +802,7 @@ function restoreWorkingTreeMirrorScope(input) {
556
802
  const touched = /* @__PURE__ */ new Set();
557
803
  for (const relativePath of [...absent].sort(deepestFirst)) {
558
804
  const inspected = inspectRelativePathWithoutFollowingAncestors(targetRoot, relativePath);
805
+ if (inspected.kind === "entry" && inspected.stat.isDirectory() && lstatIfExists(import_node_path.default.join(inspected.absolutePath, ".git"))) continue;
559
806
  if (inspected.kind === "entry") import_node_fs.default.rmSync(inspected.absolutePath, { recursive: true, force: true });
560
807
  touched.add(relativePath);
561
808
  }
@@ -593,7 +840,7 @@ function restoreWorkingTreeMirrorScope(input) {
593
840
  if (current.kind === "entry" && !current.stat.isDirectory()) {
594
841
  import_node_fs.default.rmSync(current.absolutePath, { recursive: true, force: true });
595
842
  }
596
- ensureDirectory(targetRoot, relativePath, entry.mode);
843
+ ensureDirectory(targetRoot, relativePath, entry.kind === "directory" ? entry.mode : 493);
597
844
  mirrorWorkingTree({
598
845
  sourceRoot: import_node_path.default.join(snapshotRoot, ...relativePath.split("/")),
599
846
  targetRoot: import_node_path.default.join(targetRoot, ...relativePath.split("/")),
@@ -601,6 +848,7 @@ function restoreWorkingTreeMirrorScope(input) {
601
848
  deletionMode: "all"
602
849
  });
603
850
  }
851
+ if (input.scope.index) updateIndex(targetRoot, { drop: input.scope.index.written, put: input.scope.index.removed });
604
852
  return { paths: [...touched].sort(shallowestFirst) };
605
853
  }
606
854
  function fsyncWorkingTreePaths(root, relativePaths) {