@ricsam/r5d-worker 0.0.46 → 0.0.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/main.cjs +245 -33
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/workspace-sync.cjs +619 -57
- package/dist/mjs/main.mjs +244 -34
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/workspace-sync.mjs +616 -56
- package/dist/types/main.d.ts +40 -3
- package/dist/types/workspace-sync.d.ts +30 -3
- package/package.json +1 -1
|
@@ -32,6 +32,7 @@ __export(workspace_sync_exports, {
|
|
|
32
32
|
WORKSPACE_BRANCH: () => WORKSPACE_BRANCH,
|
|
33
33
|
WORKSPACE_PERIODIC_SCAN_INTERVAL_MS: () => WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
|
|
34
34
|
WorkspaceSyncSingleFlight: () => WorkspaceSyncSingleFlight,
|
|
35
|
+
assertCanonicalCheckoutIndexMatchesWorktree: () => assertCanonicalCheckoutIndexMatchesWorktree,
|
|
35
36
|
calculateWorkspaceDiffFingerprint: () => calculateWorkspaceDiffFingerprint,
|
|
36
37
|
encodeWorkspaceBranch: () => encodeWorkspaceBranch,
|
|
37
38
|
mirrorShadowWorkspaceToVisible: () => mirrorShadowWorkspaceToVisible,
|
|
@@ -39,7 +40,8 @@ __export(workspace_sync_exports, {
|
|
|
39
40
|
synchronizeWorkspace: () => synchronizeWorkspace,
|
|
40
41
|
visibleProjectBranchPath: () => visibleProjectBranchPath,
|
|
41
42
|
workspacePlansRelativePath: () => workspacePlansRelativePath,
|
|
42
|
-
workspaceProjectBranchRelativePath: () => workspaceProjectBranchRelativePath
|
|
43
|
+
workspaceProjectBranchRelativePath: () => workspaceProjectBranchRelativePath,
|
|
44
|
+
workspaceProjectsForSync: () => workspaceProjectsForSync
|
|
43
45
|
});
|
|
44
46
|
module.exports = __toCommonJS(workspace_sync_exports);
|
|
45
47
|
var import_node_crypto = require("node:crypto");
|
|
@@ -58,10 +60,14 @@ const DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO = 0.8;
|
|
|
58
60
|
const ACTIVE_GIT_LOCK_PATHS = ["index.lock", "HEAD.lock", "packed-refs.lock", "shallow.lock"];
|
|
59
61
|
const mirrorComparisonCache = /* @__PURE__ */ new Map();
|
|
60
62
|
function gitArgs(input, args) {
|
|
61
|
-
|
|
63
|
+
const configArgs = ["-c", "submodule.recurse=false", "-c", "fetch.recurseSubmodules=false", "-c", "push.recurseSubmodules=false"];
|
|
64
|
+
return input.authHeader ? ["git", ...configArgs, "-c", `http.extraHeader=${input.authHeader}`, ...args] : ["git", ...configArgs, ...args];
|
|
65
|
+
}
|
|
66
|
+
function withoutSubmoduleRecursion(args) {
|
|
67
|
+
return args[0] === "fetch" || args[0] === "push" ? [args[0], "--no-recurse-submodules", ...args.slice(1)] : args[0] === "clone" ? ["clone", "--no-recurse-submodules", ...args.slice(1)] : args[0] === "checkout" || args[0] === "reset" || args[0] === "restore" ? [args[0], "--no-recurse-submodules", ...args.slice(1)] : args;
|
|
62
68
|
}
|
|
63
69
|
function runGitResult(input, cwd, args) {
|
|
64
|
-
const result = Bun.spawnSync(gitArgs(input, args), {
|
|
70
|
+
const result = Bun.spawnSync(gitArgs(input, withoutSubmoduleRecursion(args)), {
|
|
65
71
|
cwd,
|
|
66
72
|
stdout: "pipe",
|
|
67
73
|
stderr: "pipe",
|
|
@@ -89,6 +95,35 @@ function tryGit(input, cwd, args) {
|
|
|
89
95
|
function encodeWorkspaceBranch(branchName) {
|
|
90
96
|
return encodeURIComponent(branchName);
|
|
91
97
|
}
|
|
98
|
+
function workspaceProjectsForSync(projects, trigger) {
|
|
99
|
+
if (trigger.canonicalCheckoutOnly) {
|
|
100
|
+
if (!trigger.projectId || !trigger.branchName) {
|
|
101
|
+
throw new Error("A canonical-checkout-only synchronization requires projectId and branchName");
|
|
102
|
+
}
|
|
103
|
+
const project = projects.find((candidate) => candidate.projectId === trigger.projectId);
|
|
104
|
+
const checkout = project?.canonicalCheckouts.find((candidate) => candidate.branchName === trigger.branchName);
|
|
105
|
+
if (!project || !project.branches.includes(trigger.branchName) || !checkout) {
|
|
106
|
+
throw new Error(`Canonical resolver checkout ${trigger.projectId}/${trigger.branchName} is not present in this worker manifest`);
|
|
107
|
+
}
|
|
108
|
+
return [
|
|
109
|
+
{
|
|
110
|
+
...project,
|
|
111
|
+
branches: [trigger.branchName],
|
|
112
|
+
canonicalCheckouts: [checkout]
|
|
113
|
+
}
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
return projects.flatMap((project) => {
|
|
117
|
+
const canonicalBranches = new Set(project.canonicalCheckouts.map((checkout) => checkout.branchName));
|
|
118
|
+
const branches = project.branches.filter((branchName) => !canonicalBranches.has(branchName));
|
|
119
|
+
return branches.length > 0 || project.canonicalCheckouts.length > 0 ? [
|
|
120
|
+
{
|
|
121
|
+
...project,
|
|
122
|
+
branches
|
|
123
|
+
}
|
|
124
|
+
] : [];
|
|
125
|
+
});
|
|
126
|
+
}
|
|
92
127
|
function workspaceProjectBranchRelativePath(projectId, branchName) {
|
|
93
128
|
return import_node_path.default.posix.join("projects", projectId, "branches", encodeWorkspaceBranch(branchName));
|
|
94
129
|
}
|
|
@@ -113,12 +148,30 @@ function listGitEligibleFiles(checkoutPath) {
|
|
|
113
148
|
if (!import_node_fs.default.existsSync(import_node_path.default.join(checkoutPath, ".git"))) {
|
|
114
149
|
return [];
|
|
115
150
|
}
|
|
116
|
-
const result = Bun.spawnSync(
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
151
|
+
const result = Bun.spawnSync(
|
|
152
|
+
[
|
|
153
|
+
"git",
|
|
154
|
+
"-c",
|
|
155
|
+
"submodule.recurse=false",
|
|
156
|
+
"-c",
|
|
157
|
+
"fetch.recurseSubmodules=false",
|
|
158
|
+
"-c",
|
|
159
|
+
"push.recurseSubmodules=false",
|
|
160
|
+
"ls-files",
|
|
161
|
+
"-z",
|
|
162
|
+
"--cached",
|
|
163
|
+
"--others",
|
|
164
|
+
"--exclude-standard",
|
|
165
|
+
"--",
|
|
166
|
+
"."
|
|
167
|
+
],
|
|
168
|
+
{
|
|
169
|
+
cwd: checkoutPath,
|
|
170
|
+
stdout: "pipe",
|
|
171
|
+
stderr: "pipe",
|
|
172
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
173
|
+
}
|
|
174
|
+
);
|
|
122
175
|
if (result.exitCode !== 0) {
|
|
123
176
|
throw new Error(`inspect eligible project files: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
|
|
124
177
|
}
|
|
@@ -131,9 +184,144 @@ function listGitEligibleFiles(checkoutPath) {
|
|
|
131
184
|
}
|
|
132
185
|
}).sort();
|
|
133
186
|
}
|
|
187
|
+
function runCheckoutGitRaw(checkoutPath, args, action) {
|
|
188
|
+
const result = Bun.spawnSync(
|
|
189
|
+
["git", "-c", "submodule.recurse=false", "-c", "fetch.recurseSubmodules=false", "-c", "push.recurseSubmodules=false", ...args],
|
|
190
|
+
{
|
|
191
|
+
cwd: checkoutPath,
|
|
192
|
+
stdout: "pipe",
|
|
193
|
+
stderr: "pipe",
|
|
194
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
if (result.exitCode !== 0) {
|
|
198
|
+
throw new Error(`${action}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
|
|
199
|
+
}
|
|
200
|
+
return result.stdout.toString();
|
|
201
|
+
}
|
|
202
|
+
function stagedCheckoutPaths(checkoutPath) {
|
|
203
|
+
return runCheckoutGitRaw(
|
|
204
|
+
checkoutPath,
|
|
205
|
+
["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD"],
|
|
206
|
+
"inspect staged canonical resolver paths"
|
|
207
|
+
).split("\0").filter(Boolean).sort();
|
|
208
|
+
}
|
|
209
|
+
function parseGitIndexEntries(output) {
|
|
210
|
+
return output.split("\0").filter(Boolean).flatMap((entry) => {
|
|
211
|
+
const separator = entry.indexOf(" ");
|
|
212
|
+
if (separator === -1) return [];
|
|
213
|
+
const [mode, objectId, stage] = entry.slice(0, separator).split(" ");
|
|
214
|
+
const filePath = entry.slice(separator + 1);
|
|
215
|
+
return mode && objectId && stage && filePath ? [{ mode, objectId, stage, filePath }] : [];
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
function indexEntriesForPath(checkoutPath, filePath) {
|
|
219
|
+
return parseGitIndexEntries(
|
|
220
|
+
runCheckoutGitRaw(
|
|
221
|
+
checkoutPath,
|
|
222
|
+
["--literal-pathspecs", "ls-files", "--stage", "-z", "--", filePath],
|
|
223
|
+
`inspect canonical resolver index entry for ${JSON.stringify(filePath)}`
|
|
224
|
+
)
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
function checkoutIndexEntries(checkoutPath) {
|
|
228
|
+
return parseGitIndexEntries(runCheckoutGitRaw(checkoutPath, ["ls-files", "--stage", "-z", "--", "."], "inspect project Git index"));
|
|
229
|
+
}
|
|
230
|
+
function checkoutGitlinks(checkoutPath) {
|
|
231
|
+
const entries = checkoutIndexEntries(checkoutPath);
|
|
232
|
+
const unmergedPaths = [...new Set(entries.filter((entry) => entry.stage !== "0").map((entry) => entry.filePath))].sort();
|
|
233
|
+
if (unmergedPaths.length > 0) {
|
|
234
|
+
throw new Error(`Project checkout has unmerged Git index stages: ${JSON.stringify(unmergedPaths)}`);
|
|
235
|
+
}
|
|
236
|
+
return entries.filter((entry) => entry.mode === "160000").map(({ filePath, objectId }) => ({ filePath, objectId })).sort((left, right) => left.filePath.localeCompare(right.filePath));
|
|
237
|
+
}
|
|
238
|
+
function revisionGitlinks(checkoutPath, revision) {
|
|
239
|
+
const output = runCheckoutGitRaw(checkoutPath, ["ls-tree", "-r", "-z", revision, "--", "."], `inspect ${revision} project gitlinks`);
|
|
240
|
+
return output.split("\0").filter(Boolean).flatMap((entry) => {
|
|
241
|
+
const separator = entry.indexOf(" ");
|
|
242
|
+
if (separator === -1) return [];
|
|
243
|
+
const [mode, type, objectId] = entry.slice(0, separator).split(" ");
|
|
244
|
+
const filePath = entry.slice(separator + 1);
|
|
245
|
+
return mode === "160000" && type === "commit" && objectId && filePath ? [{ filePath, objectId }] : [];
|
|
246
|
+
}).sort((left, right) => left.filePath.localeCompare(right.filePath));
|
|
247
|
+
}
|
|
248
|
+
function stagedDeletedGitlinkPaths(checkoutPath) {
|
|
249
|
+
const currentEntries = checkoutIndexEntries(checkoutPath);
|
|
250
|
+
return revisionGitlinks(checkoutPath, "HEAD").filter(
|
|
251
|
+
(entry) => !currentEntries.some((candidate) => candidate.filePath === entry.filePath || candidate.filePath.startsWith(`${entry.filePath}/`))
|
|
252
|
+
).map((entry) => entry.filePath).sort();
|
|
253
|
+
}
|
|
254
|
+
function lstatOrNull(filePath) {
|
|
255
|
+
try {
|
|
256
|
+
return import_node_fs.default.lstatSync(filePath);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
if (error.code === "ENOENT") return null;
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function worktreeDiffersFromIndex(checkoutPath, filePath) {
|
|
263
|
+
const result = Bun.spawnSync(
|
|
264
|
+
["git", "--literal-pathspecs", "-c", "core.fileMode=true", "diff", "--quiet", "--no-ext-diff", "--", filePath],
|
|
265
|
+
{
|
|
266
|
+
cwd: checkoutPath,
|
|
267
|
+
stdout: "ignore",
|
|
268
|
+
stderr: "pipe",
|
|
269
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
if (result.exitCode === 0) return false;
|
|
273
|
+
if (result.exitCode === 1) return true;
|
|
274
|
+
throw new Error(
|
|
275
|
+
`compare canonical resolver index and worktree for ${JSON.stringify(filePath)}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
function indexEntryTypeMatchesWorktree(entry, stat) {
|
|
279
|
+
if (entry.mode === "120000") return stat.isSymbolicLink();
|
|
280
|
+
if (entry.mode !== "100644" && entry.mode !== "100755") return false;
|
|
281
|
+
if (!stat.isFile()) return false;
|
|
282
|
+
return entry.mode === "100755" === ((stat.mode & 73) !== 0);
|
|
283
|
+
}
|
|
284
|
+
function assertCanonicalCheckoutIndexMatchesWorktree(checkoutPath) {
|
|
285
|
+
checkoutGitlinks(checkoutPath);
|
|
286
|
+
const mismatchedPaths = [];
|
|
287
|
+
for (const filePath of stagedCheckoutPaths(checkoutPath)) {
|
|
288
|
+
const entries = indexEntriesForPath(checkoutPath, filePath);
|
|
289
|
+
const worktreeEntry = lstatOrNull(import_node_path.default.join(checkoutPath, ...filePath.split("/")));
|
|
290
|
+
if (entries.length === 0) {
|
|
291
|
+
if (worktreeEntry && !worktreeEntry.isDirectory()) mismatchedPaths.push(filePath);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
if (entries.length === 1 && entries[0]?.stage === "0" && entries[0].mode === "160000") {
|
|
295
|
+
if (worktreeEntry && !worktreeEntry.isDirectory()) mismatchedPaths.push(filePath);
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (entries.length !== 1 || entries[0]?.stage !== "0" || !worktreeEntry || !indexEntryTypeMatchesWorktree(entries[0], worktreeEntry) || worktreeDiffersFromIndex(checkoutPath, filePath)) {
|
|
299
|
+
mismatchedPaths.push(filePath);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (mismatchedPaths.length > 0) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Canonical resolver checkout has staged Git index state that is not represented by its worktree: ${JSON.stringify(
|
|
305
|
+
mismatchedPaths
|
|
306
|
+
)}. Make the worktree match the index or unstage these paths before synchronizing.`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
134
310
|
function checkoutGitSnapshot(checkoutPath) {
|
|
135
311
|
const gitPaths = Bun.spawnSync(
|
|
136
|
-
[
|
|
312
|
+
[
|
|
313
|
+
"git",
|
|
314
|
+
"-c",
|
|
315
|
+
"submodule.recurse=false",
|
|
316
|
+
"-c",
|
|
317
|
+
"fetch.recurseSubmodules=false",
|
|
318
|
+
"-c",
|
|
319
|
+
"push.recurseSubmodules=false",
|
|
320
|
+
"rev-parse",
|
|
321
|
+
"--git-path",
|
|
322
|
+
"index",
|
|
323
|
+
...ACTIVE_GIT_LOCK_PATHS.flatMap((lock) => ["--git-path", lock])
|
|
324
|
+
],
|
|
137
325
|
{
|
|
138
326
|
cwd: checkoutPath,
|
|
139
327
|
stdout: "pipe",
|
|
@@ -146,12 +334,26 @@ function checkoutGitSnapshot(checkoutPath) {
|
|
|
146
334
|
if (!indexPath || lockPaths.length !== ACTIVE_GIT_LOCK_PATHS.length || lockPaths.some((lockPath) => import_node_fs.default.existsSync(lockPath))) {
|
|
147
335
|
return null;
|
|
148
336
|
}
|
|
149
|
-
const head = Bun.spawnSync(
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
337
|
+
const head = Bun.spawnSync(
|
|
338
|
+
[
|
|
339
|
+
"git",
|
|
340
|
+
"-c",
|
|
341
|
+
"submodule.recurse=false",
|
|
342
|
+
"-c",
|
|
343
|
+
"fetch.recurseSubmodules=false",
|
|
344
|
+
"-c",
|
|
345
|
+
"push.recurseSubmodules=false",
|
|
346
|
+
"rev-parse",
|
|
347
|
+
"--verify",
|
|
348
|
+
"HEAD"
|
|
349
|
+
],
|
|
350
|
+
{
|
|
351
|
+
cwd: checkoutPath,
|
|
352
|
+
stdout: "pipe",
|
|
353
|
+
stderr: "ignore",
|
|
354
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
355
|
+
}
|
|
356
|
+
);
|
|
155
357
|
let indexSignature = "missing";
|
|
156
358
|
try {
|
|
157
359
|
const stat = import_node_fs.default.statSync(indexPath);
|
|
@@ -167,7 +369,10 @@ function listShadowTrackedFiles(input, relativeRoot) {
|
|
|
167
369
|
const prefix = `${normalizedRoot}/`;
|
|
168
370
|
return output.split("\0").filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length)).filter(Boolean).sort();
|
|
169
371
|
}
|
|
170
|
-
function
|
|
372
|
+
function pathIsWithinOpaqueRoot(relativePath, opaqueRoots) {
|
|
373
|
+
return opaqueRoots.some((root) => relativePath === root || relativePath.startsWith(`${root}/`));
|
|
374
|
+
}
|
|
375
|
+
function listFilesRecursively(root, filter, opaqueRoots = []) {
|
|
171
376
|
if (!import_node_fs.default.existsSync(root)) return [];
|
|
172
377
|
const files = [];
|
|
173
378
|
const visit = (current, relativeDir) => {
|
|
@@ -175,6 +380,7 @@ function listFilesRecursively(root, filter) {
|
|
|
175
380
|
if (entry.name === ".git") continue;
|
|
176
381
|
const relativePath = relativeDir ? import_node_path.default.posix.join(relativeDir, entry.name) : entry.name;
|
|
177
382
|
const absolutePath = import_node_path.default.join(current, entry.name);
|
|
383
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) continue;
|
|
178
384
|
if (entry.isDirectory()) {
|
|
179
385
|
visit(absolutePath, relativePath);
|
|
180
386
|
} else if (!filter || filter(relativePath)) {
|
|
@@ -185,14 +391,19 @@ function listFilesRecursively(root, filter) {
|
|
|
185
391
|
visit(root, "");
|
|
186
392
|
return files.sort();
|
|
187
393
|
}
|
|
188
|
-
function removeEmptyDirectories(root) {
|
|
394
|
+
function removeEmptyDirectories(root, opaqueRoots = []) {
|
|
189
395
|
if (!import_node_fs.default.existsSync(root)) return;
|
|
190
|
-
const visit = (current) => {
|
|
396
|
+
const visit = (current, relativeDir) => {
|
|
191
397
|
let empty = true;
|
|
192
398
|
for (const entry of import_node_fs.default.readdirSync(current, { withFileTypes: true })) {
|
|
193
399
|
const absolutePath = import_node_path.default.join(current, entry.name);
|
|
400
|
+
const relativePath = relativeDir ? import_node_path.default.posix.join(relativeDir, entry.name) : entry.name;
|
|
401
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) {
|
|
402
|
+
empty = false;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
194
405
|
if (entry.isDirectory()) {
|
|
195
|
-
if (!visit(absolutePath)) empty = false;
|
|
406
|
+
if (!visit(absolutePath, relativePath)) empty = false;
|
|
196
407
|
} else {
|
|
197
408
|
empty = false;
|
|
198
409
|
}
|
|
@@ -200,7 +411,7 @@ function removeEmptyDirectories(root) {
|
|
|
200
411
|
if (empty && current !== root) import_node_fs.default.rmdirSync(current);
|
|
201
412
|
return empty;
|
|
202
413
|
};
|
|
203
|
-
visit(root);
|
|
414
|
+
visit(root, "");
|
|
204
415
|
}
|
|
205
416
|
function copyEntry(sourceRoot, targetRoot, relativePath) {
|
|
206
417
|
const sourcePath = import_node_path.default.resolve(sourceRoot, ...relativePath.split("/"));
|
|
@@ -270,56 +481,218 @@ function entriesEqual(sourcePath, targetPath) {
|
|
|
270
481
|
}
|
|
271
482
|
return equal;
|
|
272
483
|
}
|
|
273
|
-
function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles) {
|
|
484
|
+
function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles, opaqueTargetRoots = [], opaqueSourceRoots = []) {
|
|
274
485
|
import_node_fs.default.mkdirSync(targetRoot, { recursive: true });
|
|
275
486
|
const sourceSet = new Set(sourceFiles);
|
|
276
487
|
for (const relativePath of targetFiles) {
|
|
488
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
|
|
277
489
|
if (sourceSet.has(relativePath)) continue;
|
|
278
490
|
const targetPath = import_node_path.default.resolve(targetRoot, ...relativePath.split("/"));
|
|
279
491
|
assertInside(targetRoot, targetPath, "Workspace deletion path");
|
|
280
492
|
import_node_fs.default.rmSync(targetPath, { recursive: true, force: true });
|
|
281
493
|
}
|
|
282
494
|
for (const relativePath of sourceFiles) {
|
|
495
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueSourceRoots)) continue;
|
|
496
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
|
|
283
497
|
const sourcePath = import_node_path.default.resolve(sourceRoot, ...relativePath.split("/"));
|
|
284
|
-
|
|
285
|
-
|
|
498
|
+
const stat = lstatOrNull(sourcePath);
|
|
499
|
+
if (!stat) continue;
|
|
286
500
|
if (!stat.isFile() && !stat.isSymbolicLink()) continue;
|
|
287
501
|
const targetPath = import_node_path.default.resolve(targetRoot, ...relativePath.split("/"));
|
|
288
502
|
if (entriesEqual(sourcePath, targetPath)) continue;
|
|
289
503
|
copyEntry(sourceRoot, targetRoot, relativePath);
|
|
290
504
|
}
|
|
291
|
-
removeEmptyDirectories(targetRoot);
|
|
505
|
+
removeEmptyDirectories(targetRoot, opaqueTargetRoots);
|
|
506
|
+
}
|
|
507
|
+
function indexPathsUnderCheckoutPath(checkoutPath, filePath) {
|
|
508
|
+
return runCheckoutGitRaw(
|
|
509
|
+
checkoutPath,
|
|
510
|
+
["--literal-pathspecs", "ls-files", "-z", "--", filePath],
|
|
511
|
+
`inspect index paths beneath ${JSON.stringify(filePath)}`
|
|
512
|
+
).split("\0").filter(Boolean);
|
|
513
|
+
}
|
|
514
|
+
function replaceCheckoutIndexPathWithGitlink(checkoutPath, gitlink) {
|
|
515
|
+
for (const indexPath of indexPathsUnderCheckoutPath(checkoutPath, gitlink.filePath)) {
|
|
516
|
+
runCheckoutGitRaw(
|
|
517
|
+
checkoutPath,
|
|
518
|
+
["--literal-pathspecs", "update-index", "--force-remove", "--", indexPath],
|
|
519
|
+
`remove index entry beneath gitlink ${JSON.stringify(gitlink.filePath)}`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
runCheckoutGitRaw(
|
|
523
|
+
checkoutPath,
|
|
524
|
+
["--literal-pathspecs", "update-index", "--add", "--cacheinfo", "160000", gitlink.objectId, gitlink.filePath],
|
|
525
|
+
`record gitlink ${JSON.stringify(gitlink.filePath)}`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
function removeCheckoutIndexPath(checkoutPath, filePath) {
|
|
529
|
+
runCheckoutGitRaw(
|
|
530
|
+
checkoutPath,
|
|
531
|
+
["--literal-pathspecs", "update-index", "--force-remove", "--", filePath],
|
|
532
|
+
`remove gitlink ${JSON.stringify(filePath)}`
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
function shadowProjectGitlinks(input, relativeRoot) {
|
|
536
|
+
const prefix = `${relativeRoot}/`;
|
|
537
|
+
const entries = parseGitIndexEntries(
|
|
538
|
+
runGit(input, input.shadowRoot, ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", relativeRoot], "inspect workspace gitlinks")
|
|
539
|
+
);
|
|
540
|
+
const unmergedPaths = [...new Set(entries.filter((entry) => entry.stage !== "0").map((entry) => entry.filePath))].sort();
|
|
541
|
+
if (unmergedPaths.length > 0) {
|
|
542
|
+
throw new Error(`Canonical workspace has unmerged Git index stages: ${JSON.stringify(unmergedPaths)}`);
|
|
543
|
+
}
|
|
544
|
+
return entries.filter((entry) => entry.mode === "160000" && entry.filePath.startsWith(prefix)).map((entry) => ({ filePath: entry.filePath.slice(prefix.length), objectId: entry.objectId })).sort((left, right) => left.filePath.localeCompare(right.filePath));
|
|
545
|
+
}
|
|
546
|
+
function replaceShadowIndexPathWithGitlink(input, relativeRoot, gitlink) {
|
|
547
|
+
const workspacePath = import_node_path.default.posix.join(relativeRoot, gitlink.filePath);
|
|
548
|
+
const existingPaths = runGit(
|
|
549
|
+
input,
|
|
550
|
+
input.shadowRoot,
|
|
551
|
+
["--literal-pathspecs", "ls-files", "-z", "--", workspacePath],
|
|
552
|
+
`inspect workspace index beneath gitlink ${JSON.stringify(workspacePath)}`
|
|
553
|
+
).split("\0").filter(Boolean);
|
|
554
|
+
for (const indexPath of existingPaths) {
|
|
555
|
+
runGit(
|
|
556
|
+
input,
|
|
557
|
+
input.shadowRoot,
|
|
558
|
+
["--literal-pathspecs", "update-index", "--force-remove", "--", indexPath],
|
|
559
|
+
`remove workspace index entry beneath gitlink ${JSON.stringify(workspacePath)}`
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
const placeholderPath = import_node_path.default.join(input.shadowRoot, ...workspacePath.split("/"));
|
|
563
|
+
import_node_fs.default.rmSync(placeholderPath, { recursive: true, force: true });
|
|
564
|
+
import_node_fs.default.mkdirSync(placeholderPath, { recursive: true });
|
|
565
|
+
runGit(
|
|
566
|
+
input,
|
|
567
|
+
input.shadowRoot,
|
|
568
|
+
["--literal-pathspecs", "update-index", "--add", "--cacheinfo", "160000", gitlink.objectId, workspacePath],
|
|
569
|
+
`record workspace gitlink ${JSON.stringify(workspacePath)}`
|
|
570
|
+
);
|
|
571
|
+
}
|
|
572
|
+
function mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks) {
|
|
573
|
+
const desiredByPath = new Map(visibleGitlinks.map((entry) => [entry.filePath, entry]));
|
|
574
|
+
for (const current of shadowProjectGitlinks(input, relativeRoot)) {
|
|
575
|
+
if (desiredByPath.has(current.filePath)) continue;
|
|
576
|
+
const workspacePath = import_node_path.default.posix.join(relativeRoot, current.filePath);
|
|
577
|
+
runGit(
|
|
578
|
+
input,
|
|
579
|
+
input.shadowRoot,
|
|
580
|
+
["--literal-pathspecs", "update-index", "--force-remove", "--", workspacePath],
|
|
581
|
+
`remove deleted workspace gitlink ${JSON.stringify(workspacePath)}`
|
|
582
|
+
);
|
|
583
|
+
import_node_fs.default.rmSync(import_node_path.default.join(input.shadowRoot, ...workspacePath.split("/")), { recursive: true, force: true });
|
|
584
|
+
}
|
|
585
|
+
for (const gitlink of visibleGitlinks) replaceShadowIndexPathWithGitlink(input, relativeRoot, gitlink);
|
|
586
|
+
}
|
|
587
|
+
function mirrorShadowGitlinksToVisible(desired, visibleRoot) {
|
|
588
|
+
const desiredByPath = new Map(desired.map((entry) => [entry.filePath, entry]));
|
|
589
|
+
for (const current of checkoutGitlinks(visibleRoot)) {
|
|
590
|
+
if (!desiredByPath.has(current.filePath)) removeCheckoutIndexPath(visibleRoot, current.filePath);
|
|
591
|
+
}
|
|
592
|
+
for (const gitlink of desired) {
|
|
593
|
+
const visiblePath = import_node_path.default.join(visibleRoot, ...gitlink.filePath.split("/"));
|
|
594
|
+
const existing = lstatOrNull(visiblePath);
|
|
595
|
+
if (existing && !existing.isDirectory()) import_node_fs.default.rmSync(visiblePath, { recursive: true, force: true });
|
|
596
|
+
import_node_fs.default.mkdirSync(visiblePath, { recursive: true });
|
|
597
|
+
replaceCheckoutIndexPathWithGitlink(visibleRoot, gitlink);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function assertSafeVisibleGitlinkTransitions(visibleRoot, currentGitlinks, desiredGitlinks, shadowFiles) {
|
|
601
|
+
const desiredGitlinkPaths = new Set(desiredGitlinks.map((entry) => entry.filePath));
|
|
602
|
+
for (const current of currentGitlinks) {
|
|
603
|
+
if (desiredGitlinkPaths.has(current.filePath)) continue;
|
|
604
|
+
const becomesOrdinaryPath = shadowFiles.some(
|
|
605
|
+
(filePath) => filePath === current.filePath || filePath.startsWith(`${current.filePath}/`)
|
|
606
|
+
);
|
|
607
|
+
if (!becomesOrdinaryPath) continue;
|
|
608
|
+
const existing = lstatOrNull(import_node_path.default.join(visibleRoot, ...current.filePath.split("/")));
|
|
609
|
+
if (!existing) continue;
|
|
610
|
+
if (existing.isDirectory() && import_node_fs.default.readdirSync(import_node_path.default.join(visibleRoot, ...current.filePath.split("/"))).length === 0) {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
throw new Error(
|
|
614
|
+
`Cannot replace initialized or nonempty gitlink ${JSON.stringify(
|
|
615
|
+
current.filePath
|
|
616
|
+
)} with ordinary workspace content without deleting submodule data`
|
|
617
|
+
);
|
|
618
|
+
}
|
|
292
619
|
}
|
|
293
620
|
function restoreShadowProjectSnapshot(input, relativeRoot) {
|
|
294
621
|
const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
295
622
|
const headFiles = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", "HEAD", "--", relativeRoot]);
|
|
296
623
|
if (headFiles.exitCode === 0 && headFiles.stdout) {
|
|
297
|
-
runGit(
|
|
624
|
+
runGit(
|
|
625
|
+
input,
|
|
626
|
+
input.shadowRoot,
|
|
627
|
+
["restore", "--source=HEAD", "--staged", "--worktree", "--", relativeRoot],
|
|
628
|
+
"restore deferred checkout scan"
|
|
629
|
+
);
|
|
298
630
|
runGit(input, input.shadowRoot, ["clean", "-fd", "--", relativeRoot], "clean deferred checkout scan");
|
|
299
631
|
return;
|
|
300
632
|
}
|
|
633
|
+
runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
|
|
301
634
|
import_node_fs.default.rmSync(shadowRoot, { recursive: true, force: true });
|
|
302
635
|
}
|
|
303
636
|
function mirrorVisibleProjectToShadow(input, manifest, branchName) {
|
|
304
637
|
const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
|
|
305
|
-
if (!import_node_fs.default.existsSync(import_node_path.default.join(visibleRoot, ".git"))) return;
|
|
638
|
+
if (!import_node_fs.default.existsSync(import_node_path.default.join(visibleRoot, ".git"))) return { entries: [], opaqueRoots: [] };
|
|
639
|
+
const isCanonicalCheckout = manifest.canonicalCheckouts?.some((checkout) => checkout.branchName === branchName);
|
|
640
|
+
if (isCanonicalCheckout) {
|
|
641
|
+
assertCanonicalCheckoutIndexMatchesWorktree(visibleRoot);
|
|
642
|
+
}
|
|
306
643
|
const beforeSnapshot = checkoutGitSnapshot(visibleRoot);
|
|
307
|
-
if (!beforeSnapshot) return;
|
|
308
|
-
const
|
|
644
|
+
if (!beforeSnapshot) return { entries: [], opaqueRoots: [] };
|
|
645
|
+
const visibleGitlinks = checkoutGitlinks(visibleRoot);
|
|
646
|
+
const visibleIndexEntries = checkoutIndexEntries(visibleRoot).filter((entry) => entry.stage === "0");
|
|
647
|
+
const indexedGitlinkPaths = new Set(visibleGitlinks.map((entry) => entry.filePath));
|
|
648
|
+
const stagedDeletedGitlinkRoots = stagedDeletedGitlinkPaths(visibleRoot);
|
|
309
649
|
const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
|
|
650
|
+
const shadowGitlinkRoots = shadowProjectGitlinks(input, relativeRoot).filter(
|
|
651
|
+
(entry) => !indexedGitlinkPaths.has(entry.filePath) && !visibleIndexEntries.some(
|
|
652
|
+
(candidate) => candidate.mode !== "160000" && (candidate.filePath === entry.filePath || candidate.filePath.startsWith(`${entry.filePath}/`))
|
|
653
|
+
)
|
|
654
|
+
).map((entry) => entry.filePath);
|
|
655
|
+
const visibleGitlinkRoots = [.../* @__PURE__ */ new Set([...indexedGitlinkPaths, ...stagedDeletedGitlinkRoots, ...shadowGitlinkRoots])].sort();
|
|
656
|
+
const visibleFiles = listGitEligibleFiles(visibleRoot).filter((filePath) => !pathIsWithinOpaqueRoot(filePath, visibleGitlinkRoots));
|
|
310
657
|
const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
311
|
-
|
|
658
|
+
mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks);
|
|
659
|
+
mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot), visibleGitlinkRoots, visibleGitlinkRoots);
|
|
312
660
|
if (checkoutGitSnapshot(visibleRoot) !== beforeSnapshot) {
|
|
313
661
|
restoreShadowProjectSnapshot(input, relativeRoot);
|
|
662
|
+
return { entries: [], opaqueRoots: [] };
|
|
314
663
|
}
|
|
664
|
+
return {
|
|
665
|
+
entries: visibleGitlinks.map((entry) => ({
|
|
666
|
+
filePath: import_node_path.default.posix.join(relativeRoot, entry.filePath),
|
|
667
|
+
objectId: entry.objectId
|
|
668
|
+
})),
|
|
669
|
+
opaqueRoots: visibleGitlinkRoots.map((filePath) => import_node_path.default.posix.join(relativeRoot, filePath))
|
|
670
|
+
};
|
|
315
671
|
}
|
|
316
|
-
function mirrorShadowProjectToVisible(input, manifest, branchName) {
|
|
672
|
+
function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpaqueRoots = []) {
|
|
317
673
|
const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
|
|
318
674
|
if (!import_node_fs.default.existsSync(import_node_path.default.join(visibleRoot, ".git"))) return;
|
|
675
|
+
checkoutGitlinks(visibleRoot);
|
|
319
676
|
const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
|
|
320
677
|
const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
321
678
|
const shadowFiles = listShadowTrackedFiles(input, relativeRoot);
|
|
322
|
-
|
|
679
|
+
const desiredGitlinks = shadowProjectGitlinks(input, relativeRoot);
|
|
680
|
+
const desiredGitlinkPaths = new Set(desiredGitlinks.map((entry) => entry.filePath));
|
|
681
|
+
const visibleGitlinks = checkoutGitlinks(visibleRoot);
|
|
682
|
+
const protectedGitlinkRoots = [
|
|
683
|
+
.../* @__PURE__ */ new Set([...visibleGitlinks.map((entry) => entry.filePath), ...stagedDeletedGitlinkPaths(visibleRoot), ...additionalOpaqueRoots])
|
|
684
|
+
].sort();
|
|
685
|
+
assertSafeVisibleGitlinkTransitions(
|
|
686
|
+
visibleRoot,
|
|
687
|
+
protectedGitlinkRoots.map((filePath) => ({ filePath, objectId: "" })),
|
|
688
|
+
desiredGitlinks,
|
|
689
|
+
shadowFiles
|
|
690
|
+
);
|
|
691
|
+
const opaqueVisibleGitlinks = protectedGitlinkRoots.filter(
|
|
692
|
+
(filePath) => desiredGitlinkPaths.has(filePath) || !shadowFiles.some((shadowFile) => shadowFile === filePath || shadowFile.startsWith(`${filePath}/`))
|
|
693
|
+
);
|
|
694
|
+
mirrorFileSet(shadowRoot, visibleRoot, shadowFiles, listGitEligibleFiles(visibleRoot), opaqueVisibleGitlinks);
|
|
695
|
+
mirrorShadowGitlinksToVisible(desiredGitlinks, visibleRoot);
|
|
323
696
|
}
|
|
324
697
|
function mirrorLocalPlansToShadow(input, manifest, branchName) {
|
|
325
698
|
const sourceRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
|
|
@@ -340,25 +713,36 @@ function mirrorShadowPlansToLocal(input, manifest, branchName) {
|
|
|
340
713
|
listFilesRecursively(targetRoot, planFilter)
|
|
341
714
|
);
|
|
342
715
|
}
|
|
716
|
+
function mergeGitlinkProjection(target, source) {
|
|
717
|
+
target.entries.push(...source.entries);
|
|
718
|
+
target.opaqueRoots.push(...source.opaqueRoots);
|
|
719
|
+
}
|
|
343
720
|
function mirrorVisibleWorkspaceToShadow(input, excludedCheckouts = /* @__PURE__ */ new Set()) {
|
|
721
|
+
const projection = { entries: [], opaqueRoots: [] };
|
|
344
722
|
for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
|
|
345
723
|
for (const branchName of [...new Set(manifest.branches)].sort()) {
|
|
346
724
|
if (excludedCheckouts.has(`${manifest.projectId}\0${branchName}`)) continue;
|
|
347
|
-
mirrorVisibleProjectToShadow(input, manifest, branchName);
|
|
348
|
-
mirrorLocalPlansToShadow(input, manifest, branchName);
|
|
725
|
+
mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, branchName));
|
|
726
|
+
if (!input.trigger.canonicalCheckoutOnly) mirrorLocalPlansToShadow(input, manifest, branchName);
|
|
349
727
|
}
|
|
350
728
|
}
|
|
729
|
+
projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
|
|
730
|
+
return projection;
|
|
351
731
|
}
|
|
352
732
|
function reconcileNewVisibleCheckouts(input) {
|
|
733
|
+
const projection = { entries: [], opaqueRoots: [] };
|
|
353
734
|
for (const target of input.newVisibleCheckouts ?? []) {
|
|
354
735
|
const manifest = input.projects.find((project) => project.projectId === target.projectId);
|
|
355
736
|
if (!manifest || !manifest.branches.includes(target.branchName)) continue;
|
|
356
737
|
const projectRoot = workspaceProjectBranchRelativePath(target.projectId, target.branchName);
|
|
357
|
-
if (
|
|
738
|
+
if (input.trigger.canonicalCheckoutOnly) {
|
|
739
|
+
mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
|
|
740
|
+
} else if (listShadowTrackedFiles(input, projectRoot).length > 0 || restoreShadowRootFromRemote(input, projectRoot)) {
|
|
358
741
|
mirrorShadowProjectToVisible(input, manifest, target.branchName);
|
|
359
742
|
} else {
|
|
360
|
-
mirrorVisibleProjectToShadow(input, manifest, target.branchName);
|
|
743
|
+
mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
|
|
361
744
|
}
|
|
745
|
+
if (input.trigger.canonicalCheckoutOnly) continue;
|
|
362
746
|
const plansRoot = workspacePlansRelativePath(target.projectId, target.branchName);
|
|
363
747
|
if (listShadowTrackedFiles(input, plansRoot).length > 0 || restoreShadowRootFromRemote(input, plansRoot)) {
|
|
364
748
|
mirrorShadowPlansToLocal(input, manifest, target.branchName);
|
|
@@ -366,12 +750,93 @@ function reconcileNewVisibleCheckouts(input) {
|
|
|
366
750
|
mirrorLocalPlansToShadow(input, manifest, target.branchName);
|
|
367
751
|
}
|
|
368
752
|
}
|
|
753
|
+
projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
|
|
754
|
+
return projection;
|
|
369
755
|
}
|
|
370
|
-
function mirrorShadowWorkspaceToVisible(input) {
|
|
756
|
+
function mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots = []) {
|
|
371
757
|
for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
|
|
372
758
|
for (const branchName of [...new Set(manifest.branches)].sort()) {
|
|
373
|
-
|
|
374
|
-
|
|
759
|
+
const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
|
|
760
|
+
const opaqueProjectRoots = opaqueWorkspaceRoots.filter((root) => root.startsWith(`${relativeRoot}/`)).map((root) => root.slice(relativeRoot.length + 1));
|
|
761
|
+
mirrorShadowProjectToVisible(input, manifest, branchName, opaqueProjectRoots);
|
|
762
|
+
if (!input.trigger.canonicalCheckoutOnly) mirrorShadowPlansToLocal(input, manifest, branchName);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function forceStageCanonicalCheckoutFiles(input, preservedGitlinks = [], opaqueRoots = []) {
|
|
767
|
+
for (const manifest of input.projects) {
|
|
768
|
+
for (const checkout of manifest.canonicalCheckouts ?? []) {
|
|
769
|
+
if (!manifest.branches.includes(checkout.branchName)) continue;
|
|
770
|
+
const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, checkout.branchName);
|
|
771
|
+
const absoluteRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
772
|
+
if (!import_node_fs.default.existsSync(absoluteRoot)) continue;
|
|
773
|
+
const scopedOpaqueRoots = opaqueRoots.filter((root) => root.startsWith(`${relativeRoot}/`)).map((root) => root.slice(relativeRoot.length + 1));
|
|
774
|
+
runGit(
|
|
775
|
+
input,
|
|
776
|
+
input.shadowRoot,
|
|
777
|
+
[
|
|
778
|
+
"add",
|
|
779
|
+
"-f",
|
|
780
|
+
"-A",
|
|
781
|
+
"--",
|
|
782
|
+
`:(literal)${relativeRoot}`,
|
|
783
|
+
...scopedOpaqueRoots.map((root) => `:(exclude,literal)${import_node_path.default.posix.join(relativeRoot, root)}`)
|
|
784
|
+
],
|
|
785
|
+
`force-stage canonical resolver checkout ${manifest.projectId}/${checkout.branchName}`
|
|
786
|
+
);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
for (const gitlink of preservedGitlinks) {
|
|
790
|
+
replaceShadowIndexPathWithGitlink(input, "", gitlink);
|
|
791
|
+
}
|
|
792
|
+
const preservedPaths = new Set(preservedGitlinks.map((entry) => entry.filePath));
|
|
793
|
+
for (const opaqueRoot of opaqueRoots) {
|
|
794
|
+
if (preservedPaths.has(opaqueRoot)) continue;
|
|
795
|
+
runGit(
|
|
796
|
+
input,
|
|
797
|
+
input.shadowRoot,
|
|
798
|
+
["--literal-pathspecs", "update-index", "--force-remove", "--", opaqueRoot],
|
|
799
|
+
`remove explicitly deleted workspace gitlink ${JSON.stringify(opaqueRoot)}`
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function canonicalCheckoutRelativeRoots(input) {
|
|
804
|
+
const roots = /* @__PURE__ */ new Set();
|
|
805
|
+
for (const manifest of input.projects) {
|
|
806
|
+
for (const checkout of manifest.canonicalCheckouts) {
|
|
807
|
+
roots.add(workspaceProjectBranchRelativePath(manifest.projectId, checkout.branchName));
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
return [...roots].sort();
|
|
811
|
+
}
|
|
812
|
+
function restoreUnscopedCanonicalCheckoutSubtrees(input) {
|
|
813
|
+
if (input.trigger.canonicalCheckoutOnly) return;
|
|
814
|
+
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
815
|
+
const emptyTree = emptyTreeHash(input);
|
|
816
|
+
for (const relativeRoot of canonicalCheckoutRelativeRoots(input)) {
|
|
817
|
+
const remoteTree = remoteHead ? workspaceSubtreeTreeHashAtRevision(input, remoteHead, relativeRoot) : emptyTree;
|
|
818
|
+
if (remoteTree === emptyTree) {
|
|
819
|
+
runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
|
|
820
|
+
import_node_fs.default.rmSync(import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/")), { recursive: true, force: true });
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
runGit(
|
|
824
|
+
input,
|
|
825
|
+
input.shadowRoot,
|
|
826
|
+
["--literal-pathspecs", "restore", `--source=${remoteHead}`, "--staged", "--worktree", "--", relativeRoot],
|
|
827
|
+
`restore excluded canonical resolver subtree ${relativeRoot}`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, revision) {
|
|
832
|
+
if (input.trigger.canonicalCheckoutOnly) return;
|
|
833
|
+
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
834
|
+
const emptyTree = emptyTreeHash(input);
|
|
835
|
+
for (const relativeRoot of canonicalCheckoutRelativeRoots(input)) {
|
|
836
|
+
const candidateTree = workspaceSubtreeTreeHashAtRevision(input, revision, relativeRoot);
|
|
837
|
+
const remoteTree = remoteHead ? workspaceSubtreeTreeHashAtRevision(input, remoteHead, relativeRoot) : emptyTree;
|
|
838
|
+
if (candidateTree !== remoteTree) {
|
|
839
|
+
throw new Error(`Generic workspace synchronization cannot publish canonical resolver subtree ${relativeRoot}`);
|
|
375
840
|
}
|
|
376
841
|
}
|
|
377
842
|
}
|
|
@@ -404,6 +869,64 @@ function revParse(input, revision) {
|
|
|
404
869
|
const result = runGitResult(input, input.shadowRoot, ["rev-parse", "--verify", revision]);
|
|
405
870
|
return result.exitCode === 0 ? result.stdout : null;
|
|
406
871
|
}
|
|
872
|
+
function emptyTreeHash(input) {
|
|
873
|
+
const result = Bun.spawnSync(gitArgs(input, ["mktree"]), {
|
|
874
|
+
cwd: input.shadowRoot,
|
|
875
|
+
stdin: Buffer.alloc(0),
|
|
876
|
+
stdout: "pipe",
|
|
877
|
+
stderr: "pipe",
|
|
878
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
879
|
+
});
|
|
880
|
+
if (result.exitCode !== 0) {
|
|
881
|
+
throw new Error(`create empty workspace tree: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
|
|
882
|
+
}
|
|
883
|
+
return result.stdout.toString().trim();
|
|
884
|
+
}
|
|
885
|
+
function workspaceSubtreeTreeHashAtRevision(input, revision, relativeRoot) {
|
|
886
|
+
const rootType = runGitResult(input, input.shadowRoot, ["cat-file", "-t", revision]);
|
|
887
|
+
if (rootType.exitCode !== 0 || rootType.stdout !== "commit" && rootType.stdout !== "tree") {
|
|
888
|
+
throw new Error(`Cannot inspect canonical resolver subtree at invalid workspace revision ${revision}`);
|
|
889
|
+
}
|
|
890
|
+
let treeHash = rootType.stdout === "commit" ? runGit(input, input.shadowRoot, ["rev-parse", "--verify", `${revision}^{tree}`], "resolve workspace root tree") : revision;
|
|
891
|
+
for (const segment of relativeRoot.split("/")) {
|
|
892
|
+
const entry = runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "ls-tree", "-z", treeHash, "--", segment]);
|
|
893
|
+
if (entry.exitCode !== 0) {
|
|
894
|
+
throw new Error(`Inspect canonical resolver workspace path ${relativeRoot}: ${entry.stderr || entry.stdout}`);
|
|
895
|
+
}
|
|
896
|
+
if (!entry.stdout) return emptyTreeHash(input);
|
|
897
|
+
const separator = entry.stdout.indexOf(" ");
|
|
898
|
+
const [mode, type, objectId] = separator === -1 ? [] : entry.stdout.slice(0, separator).split(" ");
|
|
899
|
+
const entryName = separator === -1 ? "" : entry.stdout.slice(separator + 1).replace(/\0+$/, "");
|
|
900
|
+
if (!mode || type !== "tree" || !objectId || entryName !== segment) {
|
|
901
|
+
throw new Error(`Canonical resolver workspace path ${relativeRoot} contains a non-tree component ${segment}`);
|
|
902
|
+
}
|
|
903
|
+
treeHash = objectId;
|
|
904
|
+
}
|
|
905
|
+
return treeHash;
|
|
906
|
+
}
|
|
907
|
+
function canonicalCheckoutTreeHashAtRevision(input, revision) {
|
|
908
|
+
if (!input.trigger.canonicalCheckoutOnly || !input.trigger.projectId || !input.trigger.branchName) {
|
|
909
|
+
throw new Error("Canonical checkout tree hashing requires an exact canonical-checkout-only trigger");
|
|
910
|
+
}
|
|
911
|
+
return workspaceSubtreeTreeHashAtRevision(
|
|
912
|
+
input,
|
|
913
|
+
revision,
|
|
914
|
+
workspaceProjectBranchRelativePath(input.trigger.projectId, input.trigger.branchName)
|
|
915
|
+
);
|
|
916
|
+
}
|
|
917
|
+
function sampleCanonicalCheckoutTree(input) {
|
|
918
|
+
if (!input.trigger.canonicalCheckoutOnly) return void 0;
|
|
919
|
+
const stagedWorkspaceTree = runGit(input, input.shadowRoot, ["write-tree"], "write canonical resolver workspace tree");
|
|
920
|
+
return canonicalCheckoutTreeHashAtRevision(input, stagedWorkspaceTree);
|
|
921
|
+
}
|
|
922
|
+
function assertPublishedCanonicalCheckoutTree(input, revision, sampledTreeHash) {
|
|
923
|
+
if (!input.trigger.canonicalCheckoutOnly) return;
|
|
924
|
+
if (!sampledTreeHash) throw new Error("Canonical resolver synchronization did not capture a staged subtree tree hash");
|
|
925
|
+
const publishedTreeHash = canonicalCheckoutTreeHashAtRevision(input, revision);
|
|
926
|
+
if (publishedTreeHash !== sampledTreeHash) {
|
|
927
|
+
throw new Error(`Canonical resolver subtree changed while synchronizing: sampled ${sampledTreeHash}, published ${publishedTreeHash}`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
407
930
|
function gitStatus(input) {
|
|
408
931
|
return runGit(input, input.shadowRoot, ["status", "--porcelain=v1", "--untracked-files=all"], "read workspace status");
|
|
409
932
|
}
|
|
@@ -487,8 +1010,9 @@ function revisionTrackedFileCount(input, revision, relativeRoot) {
|
|
|
487
1010
|
return result.stdout.split("\0").filter(Boolean).length;
|
|
488
1011
|
}
|
|
489
1012
|
function destructiveCheckoutReductions(input, baseRevision) {
|
|
1013
|
+
const authoritativeRoot = input.trigger.canonicalCheckoutOnly && input.trigger.projectId && input.trigger.branchName ? workspaceProjectBranchRelativePath(input.trigger.projectId, input.trigger.branchName) : null;
|
|
490
1014
|
const roots = new Set(
|
|
491
|
-
stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root))
|
|
1015
|
+
stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root) && root !== authoritativeRoot)
|
|
492
1016
|
);
|
|
493
1017
|
const destructive = [];
|
|
494
1018
|
for (const root of roots) {
|
|
@@ -563,14 +1087,14 @@ function isNonFastForward(result) {
|
|
|
563
1087
|
${result.stderr}`.toLowerCase();
|
|
564
1088
|
return output.includes("non-fast-forward") || output.includes("fetch first") || output.includes("[rejected]");
|
|
565
1089
|
}
|
|
566
|
-
function resetShadowToRemote(input) {
|
|
1090
|
+
function resetShadowToRemote(input, opaqueWorkspaceRoots = []) {
|
|
567
1091
|
runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch canonical workspace before reset");
|
|
568
1092
|
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
569
1093
|
if (!remoteHead) return null;
|
|
570
1094
|
runGitResult(input, input.shadowRoot, ["rebase", "--abort"]);
|
|
571
1095
|
runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "reset workspace to canonical state");
|
|
572
1096
|
runGit(input, input.shadowRoot, ["clean", "-fd"], "clean reset workspace");
|
|
573
|
-
mirrorShadowWorkspaceToVisible(input);
|
|
1097
|
+
if (!input.trigger.canonicalCheckoutOnly) mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots);
|
|
574
1098
|
return remoteHead;
|
|
575
1099
|
}
|
|
576
1100
|
function baseResult(input, startingHead) {
|
|
@@ -590,12 +1114,17 @@ function baseResult(input, startingHead) {
|
|
|
590
1114
|
};
|
|
591
1115
|
}
|
|
592
1116
|
async function synchronizeWorkspace(rawInput) {
|
|
593
|
-
|
|
1117
|
+
let input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
|
|
594
1118
|
try {
|
|
1119
|
+
input = { ...input, projects: workspaceProjectsForSync(input.projects, input.trigger) };
|
|
1120
|
+
let mirroredGitlinkProjection = { entries: [], opaqueRoots: [] };
|
|
595
1121
|
ensureShadowWorkspace(input);
|
|
596
1122
|
const remoteHeadAtStart = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
597
1123
|
const result = baseResult(input, remoteHeadAtStart);
|
|
598
1124
|
if (input.resetToCanonical) {
|
|
1125
|
+
if (input.trigger.canonicalCheckoutOnly) {
|
|
1126
|
+
throw new Error("A canonical-checkout-only synchronization cannot reset the authoritative resolver checkout");
|
|
1127
|
+
}
|
|
599
1128
|
const publishedHead = resetShadowToRemote(input);
|
|
600
1129
|
return {
|
|
601
1130
|
...result,
|
|
@@ -605,14 +1134,19 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
605
1134
|
gitStatus: gitStatus(input)
|
|
606
1135
|
};
|
|
607
1136
|
}
|
|
1137
|
+
if (!input.skipVisibleMirror) resetUncommittedShadowSnapshot(input);
|
|
1138
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
608
1139
|
if (!input.skipVisibleMirror) {
|
|
609
|
-
resetUncommittedShadowSnapshot(input);
|
|
610
1140
|
const newCheckoutKeys = new Set((input.newVisibleCheckouts ?? []).map((target) => `${target.projectId}\0${target.branchName}`));
|
|
611
|
-
mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
|
|
1141
|
+
mirroredGitlinkProjection = mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
|
|
612
1142
|
fastForwardCleanShadowForNewCheckouts(input);
|
|
613
|
-
reconcileNewVisibleCheckouts(input);
|
|
1143
|
+
mergeGitlinkProjection(mirroredGitlinkProjection, reconcileNewVisibleCheckouts(input));
|
|
1144
|
+
mirroredGitlinkProjection.opaqueRoots = [...new Set(mirroredGitlinkProjection.opaqueRoots)].sort();
|
|
614
1145
|
}
|
|
615
|
-
|
|
1146
|
+
const stagePathspecs = mirroredGitlinkProjection.opaqueRoots.flatMap((root) => [`:(exclude,literal)${root}`]);
|
|
1147
|
+
runGit(input, input.shadowRoot, ["add", "-A", "--", ".", ...stagePathspecs], "stage workspace changes");
|
|
1148
|
+
forceStageCanonicalCheckoutFiles(input, mirroredGitlinkProjection.entries, mirroredGitlinkProjection.opaqueRoots);
|
|
1149
|
+
const sampledCanonicalCheckoutTreeHash = sampleCanonicalCheckoutTree(input);
|
|
616
1150
|
const stagedWorkingPaths = stagedPaths(input);
|
|
617
1151
|
const baseRevision = candidateBaseRevision(input);
|
|
618
1152
|
const paths = stagedPaths(input, baseRevision);
|
|
@@ -623,7 +1157,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
623
1157
|
diffSizeBytes,
|
|
624
1158
|
gitStatus: statusBeforeCommit,
|
|
625
1159
|
affectedPaths: reportedPaths(paths),
|
|
626
|
-
affectedProjects: affectedProjects(paths)
|
|
1160
|
+
affectedProjects: affectedProjects(paths),
|
|
1161
|
+
...sampledCanonicalCheckoutTreeHash ? { sampledCanonicalCheckoutTreeHash } : {}
|
|
627
1162
|
};
|
|
628
1163
|
const destructiveReductions = destructiveCheckoutReductions(input, baseRevision);
|
|
629
1164
|
if (destructiveReductions.length > 0 && !input.confirmedLargeDiff) {
|
|
@@ -634,7 +1169,7 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
634
1169
|
error: `Workspace safety guard blocked a destructive checkout reduction: ${summary}`
|
|
635
1170
|
};
|
|
636
1171
|
}
|
|
637
|
-
if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff) {
|
|
1172
|
+
if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff && !input.trigger.canonicalCheckoutOnly) {
|
|
638
1173
|
return {
|
|
639
1174
|
...observed,
|
|
640
1175
|
outcome: "large_diff_blocked",
|
|
@@ -645,6 +1180,7 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
645
1180
|
runGit(input, input.shadowRoot, ["commit", "-m", commitMessage(input)], "commit workspace changes");
|
|
646
1181
|
}
|
|
647
1182
|
const candidateHead = revParse(input, "HEAD");
|
|
1183
|
+
if (candidateHead) assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, candidateHead);
|
|
648
1184
|
const hasRemoteMain = Boolean(revParse(input, `origin/${WORKSPACE_BRANCH}`));
|
|
649
1185
|
const hasUnpushedCommit = candidateHead ? !hasRemoteMain || !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", candidateHead, `origin/${WORKSPACE_BRANCH}`]) : false;
|
|
650
1186
|
if (!hasUnpushedCommit) {
|
|
@@ -653,7 +1189,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
653
1189
|
if (currentRemoteHead && localHead !== currentRemoteHead) {
|
|
654
1190
|
runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "update workspace from canonical state");
|
|
655
1191
|
runGit(input, input.shadowRoot, ["clean", "-fd"], "clean updated workspace");
|
|
656
|
-
|
|
1192
|
+
assertPublishedCanonicalCheckoutTree(input, currentRemoteHead, sampledCanonicalCheckoutTreeHash);
|
|
1193
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
657
1194
|
return {
|
|
658
1195
|
...observed,
|
|
659
1196
|
outcome: "updated",
|
|
@@ -662,7 +1199,13 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
662
1199
|
gitStatus: gitStatus(input)
|
|
663
1200
|
};
|
|
664
1201
|
}
|
|
665
|
-
|
|
1202
|
+
const authoritativeHead = currentRemoteHead ?? localHead;
|
|
1203
|
+
if (authoritativeHead) {
|
|
1204
|
+
assertPublishedCanonicalCheckoutTree(input, authoritativeHead, sampledCanonicalCheckoutTreeHash);
|
|
1205
|
+
}
|
|
1206
|
+
if (input.skipVisibleMirror) {
|
|
1207
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
1208
|
+
}
|
|
666
1209
|
return {
|
|
667
1210
|
...observed,
|
|
668
1211
|
outcome: "no_change",
|
|
@@ -676,7 +1219,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
676
1219
|
const push = runGitResult(input, input.shadowRoot, ["push", "origin", `HEAD:refs/heads/${WORKSPACE_BRANCH}`]);
|
|
677
1220
|
if (push.exitCode === 0) {
|
|
678
1221
|
const publishedHead = revParse(input, "HEAD") ?? candidateHead;
|
|
679
|
-
|
|
1222
|
+
if (publishedHead) assertPublishedCanonicalCheckoutTree(input, publishedHead, sampledCanonicalCheckoutTreeHash);
|
|
1223
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
680
1224
|
return {
|
|
681
1225
|
...observed,
|
|
682
1226
|
outcome: "published",
|
|
@@ -702,7 +1246,7 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
702
1246
|
if (rebase.exitCode !== 0) {
|
|
703
1247
|
const conflicted = runGitResult(input, input.shadowRoot, ["diff", "--name-only", "--diff-filter=U", "-z"]);
|
|
704
1248
|
const discardedPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
|
|
705
|
-
const publishedHead = resetShadowToRemote(input);
|
|
1249
|
+
const publishedHead = resetShadowToRemote(input, mirroredGitlinkProjection.opaqueRoots);
|
|
706
1250
|
return {
|
|
707
1251
|
...observed,
|
|
708
1252
|
outcome: "conflict_reset",
|
|
@@ -714,6 +1258,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
714
1258
|
gitStatus: gitStatus(input)
|
|
715
1259
|
};
|
|
716
1260
|
}
|
|
1261
|
+
assertPublishedCanonicalCheckoutTree(input, "HEAD", sampledCanonicalCheckoutTreeHash);
|
|
1262
|
+
assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, "HEAD");
|
|
717
1263
|
}
|
|
718
1264
|
return {
|
|
719
1265
|
...observed,
|
|
@@ -732,13 +1278,27 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
732
1278
|
};
|
|
733
1279
|
}
|
|
734
1280
|
}
|
|
735
|
-
function calculateWorkspaceDiffFingerprint(
|
|
1281
|
+
function calculateWorkspaceDiffFingerprint(rawInput) {
|
|
1282
|
+
const input = {
|
|
1283
|
+
...rawInput,
|
|
1284
|
+
projects: workspaceProjectsForSync(rawInput.projects, rawInput.trigger)
|
|
1285
|
+
};
|
|
736
1286
|
ensureShadowWorkspace(input);
|
|
1287
|
+
let gitlinkProjection = { entries: [], opaqueRoots: [] };
|
|
737
1288
|
if (!input.skipVisibleMirror) {
|
|
738
1289
|
resetUncommittedShadowSnapshot(input);
|
|
739
|
-
|
|
1290
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
1291
|
+
gitlinkProjection = mirrorVisibleWorkspaceToShadow(input);
|
|
1292
|
+
} else {
|
|
1293
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
740
1294
|
}
|
|
741
|
-
runGit(
|
|
1295
|
+
runGit(
|
|
1296
|
+
input,
|
|
1297
|
+
input.shadowRoot,
|
|
1298
|
+
["add", "-A", "--", ".", ...gitlinkProjection.opaqueRoots.map((root) => `:(exclude,literal)${root}`)],
|
|
1299
|
+
"stage workspace fingerprint"
|
|
1300
|
+
);
|
|
1301
|
+
forceStageCanonicalCheckoutFiles(input, gitlinkProjection.entries, gitlinkProjection.opaqueRoots);
|
|
742
1302
|
const stagedTree = runGit(input, input.shadowRoot, ["write-tree"], "write workspace fingerprint tree");
|
|
743
1303
|
const headTree = revParse(input, "HEAD^{tree}");
|
|
744
1304
|
const localHead = revParse(input, "HEAD");
|
|
@@ -780,6 +1340,7 @@ class WorkspaceSyncSingleFlight {
|
|
|
780
1340
|
WORKSPACE_BRANCH,
|
|
781
1341
|
WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
|
|
782
1342
|
WorkspaceSyncSingleFlight,
|
|
1343
|
+
assertCanonicalCheckoutIndexMatchesWorktree,
|
|
783
1344
|
calculateWorkspaceDiffFingerprint,
|
|
784
1345
|
encodeWorkspaceBranch,
|
|
785
1346
|
mirrorShadowWorkspaceToVisible,
|
|
@@ -787,5 +1348,6 @@ class WorkspaceSyncSingleFlight {
|
|
|
787
1348
|
synchronizeWorkspace,
|
|
788
1349
|
visibleProjectBranchPath,
|
|
789
1350
|
workspacePlansRelativePath,
|
|
790
|
-
workspaceProjectBranchRelativePath
|
|
1351
|
+
workspaceProjectBranchRelativePath,
|
|
1352
|
+
workspaceProjectsForSync
|
|
791
1353
|
});
|