@ricsam/r5d-worker 0.0.131 → 0.0.133

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 (47) hide show
  1. package/dist/cjs/atomic-rename.cjs +303 -0
  2. package/dist/cjs/git-blob-hash.cjs +41 -0
  3. package/dist/cjs/main.cjs +187 -38
  4. package/dist/cjs/package.json +1 -1
  5. package/dist/cjs/three-way-merge.cjs +346 -0
  6. package/dist/cjs/working-tree-mirror.cjs +1049 -64
  7. package/dist/cjs/workspace-command-sync-policy.cjs +8 -4
  8. package/dist/cjs/workspace-command-targets.cjs +63 -0
  9. package/dist/cjs/workspace-filesystem-job-types.cjs +11 -1
  10. package/dist/cjs/workspace-filesystem-jobs.cjs +2 -0
  11. package/dist/cjs/workspace-git-sync.cjs +846 -61
  12. package/dist/cjs/workspace-hydration-ledger.cjs +66 -0
  13. package/dist/cjs/workspace-hydration-merge.cjs +433 -0
  14. package/dist/cjs/workspace-hydration-recovery-state.cjs +53 -0
  15. package/dist/cjs/workspace-merge-projection.cjs +81 -10
  16. package/dist/cjs/workspace-project-config-policy.cjs +19 -12
  17. package/dist/mjs/atomic-rename.mjs +261 -0
  18. package/dist/mjs/git-blob-hash.mjs +16 -0
  19. package/dist/mjs/main.mjs +196 -39
  20. package/dist/mjs/package.json +1 -1
  21. package/dist/mjs/three-way-merge.mjs +318 -0
  22. package/dist/mjs/working-tree-mirror.mjs +1035 -64
  23. package/dist/mjs/workspace-command-sync-policy.mjs +8 -4
  24. package/dist/mjs/workspace-command-targets.mjs +37 -0
  25. package/dist/mjs/workspace-filesystem-job-types.mjs +11 -1
  26. package/dist/mjs/workspace-filesystem-jobs.mjs +4 -0
  27. package/dist/mjs/workspace-git-sync.mjs +854 -62
  28. package/dist/mjs/workspace-hydration-ledger.mjs +42 -0
  29. package/dist/mjs/workspace-hydration-merge.mjs +399 -0
  30. package/dist/mjs/workspace-hydration-recovery-state.mjs +29 -0
  31. package/dist/mjs/workspace-merge-projection.mjs +85 -11
  32. package/dist/mjs/workspace-project-config-policy.mjs +16 -10
  33. package/dist/types/atomic-rename.d.ts +78 -0
  34. package/dist/types/git-blob-hash.d.ts +10 -0
  35. package/dist/types/main.d.ts +21 -2
  36. package/dist/types/three-way-merge.d.ts +77 -0
  37. package/dist/types/working-tree-mirror.d.ts +270 -7
  38. package/dist/types/workspace-command-sync-policy.d.ts +12 -6
  39. package/dist/types/workspace-command-targets.d.ts +37 -0
  40. package/dist/types/workspace-filesystem-job-types.d.ts +46 -4
  41. package/dist/types/workspace-git-sync.d.ts +125 -3
  42. package/dist/types/workspace-hydration-ledger.d.ts +43 -0
  43. package/dist/types/workspace-hydration-merge.d.ts +95 -0
  44. package/dist/types/workspace-hydration-recovery-state.d.ts +10 -0
  45. package/dist/types/workspace-merge-projection.d.ts +19 -1
  46. package/dist/types/workspace-project-config-policy.d.ts +17 -3
  47. package/package.json +2 -2
