@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.
- package/dist/cjs/atomic-rename.cjs +303 -0
- package/dist/cjs/git-blob-hash.cjs +41 -0
- package/dist/cjs/main.cjs +187 -38
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/three-way-merge.cjs +346 -0
- package/dist/cjs/working-tree-mirror.cjs +1049 -64
- package/dist/cjs/workspace-command-sync-policy.cjs +8 -4
- package/dist/cjs/workspace-command-targets.cjs +63 -0
- package/dist/cjs/workspace-filesystem-job-types.cjs +11 -1
- package/dist/cjs/workspace-filesystem-jobs.cjs +2 -0
- package/dist/cjs/workspace-git-sync.cjs +846 -61
- package/dist/cjs/workspace-hydration-ledger.cjs +66 -0
- package/dist/cjs/workspace-hydration-merge.cjs +433 -0
- package/dist/cjs/workspace-hydration-recovery-state.cjs +53 -0
- package/dist/cjs/workspace-merge-projection.cjs +81 -10
- package/dist/cjs/workspace-project-config-policy.cjs +19 -12
- package/dist/mjs/atomic-rename.mjs +261 -0
- package/dist/mjs/git-blob-hash.mjs +16 -0
- package/dist/mjs/main.mjs +196 -39
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/three-way-merge.mjs +318 -0
- package/dist/mjs/working-tree-mirror.mjs +1035 -64
- package/dist/mjs/workspace-command-sync-policy.mjs +8 -4
- package/dist/mjs/workspace-command-targets.mjs +37 -0
- package/dist/mjs/workspace-filesystem-job-types.mjs +11 -1
- package/dist/mjs/workspace-filesystem-jobs.mjs +4 -0
- package/dist/mjs/workspace-git-sync.mjs +854 -62
- package/dist/mjs/workspace-hydration-ledger.mjs +42 -0
- package/dist/mjs/workspace-hydration-merge.mjs +399 -0
- package/dist/mjs/workspace-hydration-recovery-state.mjs +29 -0
- package/dist/mjs/workspace-merge-projection.mjs +85 -11
- package/dist/mjs/workspace-project-config-policy.mjs +16 -10
- package/dist/types/atomic-rename.d.ts +78 -0
- package/dist/types/git-blob-hash.d.ts +10 -0
- package/dist/types/main.d.ts +21 -2
- package/dist/types/three-way-merge.d.ts +77 -0
- package/dist/types/working-tree-mirror.d.ts +270 -7
- package/dist/types/workspace-command-sync-policy.d.ts +12 -6
- package/dist/types/workspace-command-targets.d.ts +37 -0
- package/dist/types/workspace-filesystem-job-types.d.ts +46 -4
- package/dist/types/workspace-git-sync.d.ts +125 -3
- package/dist/types/workspace-hydration-ledger.d.ts +43 -0
- package/dist/types/workspace-hydration-merge.d.ts +95 -0
- package/dist/types/workspace-hydration-recovery-state.d.ts +10 -0
- package/dist/types/workspace-merge-projection.d.ts +19 -1
- package/dist/types/workspace-project-config-policy.d.ts +17 -3
- 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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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) {
|