@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
@@ -4,6 +4,16 @@ import { randomUUID } from "node:crypto";
4
4
  import { workerGitProcessEnvironment } from "./git-process-environment.mjs";
5
5
  const HFS_IGNORED_CODE_POINTS = /[\u200c-\u200f\u202a-\u202e\u206a-\u206f\ufeff]/gu;
6
6
  const MIRROR_TEMPORARY_FILE_PREFIX = ".r5d-working-tree-mirror-";
7
+ const OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u;
8
+ const GITLINK_STALE_HEADS_FILE = "gitlink-heads.json";
9
+ const NON_RECURSIVE_GIT_CONFIG = [
10
+ "-c",
11
+ "submodule.recurse=false",
12
+ "-c",
13
+ "fetch.recurseSubmodules=false",
14
+ "-c",
15
+ "push.recurseSubmodules=false"
16
+ ];
7
17
  function isPortableGitMetadataSegment(segment) {
8
18
  const hfsFolded = segment.replace(HFS_IGNORED_CODE_POINTS, "").toLowerCase();
9
19
  if (hfsFolded === ".git") return true;
@@ -79,42 +89,145 @@ function inspectDirectoryRoot(root, options) {
79
89
  }
80
90
  return true;
81
91
  }
82
- function gitEligibleRoots(root) {
83
- const result = Bun.spawnSync(
84
- [
85
- "git",
86
- "-c",
87
- "submodule.recurse=false",
88
- "-c",
89
- "fetch.recurseSubmodules=false",
90
- "-c",
91
- "push.recurseSubmodules=false",
92
- "ls-files",
93
- "-z",
94
- "--cached",
95
- "--others",
96
- "--exclude-standard",
97
- "--",
98
- "."
99
- ],
100
- {
101
- cwd: root,
102
- stdout: "pipe",
103
- stderr: "pipe",
104
- env: workerGitProcessEnvironment()
105
- }
106
- );
92
+ function lstatIfExists(targetPath) {
93
+ try {
94
+ return fs.lstatSync(targetPath);
95
+ } catch (error) {
96
+ if (error.code === "ENOENT") return null;
97
+ throw error;
98
+ }
99
+ }
100
+ function nulRecords(content) {
101
+ const records = [];
102
+ let start = 0;
103
+ for (let index = 0; index < content.length; index += 1) {
104
+ if (content[index] !== 0) continue;
105
+ if (index > start) records.push(content.subarray(start, index));
106
+ start = index + 1;
107
+ }
108
+ if (start < content.length) records.push(content.subarray(start));
109
+ return records;
110
+ }
111
+ function gitOutput(cwd, args, action, options = {}) {
112
+ const result = Bun.spawnSync(["git", ...NON_RECURSIVE_GIT_CONFIG, ...args], {
113
+ cwd,
114
+ ...options.stdin ? { stdin: options.stdin } : {},
115
+ stdout: "pipe",
116
+ stderr: "pipe",
117
+ env: options.environment ?? workerGitProcessEnvironment()
118
+ });
107
119
  if (result.exitCode !== 0) {
108
- throw new Error(`Inspect Git working tree: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
120
+ throw new Error(`${action}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
109
121
  }
110
- return result.stdout.toString().split("\0").flatMap((entry) => {
111
- const normalized = normalizeRelativePath(entry);
112
- return normalized ? [normalized] : [];
113
- }).sort();
122
+ return Buffer.from(result.stdout);
114
123
  }
115
- function addEntry(entries, root, relativePath, recurseDirectories) {
124
+ function listGitCheckout(root) {
125
+ const output = gitOutput(
126
+ root,
127
+ ["ls-files", "-z", "-t", "--stage", "--cached", "--others", "--exclude-standard", "--", "."],
128
+ "Inspect Git working tree"
129
+ );
130
+ const roots = /* @__PURE__ */ new Map();
131
+ const indexRecords = [];
132
+ for (const record of nulRecords(output)) {
133
+ const text = record.toString();
134
+ const other = /^\? (.*)$/su.exec(text);
135
+ if (other) {
136
+ const normalized2 = normalizeRelativePath(other[1]);
137
+ if (normalized2 && !roots.has(normalized2)) roots.set(normalized2, other[1].endsWith("/"));
138
+ continue;
139
+ }
140
+ const staged = /^[A-Z] ([0-7]{6}) ([0-9a-f]{40,64}) ([0-3])\t(.*)$/su.exec(text);
141
+ if (!staged) continue;
142
+ const normalized = normalizeRelativePath(staged[4]);
143
+ if (!normalized) continue;
144
+ indexRecords.push({ mode: staged[1], objectId: staged[2], stage: Number(staged[3]), relativePath: normalized });
145
+ if (!roots.has(normalized)) roots.set(normalized, false);
146
+ }
147
+ return {
148
+ roots: [...roots].map(([relativePath, nestedRepository]) => ({ relativePath, nestedRepository })).sort((left, right) => left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0),
149
+ indexRecords
150
+ };
151
+ }
152
+ function populatedCheckoutHead(root, absolutePath) {
153
+ if (!lstatIfExists(path.join(absolutePath, ".git"))) return null;
154
+ const result = Bun.spawnSync(["git", ...NON_RECURSIVE_GIT_CONFIG, "rev-parse", "--verify", "HEAD^{commit}"], {
155
+ cwd: absolutePath,
156
+ stdout: "pipe",
157
+ stderr: "pipe",
158
+ env: { ...workerGitProcessEnvironment(), GIT_CEILING_DIRECTORIES: root }
159
+ });
160
+ if (result.exitCode !== 0) return null;
161
+ const head = result.stdout.toString().trim();
162
+ return OBJECT_ID_PATTERN.test(head) ? head : null;
163
+ }
164
+ function checkoutGitDirectory(root) {
165
+ const dotGit = path.join(root, ".git");
166
+ const status = lstatIfExists(dotGit);
167
+ if (!status) return null;
168
+ if (status.isDirectory()) return dotGit;
169
+ if (!status.isFile()) return null;
170
+ const match = /^gitdir:[ \t]*(.+?)\s*$/u.exec(fs.readFileSync(dotGit, "utf8").split(/\r?\n/u)[0] ?? "");
171
+ return match ? path.resolve(root, match[1]) : null;
172
+ }
173
+ function gitlinkStaleHeadsPath(root) {
174
+ const gitDirectory = checkoutGitDirectory(root);
175
+ return gitDirectory ? path.join(gitDirectory, "r5d", GITLINK_STALE_HEADS_FILE) : null;
176
+ }
177
+ function readGitlinkStaleHeads(root) {
178
+ const heads = /* @__PURE__ */ new Map();
179
+ const markerPath = gitlinkStaleHeadsPath(root);
180
+ if (!markerPath || !lstatIfExists(markerPath)?.isFile()) return heads;
181
+ let parsed;
182
+ try {
183
+ parsed = JSON.parse(fs.readFileSync(markerPath, "utf8"));
184
+ } catch {
185
+ return heads;
186
+ }
187
+ if (!parsed || typeof parsed !== "object" || parsed.version !== 1) return heads;
188
+ const recorded = parsed.heads;
189
+ if (!recorded || typeof recorded !== "object") return heads;
190
+ for (const [relativePath, objectId] of Object.entries(recorded)) {
191
+ const normalized = normalizeRelativePath(relativePath);
192
+ if (normalized && typeof objectId === "string" && OBJECT_ID_PATTERN.test(objectId)) heads.set(normalized, objectId);
193
+ }
194
+ return heads;
195
+ }
196
+ function writeGitlinkStaleHeads(root, heads) {
197
+ const markerPath = gitlinkStaleHeadsPath(root);
198
+ if (!markerPath) return;
199
+ const directory = path.dirname(markerPath);
200
+ if (heads.size === 0) {
201
+ if (!lstatIfExists(markerPath)) return;
202
+ fs.rmSync(markerPath, { force: true });
203
+ fsyncPath(directory, fs.constants.O_RDONLY);
204
+ return;
205
+ }
206
+ const directoryStatus = lstatIfExists(directory);
207
+ if (!directoryStatus) fs.mkdirSync(directory, { mode: 448 });
208
+ else if (!directoryStatus.isDirectory() || directoryStatus.isSymbolicLink()) {
209
+ throw new Error(`Checkout private state is not a regular directory: ${directory}`);
210
+ }
211
+ const temporaryPath = path.join(directory, `.${GITLINK_STALE_HEADS_FILE}.${randomUUID()}.tmp`);
212
+ const descriptor = fs.openSync(temporaryPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 384);
213
+ try {
214
+ fs.writeFileSync(descriptor, `${JSON.stringify({ version: 1, heads: Object.fromEntries([...heads].sort()) })}
215
+ `);
216
+ fs.fsyncSync(descriptor);
217
+ } finally {
218
+ fs.closeSync(descriptor);
219
+ }
220
+ fs.renameSync(temporaryPath, markerPath);
221
+ fsyncPath(directory, fs.constants.O_RDONLY);
222
+ }
223
+ function addEntry(entries, root, relativePath, recurseDirectories, gitlinks) {
116
224
  const normalized = normalizeRelativePath(relativePath);
117
225
  if (!normalized || entries.has(normalized)) return;
226
+ const gitlink = gitlinks?.get(normalized);
227
+ if (gitlink !== void 0) {
228
+ entries.set(normalized, { kind: "gitlink", objectId: gitlink });
229
+ return;
230
+ }
118
231
  const inspected = inspectRelativePathWithoutFollowingAncestors(root, normalized);
119
232
  if (inspected.kind !== "entry") return;
120
233
  const { absolutePath, stat } = inspected;
@@ -133,7 +246,7 @@ function addEntry(entries, root, relativePath, recurseDirectories) {
133
246
  entries.set(normalized, { kind: "directory", mode: stat.mode });
134
247
  if (!recurseDirectories) return;
135
248
  for (const child of fs.readdirSync(absolutePath).sort()) {
136
- addEntry(entries, root, path.posix.join(normalized, child), true);
249
+ addEntry(entries, root, path.posix.join(normalized, child), true, gitlinks);
137
250
  }
138
251
  }
139
252
  function addParentDirectories(entries, root) {
@@ -151,57 +264,59 @@ function addParentDirectories(entries, root) {
151
264
  }
152
265
  }
153
266
  }
154
- function gitlinkPaths(root) {
155
- const result = Bun.spawnSync(
156
- [
157
- "git",
158
- "-c",
159
- "submodule.recurse=false",
160
- "-c",
161
- "fetch.recurseSubmodules=false",
162
- "-c",
163
- "push.recurseSubmodules=false",
164
- "ls-files",
165
- "-z",
166
- "--stage",
167
- "--",
168
- "."
169
- ],
170
- {
171
- cwd: root,
172
- stdout: "pipe",
173
- stderr: "pipe",
174
- env: workerGitProcessEnvironment()
267
+ function inspectGitWorkingTree(root) {
268
+ const entries = /* @__PURE__ */ new Map();
269
+ const listing = listGitCheckout(root);
270
+ const indexGitlinks = /* @__PURE__ */ new Map();
271
+ for (const record of listing.indexRecords) {
272
+ if (record.mode === "160000" && !indexGitlinks.has(record.relativePath)) indexGitlinks.set(record.relativePath, record.objectId);
273
+ }
274
+ const submodules = /* @__PURE__ */ new Map();
275
+ let staleHeads = null;
276
+ for (const { relativePath, nestedRepository } of listing.roots) {
277
+ if (entries.has(relativePath)) continue;
278
+ const indexObjectId = indexGitlinks.get(relativePath);
279
+ if (indexObjectId === void 0 && !nestedRepository) {
280
+ addEntry(entries, root, relativePath, true);
281
+ continue;
175
282
  }
176
- );
177
- if (result.exitCode !== 0) {
178
- throw new Error(`Inspect Git index gitlinks: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
179
- }
180
- const gitlinks = /* @__PURE__ */ new Set();
181
- for (const record of result.stdout.toString().split("\0")) {
182
- const match = /^160000 [0-9a-f]+ [0-3]\t(.+)$/su.exec(record);
183
- if (!match) continue;
184
- const normalized = normalizeRelativePath(match[1]);
185
- if (normalized) gitlinks.add(normalized);
283
+ const inspected = inspectRelativePathWithoutFollowingAncestors(root, relativePath);
284
+ if (inspected.kind !== "entry") continue;
285
+ if (!inspected.stat.isDirectory() || inspected.stat.isSymbolicLink()) {
286
+ addEntry(entries, root, relativePath, true);
287
+ continue;
288
+ }
289
+ const head = populatedCheckoutHead(root, inspected.absolutePath);
290
+ submodules.set(relativePath, head);
291
+ let objectId = indexObjectId ?? head;
292
+ if (head && indexObjectId !== void 0 && head !== indexObjectId) {
293
+ staleHeads ??= readGitlinkStaleHeads(root);
294
+ objectId = staleHeads.get(relativePath) === head ? indexObjectId : head;
295
+ }
296
+ if (objectId) entries.set(relativePath, { kind: "gitlink", objectId });
186
297
  }
187
- return gitlinks;
298
+ addParentDirectories(entries, root);
299
+ return { entries, indexRecords: listing.indexRecords, submodules };
188
300
  }
189
- function directoryIsEmpty(root, relativePath) {
190
- const inspected = inspectRelativePathWithoutFollowingAncestors(root, relativePath);
191
- return inspected.kind === "entry" && inspected.stat.isDirectory() && fs.readdirSync(inspected.absolutePath).length === 0;
301
+ function normalizedGitlinks(gitlinks) {
302
+ const normalized = /* @__PURE__ */ new Map();
303
+ for (const [relativePath, objectId] of gitlinks ?? []) {
304
+ const relative = normalizeRelativePath(relativePath);
305
+ if (!relative) throw new Error(`Invalid working-tree gitlink path: ${relativePath}`);
306
+ if (!OBJECT_ID_PATTERN.test(objectId)) throw new Error(`Invalid working-tree gitlink object id for ${relativePath}: ${objectId}`);
307
+ normalized.set(relative, objectId);
308
+ }
309
+ return normalized;
192
310
  }
193
311
  function inspectWorkingTree(root, mode, options = {}) {
194
312
  const entries = /* @__PURE__ */ new Map();
195
313
  root = path.resolve(root);
196
314
  if (!inspectDirectoryRoot(root, { allowMissing: true, label: "Working-tree root" })) return entries;
197
- if (mode === "all") {
198
- for (const child of fs.readdirSync(root).sort()) addEntry(entries, root, child, true);
199
- } else {
200
- const omittedGitlinks = options.omitUnpopulatedGitlinks ? gitlinkPaths(root) : null;
201
- for (const relativePath of gitEligibleRoots(root)) {
202
- if (omittedGitlinks?.has(relativePath) && directoryIsEmpty(root, relativePath)) continue;
203
- addEntry(entries, root, relativePath, true);
204
- }
315
+ if (mode === "git") return inspectGitWorkingTree(root).entries;
316
+ const gitlinks = normalizedGitlinks(options.gitlinks);
317
+ for (const child of fs.readdirSync(root).sort()) addEntry(entries, root, child, true, gitlinks);
318
+ for (const [relativePath, objectId] of gitlinks) {
319
+ if (entries.get(relativePath)?.kind !== "gitlink") entries.set(relativePath, { kind: "gitlink", objectId });
205
320
  }
206
321
  addParentDirectories(entries, root);
207
322
  return entries;
@@ -351,6 +466,9 @@ function copyEntry(sourceRoot, targetRoot, relativePath, entry, durability) {
351
466
  }
352
467
  return;
353
468
  }
469
+ if (entry.kind === "gitlink") {
470
+ throw new Error(`Working-tree gitlink cannot be copied as content: ${relativePath}`);
471
+ }
354
472
  if (!source.stat.isFile() || source.stat.isSymbolicLink() || source.stat.size !== entry.size) {
355
473
  throw new Error(`Working-tree source changed while it was being mirrored: ${sourcePath}`);
356
474
  }
@@ -364,6 +482,110 @@ function copyEntry(sourceRoot, targetRoot, relativePath, entry, durability) {
364
482
  copyRegularFile(sourcePath, targetPath, entry, durability);
365
483
  }
366
484
  }
485
+ function inspectMirrorTarget(targetRoot, deletionMode) {
486
+ if (deletionMode !== "git") return { existing: inspectWorkingTree(targetRoot, "all"), git: null };
487
+ const inspection = inspectGitWorkingTree(targetRoot);
488
+ const occupied = /* @__PURE__ */ new Set();
489
+ for (const relativePath of inspection.submodules.keys()) {
490
+ const inspected = inspectRelativePathWithoutFollowingAncestors(targetRoot, relativePath);
491
+ if (inspected.kind === "entry" && inspected.stat.isDirectory() && fs.readdirSync(inspected.absolutePath).length > 0) {
492
+ occupied.add(relativePath);
493
+ }
494
+ }
495
+ return { existing: inspection.entries, git: { indexRecords: inspection.indexRecords, submodules: inspection.submodules, occupied } };
496
+ }
497
+ function shieldedDesiredPaths(desired, git) {
498
+ const shielded = /* @__PURE__ */ new Set();
499
+ if (git.submodules.size === 0) return shielded;
500
+ const roots = new Set(git.submodules.keys());
501
+ for (const [relativePath, entry] of desired) {
502
+ if (hasAncestorIn(relativePath, roots)) {
503
+ shielded.add(relativePath);
504
+ continue;
505
+ }
506
+ if (!roots.has(relativePath) || entry.kind === "gitlink") continue;
507
+ if (entry.kind === "directory" || git.occupied.has(relativePath)) shielded.add(relativePath);
508
+ }
509
+ return shielded;
510
+ }
511
+ function directoryShieldsSubmodule(relativePath, entry, git) {
512
+ if (!git || entry.kind !== "directory") return false;
513
+ const prefix = `${relativePath}/`;
514
+ for (const root of git.submodules.keys()) if (root.startsWith(prefix)) return true;
515
+ return false;
516
+ }
517
+ function planIndexChanges(desired, shielded, git) {
518
+ const removed = [];
519
+ const written = [];
520
+ const desiredGitlinks = /* @__PURE__ */ new Map();
521
+ for (const [relativePath, entry] of desired) {
522
+ if (entry.kind === "gitlink" && !shielded.has(relativePath)) desiredGitlinks.set(relativePath, entry.objectId);
523
+ }
524
+ const desiredGitlinkRoots = new Set(desiredGitlinks.keys());
525
+ const currentGitlinks = /* @__PURE__ */ new Map();
526
+ for (const record of git.indexRecords) {
527
+ const desiredObjectId = desiredGitlinks.get(record.relativePath);
528
+ if (record.mode === "160000") {
529
+ if (record.stage === 0) currentGitlinks.set(record.relativePath, record.objectId);
530
+ if (desiredObjectId !== void 0) {
531
+ if (desiredObjectId !== record.objectId) removed.push(record);
532
+ continue;
533
+ }
534
+ const desiredEntry = desired.get(record.relativePath);
535
+ if (desiredEntry?.kind !== "directory" && desiredEntry?.kind !== "gitlink" && !git.occupied.has(record.relativePath)) {
536
+ removed.push(record);
537
+ }
538
+ continue;
539
+ }
540
+ if (desiredObjectId !== void 0 || hasAncestorIn(record.relativePath, desiredGitlinkRoots)) removed.push(record);
541
+ }
542
+ for (const [relativePath, objectId] of desiredGitlinks) {
543
+ if (currentGitlinks.get(relativePath) !== objectId) written.push({ relativePath, objectId });
544
+ }
545
+ return { removed, written };
546
+ }
547
+ function indexInfoRecord(record) {
548
+ const stage = record.stage === 0 ? "" : ` ${record.stage}`;
549
+ return Buffer.concat([Buffer.from(`${record.mode} ${record.objectId}${stage} `), Buffer.from(record.relativePath), Buffer.from([0])]);
550
+ }
551
+ function updateIndex(targetRoot, changes) {
552
+ if (changes.drop.length === 0 && changes.put.length === 0) return;
553
+ const feed = Buffer.concat([
554
+ ...changes.drop.map(
555
+ ({ relativePath, objectId }) => indexInfoRecord({ mode: "0", objectId: "0".repeat(objectId.length), stage: 0, relativePath })
556
+ ),
557
+ ...changes.put.map(indexInfoRecord)
558
+ ]);
559
+ gitOutput(
560
+ targetRoot,
561
+ ["-c", "core.fsync=index", "-c", "core.fsyncMethod=fsync", "update-index", "-z", "--index-info"],
562
+ "Update Git working-tree index",
563
+ { stdin: feed }
564
+ );
565
+ const gitDirectory = checkoutGitDirectory(targetRoot);
566
+ if (!gitDirectory) return;
567
+ const indexPath = path.join(gitDirectory, "index");
568
+ if (lstatIfExists(indexPath)?.isFile()) fsyncPath(indexPath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
569
+ fsyncPath(gitDirectory, fs.constants.O_RDONLY);
570
+ }
571
+ function recordGitlinkStaleHeads(targetRoot, desired, shielded, git) {
572
+ const heads = readGitlinkStaleHeads(targetRoot);
573
+ let changed = false;
574
+ for (const [relativePath, head] of git.submodules) {
575
+ const entry = desired.get(relativePath);
576
+ const next = entry?.kind === "gitlink" && !shielded.has(relativePath) && head && head !== entry.objectId ? head : null;
577
+ if ((heads.get(relativePath) ?? null) === next) continue;
578
+ if (next) heads.set(relativePath, next);
579
+ else heads.delete(relativePath);
580
+ changed = true;
581
+ }
582
+ for (const relativePath of [...heads.keys()]) {
583
+ if (git.submodules.has(relativePath)) continue;
584
+ heads.delete(relativePath);
585
+ changed = true;
586
+ }
587
+ if (changed) writeGitlinkStaleHeads(targetRoot, heads);
588
+ }
367
589
  function mirrorWorkingTree(input) {
368
590
  const sourceRoot = path.resolve(input.sourceRoot);
369
591
  const targetRoot = path.resolve(input.targetRoot);
@@ -372,19 +594,34 @@ function mirrorWorkingTree(input) {
372
594
  fs.mkdirSync(targetRoot, { recursive: true });
373
595
  }
374
596
  inspectDirectoryRoot(targetRoot, { allowMissing: false, label: "Working-tree target root" });
375
- const desired = inspectWorkingTree(sourceRoot, input.sourceMode);
376
- const existing = inspectWorkingTree(targetRoot, input.deletionMode === "git" ? "git" : "all", {
377
- omitUnpopulatedGitlinks: input.deletionMode === "git"
378
- });
379
- for (const relativePath of [...existing.keys()].sort((left, right) => right.split("/").length - left.split("/").length)) {
380
- if (!desired.has(relativePath)) removeEntry(targetRoot, relativePath);
381
- }
382
- for (const [relativePath, entry] of [...desired.entries()].sort(
383
- ([left], [right]) => left.split("/").length - right.split("/").length || left.localeCompare(right)
384
- )) {
597
+ const desired = inspectWorkingTree(sourceRoot, input.sourceMode, { gitlinks: input.sourceGitlinks });
598
+ const target = inspectMirrorTarget(targetRoot, input.deletionMode);
599
+ const shielded = target.git ? shieldedDesiredPaths(desired, target.git) : /* @__PURE__ */ new Set();
600
+ const index = target.git ? planIndexChanges(desired, shielded, target.git) : null;
601
+ for (const relativePath of [...target.existing.keys()].sort(deepestFirst)) {
602
+ if (desired.has(relativePath)) continue;
603
+ const entry = target.existing.get(relativePath);
604
+ if (entry.kind === "gitlink" || directoryShieldsSubmodule(relativePath, entry, target.git)) continue;
605
+ removeEntry(targetRoot, relativePath);
606
+ }
607
+ const gitlinks = [];
608
+ for (const [relativePath, entry] of [...desired.entries()].sort(([left], [right]) => shallowestFirst(left, right))) {
609
+ if (shielded.has(relativePath)) continue;
610
+ if (entry.kind === "gitlink") {
611
+ ensureDirectory(targetRoot, relativePath, 493);
612
+ gitlinks.push({ relativePath, objectId: entry.objectId });
613
+ continue;
614
+ }
385
615
  copyEntry(sourceRoot, targetRoot, relativePath, entry, durability);
386
616
  }
387
- return { paths: [...desired.keys()].filter((relativePath) => desired.get(relativePath)?.kind !== "directory").sort() };
617
+ if (index && target.git) {
618
+ updateIndex(targetRoot, { drop: index.removed, put: index.written.map((gitlink) => ({ mode: "160000", stage: 0, ...gitlink })) });
619
+ recordGitlinkStaleHeads(targetRoot, desired, shielded, target.git);
620
+ }
621
+ return {
622
+ paths: [...desired.keys()].filter((relativePath) => !shielded.has(relativePath) && desired.get(relativePath)?.kind !== "directory").sort(),
623
+ gitlinks
624
+ };
388
625
  }
389
626
  function normalizeWorkingTreeRelativePath(value) {
390
627
  return normalizeRelativePath(value);
@@ -424,23 +661,25 @@ function fsyncPath(targetPath, flags) {
424
661
  function planWorkingTreeMirror(input) {
425
662
  const sourceRoot = path.resolve(input.sourceRoot);
426
663
  const targetRoot = path.resolve(input.targetRoot);
427
- const desired = inspectWorkingTree(sourceRoot, input.sourceMode);
428
- const existing = inspectWorkingTree(targetRoot, input.deletionMode === "git" ? "git" : "all", {
429
- omitUnpopulatedGitlinks: input.deletionMode === "git"
430
- });
664
+ const inspectedDesired = inspectWorkingTree(sourceRoot, input.sourceMode, { gitlinks: input.sourceGitlinks });
665
+ const target = inspectMirrorTarget(targetRoot, input.deletionMode);
666
+ const shielded = target.git ? shieldedDesiredPaths(inspectedDesired, target.git) : /* @__PURE__ */ new Set();
667
+ const desired = new Map([...inspectedDesired].filter(([relativePath]) => !shielded.has(relativePath)));
431
668
  const replaced = /* @__PURE__ */ new Map();
432
669
  const absent = [];
433
- for (const [relativePath, entry] of existing) {
434
- if (!desired.has(relativePath)) replaced.set(relativePath, entry);
670
+ for (const [relativePath, entry] of target.existing) {
671
+ if (inspectedDesired.has(relativePath) || entry.kind === "gitlink") continue;
672
+ if (directoryShieldsSubmodule(relativePath, entry, target.git)) continue;
673
+ replaced.set(relativePath, entry);
435
674
  }
436
675
  for (const [relativePath, entry] of desired) {
437
- const target = inspectRelativePathWithoutFollowingAncestors(targetRoot, relativePath);
438
- if (target.kind !== "entry") {
676
+ const targetEntry = inspectRelativePathWithoutFollowingAncestors(targetRoot, relativePath);
677
+ if (targetEntry.kind !== "entry") {
439
678
  absent.push(relativePath);
440
679
  continue;
441
680
  }
442
- const { absolutePath, stat } = target;
443
- if (entry.kind === "directory") {
681
+ const { absolutePath, stat } = targetEntry;
682
+ if (entry.kind === "directory" || entry.kind === "gitlink") {
444
683
  if (stat.isDirectory()) continue;
445
684
  } else if (entry.kind === "symlink") {
446
685
  if (stat.isSymbolicLink() && fs.readlinkSync(absolutePath) === entry.target) continue;
@@ -460,7 +699,8 @@ function planWorkingTreeMirror(input) {
460
699
  return {
461
700
  desired,
462
701
  replaced,
463
- absent: absent.filter((relativePath) => !hasAncestorIn(relativePath, replacedDirectories)).sort(shallowestFirst)
702
+ absent: absent.filter((relativePath) => !hasAncestorIn(relativePath, replacedDirectories)).sort(shallowestFirst),
703
+ index: target.git ? planIndexChanges(inspectedDesired, shielded, target.git) : { removed: [], written: [] }
464
704
  };
465
705
  }
466
706
  function captureWorkingTreeMirrorPlan(input) {
@@ -473,6 +713,7 @@ function captureWorkingTreeMirrorPlan(input) {
473
713
  }
474
714
  const trees = [];
475
715
  for (const [relativePath, entry] of [...input.plan.replaced.entries()].sort(([left], [right]) => shallowestFirst(left, right))) {
716
+ if (entry.kind === "gitlink") continue;
476
717
  if (entry.kind !== "directory") {
477
718
  copyEntry(targetRoot, snapshotRoot, relativePath, entry, durability);
478
719
  continue;
@@ -491,7 +732,12 @@ function captureWorkingTreeMirrorPlan(input) {
491
732
  });
492
733
  trees.push(relativePath);
493
734
  }
494
- return { absent: [...input.plan.absent].sort(shallowestFirst), trees: trees.sort(shallowestFirst) };
735
+ const { index } = input.plan;
736
+ return {
737
+ absent: [...input.plan.absent].sort(shallowestFirst),
738
+ trees: trees.sort(shallowestFirst),
739
+ ...index.removed.length > 0 || index.written.length > 0 ? { index } : {}
740
+ };
495
741
  }
496
742
  function restoreWorkingTreeMirrorScope(input) {
497
743
  const snapshotRoot = path.resolve(input.snapshotRoot);
@@ -516,6 +762,7 @@ function restoreWorkingTreeMirrorScope(input) {
516
762
  const touched = /* @__PURE__ */ new Set();
517
763
  for (const relativePath of [...absent].sort(deepestFirst)) {
518
764
  const inspected = inspectRelativePathWithoutFollowingAncestors(targetRoot, relativePath);
765
+ if (inspected.kind === "entry" && inspected.stat.isDirectory() && lstatIfExists(path.join(inspected.absolutePath, ".git"))) continue;
519
766
  if (inspected.kind === "entry") fs.rmSync(inspected.absolutePath, { recursive: true, force: true });
520
767
  touched.add(relativePath);
521
768
  }
@@ -553,7 +800,7 @@ function restoreWorkingTreeMirrorScope(input) {
553
800
  if (current.kind === "entry" && !current.stat.isDirectory()) {
554
801
  fs.rmSync(current.absolutePath, { recursive: true, force: true });
555
802
  }
556
- ensureDirectory(targetRoot, relativePath, entry.mode);
803
+ ensureDirectory(targetRoot, relativePath, entry.kind === "directory" ? entry.mode : 493);
557
804
  mirrorWorkingTree({
558
805
  sourceRoot: path.join(snapshotRoot, ...relativePath.split("/")),
559
806
  targetRoot: path.join(targetRoot, ...relativePath.split("/")),
@@ -561,6 +808,7 @@ function restoreWorkingTreeMirrorScope(input) {
561
808
  deletionMode: "all"
562
809
  });
563
810
  }
811
+ if (input.scope.index) updateIndex(targetRoot, { drop: input.scope.index.written, put: input.scope.index.removed });
564
812
  return { paths: [...touched].sort(shallowestFirst) };
565
813
  }
566
814
  function fsyncWorkingTreePaths(root, relativePaths) {