@@ -0,0 +1,42 @@
1
+ class WorkspaceHydrationPreBlobLedger {
2
+ mounts = /* @__PURE__ */ new Map();
3
+ static key(mount) {
4
+ return `${mount.hydrationIncarnationKey}\0${mount.id}`;
5
+ }
6
+ /** Blob ids by mount-relative path for the mount, or an empty map. */
7
+ preBlobs(mount) {
8
+ return this.mounts.get(WorkspaceHydrationPreBlobLedger.key(mount)) ?? /* @__PURE__ */ new Map();
9
+ }
10
+ /** Record the pre-hydration blob of every path a merge hydration replaced; earlier entries for other paths are kept. */
11
+ record(mount, preBlobs) {
12
+ const entries = preBlobs instanceof Map ? [...preBlobs] : Object.entries(preBlobs);
13
+ if (entries.length === 0) return;
14
+ const key = WorkspaceHydrationPreBlobLedger.key(mount);
15
+ const current = this.mounts.get(key) ?? /* @__PURE__ */ new Map();
16
+ for (const [relativePath, blob] of entries) current.set(relativePath, blob);
17
+ this.mounts.set(key, current);
18
+ }
19
+ /** Forget the mount: its checkout was rewritten by something this ledger did not observe. */
20
+ clearMount(mount) {
21
+ this.mounts.delete(WorkspaceHydrationPreBlobLedger.key(mount));
22
+ }
23
+ /** Forget paths whose checkout content moved on from the hydrated bytes: a later return to the old bytes is a deliberate edit. */
24
+ forget(mount, paths) {
25
+ const key = WorkspaceHydrationPreBlobLedger.key(mount);
26
+ const current = this.mounts.get(key);
27
+ if (!current) return;
28
+ for (const relativePath of paths) current.delete(relativePath);
29
+ if (current.size === 0) this.mounts.delete(key);
30
+ }
31
+ clear() {
32
+ this.mounts.clear();
33
+ }
34
+ get size() {
35
+ let total = 0;
36
+ for (const entries of this.mounts.values()) total += entries.size;
37
+ return total;
38
+ }
39
+ }
40
+ export {
41
+ WorkspaceHydrationPreBlobLedger
42
+ };
@@ -0,0 +1,399 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { workerGitProcessEnvironment } from "./git-process-environment.mjs";
4
+ import { gitBlobHash, gitObjectHashAlgorithmFor } from "./git-blob-hash.mjs";
5
+ import { isBinaryContent, mergeThreeWay } from "./three-way-merge.mjs";
6
+ import {
7
+ inspectWorkingTreePath,
8
+ projectedFileEntryMatches,
9
+ workingTreeStatsMatch
10
+ } from "./working-tree-mirror.mjs";
11
+ const RACY_PROJECTION_WINDOW_MS = 2e3;
12
+ class TargetChanged extends Error {
13
+ constructor(relativePath) {
14
+ super(`Working-tree target changed while merge hydration inspected it: ${relativePath}`);
15
+ this.relativePath = relativePath;
16
+ }
17
+ relativePath;
18
+ }
19
+ function isBlobMode(mode) {
20
+ return mode === "100644" || mode === "100755";
21
+ }
22
+ function executableBit(mode) {
23
+ return (mode & 73) !== 0;
24
+ }
25
+ function resolveExecutable(base, ours, theirs) {
26
+ if (ours === base) return theirs;
27
+ if (theirs === base) return ours;
28
+ return ours === theirs ? ours : null;
29
+ }
30
+ function withExecutable(mode, executable) {
31
+ const bits = mode & 511;
32
+ return executable ? bits | 73 : bits & ~73;
33
+ }
34
+ function readOwnedFile(root, relativePath, expected) {
35
+ const inspected = inspectWorkingTreePath(root, relativePath);
36
+ if (inspected.kind !== "entry" || !workingTreeStatsMatch(expected, inspected.stat)) throw new TargetChanged(relativePath);
37
+ const descriptor = fs.openSync(inspected.absolutePath, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
38
+ try {
39
+ const before = fs.fstatSync(descriptor);
40
+ if (!workingTreeStatsMatch(expected, before)) throw new TargetChanged(relativePath);
41
+ const content = Buffer.allocUnsafe(before.size);
42
+ let offset = 0;
43
+ while (offset < before.size) {
44
+ const bytesRead = fs.readSync(descriptor, content, offset, before.size - offset, offset);
45
+ if (bytesRead <= 0) throw new TargetChanged(relativePath);
46
+ offset += bytesRead;
47
+ }
48
+ if (!workingTreeStatsMatch(expected, fs.fstatSync(descriptor))) throw new TargetChanged(relativePath);
49
+ return content;
50
+ } finally {
51
+ fs.closeSync(descriptor);
52
+ }
53
+ }
54
+ function readOuterFile(root, relativePath) {
55
+ const inspected = inspectWorkingTreePath(root, relativePath);
56
+ if (inspected.kind !== "entry" || !inspected.stat.isFile())
57
+ throw new Error(`Outer worktree does not hold a regular file at ${relativePath}`);
58
+ return fs.readFileSync(inspected.absolutePath);
59
+ }
60
+ function currentTargetStat(root, relativePath) {
61
+ const inspected = inspectWorkingTreePath(root, relativePath);
62
+ return inspected.kind === "entry" ? inspected.stat : null;
63
+ }
64
+ function directoryHoldsOnlyVisibleEntries(root, relativePath, existing) {
65
+ const inspected = inspectWorkingTreePath(root, relativePath);
66
+ if (inspected.kind !== "entry" || !inspected.stat.isDirectory()) return true;
67
+ const pending = [{ absolutePath: inspected.absolutePath, relativePath }];
68
+ while (pending.length > 0) {
69
+ const directory = pending.pop();
70
+ for (const name of fs.readdirSync(directory.absolutePath)) {
71
+ const childRelative = `${directory.relativePath}/${name}`;
72
+ if (!existing.has(childRelative)) return false;
73
+ const child = fs.lstatSync(path.join(directory.absolutePath, name));
74
+ if (child.isDirectory() && !child.isSymbolicLink())
75
+ pending.push({ absolutePath: path.join(directory.absolutePath, name), relativePath: childRelative });
76
+ }
77
+ }
78
+ return true;
79
+ }
80
+ function decideWorkspaceHydrationMerge(input) {
81
+ const { prepared, changes, projectedFiles } = input;
82
+ const trustedProjection = (projected, stat) => projectedFileEntryMatches(projected, stat) && (input.readAtMs === void 0 || projected.mtimeMs < input.readAtMs - RACY_PROJECTION_WINDOW_MS && projected.ctimeMs < input.readAtMs - RACY_PROJECTION_WINDOW_MS);
83
+ const { desired, existing, existingStats, shielded, sourceRoot, targetRoot } = prepared;
84
+ const conflicts = /* @__PURE__ */ new Set();
85
+ const write = /* @__PURE__ */ new Set();
86
+ const remove = /* @__PURE__ */ new Set();
87
+ const overrides = /* @__PURE__ */ new Map();
88
+ const expectations = /* @__PURE__ */ new Map();
89
+ const preBlobs = /* @__PURE__ */ new Map();
90
+ const mergedPaths = [];
91
+ const keptPaths = /* @__PURE__ */ new Set();
92
+ const desiredForIndex = new Map(desired);
93
+ let algorithm = "sha1";
94
+ for (const change of changes.values()) {
95
+ const detected = gitObjectHashAlgorithmFor(change.base?.objectId ?? change.theirs?.objectId ?? "");
96
+ if (detected) {
97
+ algorithm = detected;
98
+ break;
99
+ }
100
+ }
101
+ const hashOf = (content) => gitBlobHash(content, algorithm);
102
+ const mergeCandidates = [];
103
+ const directoryReplacements = [];
104
+ const oursState = (relativePath, change) => {
105
+ const ours = existing.get(relativePath);
106
+ const oursStat = existingStats.get(relativePath);
107
+ if (!ours || !oursStat) return change.base ? "deleted" : "absent";
108
+ if (!change.base) return "added";
109
+ const base = change.base;
110
+ if (ours.kind === "file" && isBlobMode(base.mode)) {
111
+ if (executableBit(ours.mode) !== (base.mode === "100755")) return "changed";
112
+ const projected = projectedFiles?.[relativePath];
113
+ if (projected?.kind === "file" && trustedProjection(projected, oursStat)) return "base";
114
+ if (projectedFiles !== null && projected === void 0) return "changed";
115
+ return hashOf(readOwnedFile(targetRoot, relativePath, oursStat)) === base.objectId ? "base" : "changed";
116
+ }
117
+ if (ours.kind === "symlink" && base.mode === "120000") return hashOf(Buffer.from(ours.target)) === base.objectId ? "base" : "changed";
118
+ if (ours.kind === "gitlink" && base.mode === "160000") return ours.objectId === base.objectId ? "base" : "changed";
119
+ return "changed";
120
+ };
121
+ try {
122
+ for (const [relativePath, change] of [...changes].sort(([left], [right]) => left.localeCompare(right))) {
123
+ if (shielded.has(relativePath)) continue;
124
+ const ours = existing.get(relativePath);
125
+ const oursStat = existingStats.get(relativePath);
126
+ const theirsEntry = desired.get(relativePath);
127
+ if (change.theirs && (!theirsEntry || theirsEntry.kind === "directory")) {
128
+ conflicts.add(relativePath);
129
+ continue;
130
+ }
131
+ if (ours?.kind === "directory" && !change.base && change.theirs) {
132
+ directoryReplacements.push(relativePath);
133
+ continue;
134
+ }
135
+ const state = oursState(relativePath, change);
136
+ if (state === "base" || state === "absent") {
137
+ if (!change.theirs) {
138
+ if (ours && oursStat) {
139
+ remove.add(relativePath);
140
+ expectations.set(relativePath, oursStat);
141
+ }
142
+ if (change.base && isBlobMode(change.base.mode)) preBlobs.set(relativePath, change.base.objectId);
143
+ continue;
144
+ }
145
+ write.add(relativePath);
146
+ expectations.set(relativePath, oursStat ?? currentTargetStat(targetRoot, relativePath));
147
+ if (change.base && isBlobMode(change.base.mode) && change.base.objectId !== change.theirs.objectId) {
148
+ preBlobs.set(relativePath, change.base.objectId);
149
+ }
150
+ continue;
151
+ }
152
+ if (state === "deleted") {
153
+ if (change.theirs) conflicts.add(relativePath);
154
+ continue;
155
+ }
156
+ if (!change.theirs) {
157
+ conflicts.add(relativePath);
158
+ continue;
159
+ }
160
+ const theirs = change.theirs;
161
+ if (ours.kind === "file" && isBlobMode(theirs.mode)) {
162
+ const oursBytes = readOwnedFile(targetRoot, relativePath, oursStat);
163
+ const theirsBytes = readOuterFile(sourceRoot, relativePath);
164
+ const oursExecutable = executableBit(ours.mode);
165
+ const theirsExecutable = theirs.mode === "100755";
166
+ if (oursBytes.equals(theirsBytes)) {
167
+ if (oursExecutable === theirsExecutable) {
168
+ keptPaths.add(relativePath);
169
+ continue;
170
+ }
171
+ const baseExecutable = change.base ? change.base.mode === "100755" : null;
172
+ const resolved = baseExecutable === null ? null : resolveExecutable(baseExecutable, oursExecutable, theirsExecutable);
173
+ if (resolved === null) {
174
+ conflicts.add(relativePath);
175
+ continue;
176
+ }
177
+ overrides.set(relativePath, { content: theirsBytes, mode: withExecutable(oursStat.mode, resolved) });
178
+ write.add(relativePath);
179
+ expectations.set(relativePath, oursStat);
180
+ mergedPaths.push(relativePath);
181
+ continue;
182
+ }
183
+ if (!change.base || !isBlobMode(change.base.mode)) {
184
+ conflicts.add(relativePath);
185
+ continue;
186
+ }
187
+ mergeCandidates.push({
188
+ relativePath,
189
+ ours,
190
+ oursStat,
191
+ base: change.base.objectId,
192
+ baseExecutable: change.base.mode === "100755",
193
+ theirs
194
+ });
195
+ continue;
196
+ }
197
+ if (ours.kind === "symlink" && theirs.mode === "120000" && theirsEntry?.kind === "symlink") {
198
+ if (ours.target === theirsEntry.target) keptPaths.add(relativePath);
199
+ else conflicts.add(relativePath);
200
+ continue;
201
+ }
202
+ if (ours.kind === "gitlink" && theirs.mode === "160000") {
203
+ if (ours.objectId === theirs.objectId) keptPaths.add(relativePath);
204
+ else conflicts.add(relativePath);
205
+ continue;
206
+ }
207
+ conflicts.add(relativePath);
208
+ }
209
+ for (const relativePath of directoryReplacements) {
210
+ const prefix = `${relativePath}/`;
211
+ let container = true;
212
+ for (const candidate of existing.keys()) {
213
+ if (candidate.startsWith(prefix) && !remove.has(candidate)) {
214
+ container = false;
215
+ break;
216
+ }
217
+ }
218
+ if (container && !directoryHoldsOnlyVisibleEntries(targetRoot, relativePath, existing)) container = false;
219
+ if (!container) {
220
+ conflicts.add(relativePath);
221
+ continue;
222
+ }
223
+ write.add(relativePath);
224
+ expectations.set(relativePath, existingStats.get(relativePath) ?? null);
225
+ }
226
+ if (mergeCandidates.length > 0) {
227
+ const binaryPaths = input.readBinaryPaths?.(mergeCandidates.map(({ relativePath }) => relativePath));
228
+ const baseBlobs = input.readBaseBlobs([...new Set(mergeCandidates.map(({ base }) => base))]);
229
+ for (const candidate of mergeCandidates) {
230
+ const { relativePath } = candidate;
231
+ const baseBytes = baseBlobs.get(candidate.base);
232
+ if (!baseBytes) throw new Error(`Projected blob ${candidate.base} for ${relativePath} is missing from the workspace clone`);
233
+ const oursBytes = readOwnedFile(targetRoot, relativePath, candidate.oursStat);
234
+ const theirsBytes = readOuterFile(sourceRoot, relativePath);
235
+ if (binaryPaths?.has(relativePath) || isBinaryContent(baseBytes) || isBinaryContent(oursBytes) || isBinaryContent(theirsBytes)) {
236
+ conflicts.add(relativePath);
237
+ continue;
238
+ }
239
+ const merged = mergeThreeWay(baseBytes, oursBytes, theirsBytes);
240
+ if (merged.kind === "conflict") {
241
+ conflicts.add(relativePath);
242
+ continue;
243
+ }
244
+ const resolved = resolveExecutable(
245
+ candidate.baseExecutable,
246
+ executableBit(candidate.ours.mode),
247
+ candidate.theirs.mode === "100755"
248
+ );
249
+ if (resolved === null) {
250
+ conflicts.add(relativePath);
251
+ continue;
252
+ }
253
+ const mode = withExecutable(candidate.oursStat.mode, resolved);
254
+ const contentUnchanged = merged.content.equals(oursBytes);
255
+ if (contentUnchanged && mode === (candidate.oursStat.mode & 511)) {
256
+ keptPaths.add(relativePath);
257
+ continue;
258
+ }
259
+ overrides.set(relativePath, { content: merged.content, mode });
260
+ write.add(relativePath);
261
+ expectations.set(relativePath, candidate.oursStat);
262
+ if (!contentUnchanged) preBlobs.set(relativePath, hashOf(oursBytes));
263
+ mergedPaths.push(relativePath);
264
+ }
265
+ }
266
+ const neededDirectories = /* @__PURE__ */ new Set();
267
+ for (const relativePath of write) {
268
+ let parent = path.posix.dirname(relativePath);
269
+ while (parent !== ".") {
270
+ neededDirectories.add(parent);
271
+ parent = path.posix.dirname(parent);
272
+ }
273
+ }
274
+ for (const [relativePath, entry] of desired) {
275
+ if (shielded.has(relativePath) || changes.has(relativePath)) continue;
276
+ const current = existing.get(relativePath);
277
+ if (entry.kind === "directory") {
278
+ if (!neededDirectories.has(relativePath)) continue;
279
+ if (current && current.kind !== "directory") {
280
+ if (!remove.has(relativePath)) conflicts.add(relativePath);
281
+ continue;
282
+ }
283
+ expectations.set(relativePath, currentTargetStat(targetRoot, relativePath));
284
+ continue;
285
+ }
286
+ if (entry.kind === "gitlink") {
287
+ if (current?.kind === "gitlink") desiredForIndex.set(relativePath, current);
288
+ else {
289
+ const record = prepared.git?.indexRecords.find((candidate) => candidate.relativePath === relativePath && candidate.stage === 0);
290
+ if (record?.mode === "160000") desiredForIndex.set(relativePath, { kind: "gitlink", objectId: record.objectId });
291
+ else desiredForIndex.delete(relativePath);
292
+ }
293
+ }
294
+ if (!current || current.kind !== entry.kind) {
295
+ keptPaths.add(relativePath);
296
+ continue;
297
+ }
298
+ if (current.kind === "file") {
299
+ const stat = existingStats.get(relativePath);
300
+ const projected = projectedFiles?.[relativePath];
301
+ const unchanged = projected?.kind === "file" && stat ? trustedProjection(projected, stat) : projectedFiles === null || projected !== void 0;
302
+ if (!unchanged) keptPaths.add(relativePath);
303
+ }
304
+ }
305
+ for (const [relativePath, entry] of existing) {
306
+ if (desired.has(relativePath) || changes.has(relativePath) || entry.kind === "directory" || shielded.has(relativePath)) continue;
307
+ keptPaths.add(relativePath);
308
+ }
309
+ const blockedDirectories = /* @__PURE__ */ new Set();
310
+ for (const [relativePath, entry] of existing) {
311
+ if (entry.kind === "directory" || remove.has(relativePath)) continue;
312
+ let parent = path.posix.dirname(relativePath);
313
+ while (parent !== ".") {
314
+ blockedDirectories.add(parent);
315
+ parent = path.posix.dirname(parent);
316
+ }
317
+ }
318
+ for (const [relativePath, entry] of existing) {
319
+ if (entry.kind !== "directory" || desired.has(relativePath) || blockedDirectories.has(relativePath)) continue;
320
+ remove.add(relativePath);
321
+ }
322
+ } catch (error) {
323
+ if (error instanceof TargetChanged) return { kind: "target_changed", paths: [error.relativePath] };
324
+ throw error;
325
+ }
326
+ if (conflicts.size > 0) return { kind: "conflict", paths: [...conflicts].sort() };
327
+ for (const relativePath of [...write, ...overrides.keys()]) {
328
+ if (!expectations.has(relativePath)) expectations.set(relativePath, currentTargetStat(targetRoot, relativePath));
329
+ }
330
+ return {
331
+ kind: "apply",
332
+ selection: { write, remove, desiredForIndex },
333
+ overrides,
334
+ expectations,
335
+ preBlobs,
336
+ mergedPaths: mergedPaths.sort(),
337
+ keptPaths: [...keptPaths].sort()
338
+ };
339
+ }
340
+ function readHydrationBinaryPaths(input) {
341
+ const binary = /* @__PURE__ */ new Set();
342
+ if (!input.relativePaths.length) return binary;
343
+ const requested = new Set(input.relativePaths);
344
+ for (const cwd of [input.checkoutRoot, input.outerRoot]) {
345
+ const result = Bun.spawnSync(["git", "check-attr", "-z", "--stdin", "diff"], {
346
+ cwd,
347
+ env: workerGitProcessEnvironment(),
348
+ stdin: Buffer.from(`${input.relativePaths.join("\0")}\0`),
349
+ stdout: "pipe",
350
+ stderr: "pipe"
351
+ });
352
+ if (result.exitCode !== 0) throw new Error(`Cannot inspect hydration binary attributes: ${result.stderr.toString().trim()}`);
353
+ const records = result.stdout.toString().split("\0");
354
+ if (records.pop() !== "" || records.length !== requested.size * 3) throw new Error("Invalid hydration attribute response");
355
+ const seen = /* @__PURE__ */ new Set();
356
+ for (let index = 0; index < records.length; index += 3) {
357
+ const relativePath = records[index];
358
+ if (!requested.has(relativePath) || seen.has(relativePath) || records[index + 1] !== "diff")
359
+ throw new Error("Invalid hydration attribute path");
360
+ seen.add(relativePath);
361
+ if (records[index + 2] === "unset") binary.add(relativePath);
362
+ }
363
+ }
364
+ return binary;
365
+ }
366
+ function parseWorkspaceSubtreeChanges(raw) {
367
+ const changes = /* @__PURE__ */ new Map();
368
+ const records = [];
369
+ let start = 0;
370
+ for (let index = 0; index < raw.length; index += 1) {
371
+ if (raw[index] !== 0) continue;
372
+ if (index > start) records.push(raw.subarray(start, index));
373
+ start = index + 1;
374
+ }
375
+ if (start < raw.length) records.push(raw.subarray(start));
376
+ for (let index = 0; index + 1 < records.length; index += 2) {
377
+ const match = /^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40,64}) ([0-9a-f]{40,64}) ([A-Z])/u.exec(records[index].toString());
378
+ if (!match) throw new Error(`Unexpected diff-tree record: ${records[index].toString()}`);
379
+ const relativePath = records[index + 1].toString();
380
+ if (relativePath.split("/").some((segment) => segment === ".." || segment === "" || segment === ".git")) {
381
+ throw new Error(`Unsafe path in workspace subtree diff: ${relativePath}`);
382
+ }
383
+ const base = match[1] === "000000" ? null : { mode: match[1], objectId: match[3] };
384
+ const theirs = match[2] === "000000" ? null : { mode: match[2], objectId: match[4] };
385
+ changes.set(relativePath, { base, theirs });
386
+ }
387
+ return changes;
388
+ }
389
+ function describeHydrationSkipPaths(mountSourcePath, paths) {
390
+ const shown = paths.slice(0, 5).map((relativePath) => path.join(mountSourcePath, ...relativePath.split("/")));
391
+ return paths.length > 5 ? `${shown.join(", ")} and ${paths.length - 5} more` : shown.join(", ");
392
+ }
393
+ export {
394
+ RACY_PROJECTION_WINDOW_MS,
395
+ decideWorkspaceHydrationMerge,
396
+ describeHydrationSkipPaths,
397
+ parseWorkspaceSubtreeChanges,
398
+ readHydrationBinaryPaths
399
+ };
@@ -0,0 +1,29 @@
1
+ function findHydrationRecovery(error) {
2
+ const pending = [error];
3
+ const seen = /* @__PURE__ */ new Set();
4
+ while (pending.length) {
5
+ const candidate = pending.pop();
6
+ if (!candidate || typeof candidate !== "object" || seen.has(candidate)) continue;
7
+ seen.add(candidate);
8
+ const record = candidate;
9
+ if (record.code === "hydration_recovery_required" && record.hydrationRecovery && typeof record.hydrationRecovery === "object") {
10
+ const evidence = record.hydrationRecovery;
11
+ if (typeof evidence.transactionId === "string" && typeof evidence.transactionPath === "string" && Array.isArray(evidence.mounts)) {
12
+ const retainedPaths = [...new Set(evidence.mounts.flatMap((mount) => mount.retainedPaths ?? []))];
13
+ return {
14
+ transactionId: evidence.transactionId,
15
+ transactionPath: evidence.transactionPath,
16
+ mountIds: evidence.mounts.map(({ id }) => id),
17
+ previousHeads: Object.fromEntries((evidence.receiptBefore?.mounts ?? []).map(({ id, head }) => [id, head])),
18
+ ...retainedPaths.length > 0 ? { retainedPaths } : {}
19
+ };
20
+ }
21
+ }
22
+ pending.push(record.cause);
23
+ if (Array.isArray(record.errors)) pending.push(...record.errors);
24
+ }
25
+ return void 0;
26
+ }
27
+ export {
28
+ findHydrationRecovery
29
+ };
@@ -1,7 +1,10 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { workerGitProcessEnvironment } from "./git-process-environment.mjs";
4
- import { inspectWorkingTree } from "./working-tree-mirror.mjs";
4
+ import {
5
+ inspectWorkingTree,
6
+ projectedFileEntry
7
+ } from "./working-tree-mirror.mjs";
5
8
  import { assertManagedDirectoryPath } from "./workspace-mount-boundary.mjs";
6
9
  const MINIMUM_MERGE_TREE_GIT_VERSION = { major: 2, minor: 40 };
7
10
  const NON_RECURSIVE_GIT_CONFIG = [
@@ -142,7 +145,7 @@ function hashRegularFile(workspacePath, sourcePath, entry) {
142
145
  if (pathStatus.dev !== after.dev || pathStatus.ino !== after.ino || !pathStatus.isFile() || pathStatus.isSymbolicLink()) {
143
146
  throw new Error(`Working-tree source changed while its merge projection was being synthesized: ${sourcePath}`);
144
147
  }
145
- return objectId;
148
+ return { objectId, projected: projectedFileEntry(after) };
146
149
  } finally {
147
150
  fs.closeSync(descriptor);
148
151
  }
@@ -175,6 +178,8 @@ function synthesizeMountTree(input) {
175
178
  const entries = inspectWorkingTree(sourceRoot, input.sourceMode);
176
179
  const indexPath = temporaryIndexPath(input.workspacePath);
177
180
  const environment = temporaryIndexEnvironment(indexPath);
181
+ const projectedFiles = {};
182
+ const blobs = /* @__PURE__ */ new Map();
178
183
  try {
179
184
  git(input.workspacePath, ["read-tree", "--empty"], "initialize workspace projection index", { environment });
180
185
  const records = [];
@@ -182,10 +187,20 @@ function synthesizeMountTree(input) {
182
187
  if (entry.kind === "directory") continue;
183
188
  if (entry.kind === "gitlink") {
184
189
  records.push(indexInfoRecord("160000", entry.objectId, relativePath));
190
+ projectedFiles[relativePath] = { kind: "gitlink", objectId: entry.objectId };
185
191
  continue;
186
192
  }
187
193
  const sourcePath = sourceEntryPath(sourceRoot, relativePath);
188
- const objectId = entry.kind === "file" ? hashRegularFile(input.workspacePath, sourcePath, entry) : hashSymlink(input.workspacePath, sourcePath, entry);
194
+ let objectId;
195
+ if (entry.kind === "file") {
196
+ const hashed = hashRegularFile(input.workspacePath, sourcePath, entry);
197
+ objectId = hashed.objectId;
198
+ projectedFiles[relativePath] = hashed.projected;
199
+ } else {
200
+ objectId = hashSymlink(input.workspacePath, sourcePath, entry);
201
+ projectedFiles[relativePath] = { kind: "symlink", target: entry.target };
202
+ }
203
+ blobs.set(relativePath, objectId);
189
204
  records.push(indexInfoRecord(indexMode(entry), objectId, relativePath));
190
205
  }
191
206
  if (records.length > 0) {
@@ -194,14 +209,54 @@ function synthesizeMountTree(input) {
194
209
  stdin: Buffer.concat(records)
195
210
  });
196
211
  }
197
- return requireObjectId(
212
+ const tree = requireObjectId(
198
213
  gitText(input.workspacePath, ["write-tree"], "write workspace projection mount tree", { environment }),
199
214
  "Workspace projection mount tree"
200
215
  );
216
+ return { tree, projectedFiles, blobs };
201
217
  } finally {
202
218
  removeTemporaryIndex(indexPath);
203
219
  }
204
220
  }
221
+ function staleRewritePaths(input) {
222
+ const entries = Object.entries(input.staleRewriteBlobs ?? {});
223
+ if (entries.length === 0) return { stale: [], cleared: [] };
224
+ const probed = gitText(input.workspacePath, ["cat-file", "--batch-check"], "resolve workspace projection basis subtree", {
225
+ stdin: Buffer.from(`${input.basisHead}:${input.workspaceRelativePath}
226
+ `)
227
+ });
228
+ const basisSubtree = /^([0-9a-f]{40,64}) tree \d+$/u.exec(probed);
229
+ if (!basisSubtree && !/ missing$/u.test(probed))
230
+ throw new Error(`Resolve workspace projection basis subtree: unexpected answer ${probed}`);
231
+ const changed = /* @__PURE__ */ new Set();
232
+ if (!basisSubtree) {
233
+ for (const relativePath of input.blobs.keys()) changed.add(relativePath);
234
+ } else {
235
+ const listing = git(
236
+ input.workspacePath,
237
+ ["diff-tree", "-r", "-z", "--no-renames", "--raw", basisSubtree[1], input.mountTree],
238
+ "compare workspace projection with its basis"
239
+ );
240
+ const records = nulRecords(listing);
241
+ for (let index = 0; index + 1 < records.length; index += 2) changed.add(records[index + 1].toString());
242
+ }
243
+ const stale = [];
244
+ const cleared = [];
245
+ for (const [relativePath, blob] of entries) {
246
+ const synthesized = input.blobs.get(relativePath);
247
+ if (synthesized === void 0) {
248
+ cleared.push(relativePath);
249
+ continue;
250
+ }
251
+ if (synthesized === blob) {
252
+ if (changed.has(relativePath)) stale.push(relativePath);
253
+ else cleared.push(relativePath);
254
+ continue;
255
+ }
256
+ if (changed.has(relativePath)) cleared.push(relativePath);
257
+ }
258
+ return { stale: stale.sort(), cleared: cleared.sort() };
259
+ }
205
260
  function nulRecords(content) {
206
261
  const records = [];
207
262
  let start = 0;
@@ -249,23 +304,32 @@ function graftMountTree(input) {
249
304
  }
250
305
  }
251
306
  function synthesizeOursCommit(input) {
252
- const mountTree = synthesizeMountTree({
307
+ const synthesized = synthesizeMountTree({
253
308
  workspacePath: input.workspacePath,
254
309
  sourcePath: input.mount.sourcePath,
255
310
  sourceMode: input.mount.sourceMode
256
311
  });
312
+ const detected = staleRewritePaths({
313
+ workspacePath: input.workspacePath,
314
+ workspaceRelativePath: input.workspaceRelativePath,
315
+ basisHead: input.basisHead,
316
+ mountTree: synthesized.tree,
317
+ blobs: synthesized.blobs,
318
+ staleRewriteBlobs: input.staleRewriteBlobs
319
+ });
320
+ if (detected.stale.length > 0) return { staleRewritePaths: detected.stale, staleRewriteCleared: detected.cleared };
257
321
  const rootTree = graftMountTree({
258
322
  workspacePath: input.workspacePath,
259
323
  workspaceRelativePath: input.workspaceRelativePath,
260
324
  basisHead: input.basisHead,
261
- mountTree
325
+ mountTree: synthesized.tree
262
326
  });
263
327
  const message = JSON.stringify({
264
328
  type: "workspace_projection_basis",
265
329
  mountId: input.mount.id,
266
330
  attemptId: input.attemptId
267
331
  });
268
- return requireObjectId(
332
+ const oursCommit = requireObjectId(
269
333
  gitText(
270
334
  input.workspacePath,
271
335
  ["commit-tree", rootTree, "-p", input.basisHead, "-m", message],
@@ -273,6 +337,7 @@ function synthesizeOursCommit(input) {
273
337
  ),
274
338
  `Synthesized workspace projection commit for mount ${input.mount.id}`
275
339
  );
340
+ return { oursCommit, projectedFiles: synthesized.projectedFiles, staleRewriteCleared: detected.cleared };
276
341
  }
277
342
  function mergeWorkspaceProjectionMount(input) {
278
343
  const support = workspaceMergeProjectionSupport();
@@ -281,13 +346,19 @@ function mergeWorkspaceProjectionMount(input) {
281
346
  const workspaceRelativePath = normalizeWorkspaceRelativePath(input.mount.workspaceRelativePath);
282
347
  const basisHead = requireCommit(workspacePath, input.basisHead, `workspace projection basis for mount ${input.mount.id}`);
283
348
  const currentHead = requireCommit(workspacePath, input.currentHead, "current workspace projection head");
284
- const oursCommit = synthesizeOursCommit({
349
+ const synthesized = synthesizeOursCommit({
285
350
  workspacePath,
286
351
  mount: input.mount,
287
352
  workspaceRelativePath,
288
353
  basisHead,
289
- attemptId: input.attemptId
354
+ attemptId: input.attemptId,
355
+ ...input.staleRewriteBlobs ? { staleRewriteBlobs: input.staleRewriteBlobs } : {}
290
356
  });
357
+ if ("staleRewritePaths" in synthesized) {
358
+ return { kind: "stale_rewrite", paths: synthesized.staleRewritePaths, staleRewriteCleared: synthesized.staleRewriteCleared };
359
+ }
360
+ const { oursCommit, projectedFiles, staleRewriteCleared } = synthesized;
361
+ const readAtMs = Date.now();
291
362
  const merged = gitResult(workspacePath, [
292
363
  "merge-tree",
293
364
  "--write-tree",
@@ -305,13 +376,16 @@ function mergeWorkspaceProjectionMount(input) {
305
376
  const records = nulRecords(merged.stdout);
306
377
  const resultTree = records.shift()?.toString() ?? "";
307
378
  requireObjectId(resultTree, `Merged workspace projection tree for mount ${input.mount.id}`);
308
- if (merged.exitCode === 0) return { kind: "clean", resultTree, oursCommit };
379
+ if (merged.exitCode === 0) return { kind: "clean", resultTree, oursCommit, projectedFiles, readAtMs, staleRewriteCleared };
309
380
  const conflictPaths = records.map((record) => record.toString()).sort();
310
381
  return {
311
382
  kind: "conflict",
312
383
  oursCommit,
313
384
  conflictPaths,
314
- error: `Workspace projection for mount ${input.mount.id} conflicted with the current workspace head`
385
+ error: `Workspace projection for mount ${input.mount.id} conflicted with the current workspace head`,
386
+ projectedFiles,
387
+ readAtMs,
388
+ staleRewriteCleared
315
389
  };
316
390
  }
317
391
  function materializeWorkspaceProjectionTree(input) {