@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
|
@@ -14,10 +14,14 @@ const DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO = 0.8;
|
|
|
14
14
|
const ACTIVE_GIT_LOCK_PATHS = ["index.lock", "HEAD.lock", "packed-refs.lock", "shallow.lock"];
|
|
15
15
|
const mirrorComparisonCache = /* @__PURE__ */ new Map();
|
|
16
16
|
function gitArgs(input, args) {
|
|
17
|
-
|
|
17
|
+
const configArgs = ["-c", "submodule.recurse=false", "-c", "fetch.recurseSubmodules=false", "-c", "push.recurseSubmodules=false"];
|
|
18
|
+
return input.authHeader ? ["git", ...configArgs, "-c", `http.extraHeader=${input.authHeader}`, ...args] : ["git", ...configArgs, ...args];
|
|
19
|
+
}
|
|
20
|
+
function withoutSubmoduleRecursion(args) {
|
|
21
|
+
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;
|
|
18
22
|
}
|
|
19
23
|
function runGitResult(input, cwd, args) {
|
|
20
|
-
const result = Bun.spawnSync(gitArgs(input, args), {
|
|
24
|
+
const result = Bun.spawnSync(gitArgs(input, withoutSubmoduleRecursion(args)), {
|
|
21
25
|
cwd,
|
|
22
26
|
stdout: "pipe",
|
|
23
27
|
stderr: "pipe",
|
|
@@ -45,6 +49,35 @@ function tryGit(input, cwd, args) {
|
|
|
45
49
|
function encodeWorkspaceBranch(branchName) {
|
|
46
50
|
return encodeURIComponent(branchName);
|
|
47
51
|
}
|
|
52
|
+
function workspaceProjectsForSync(projects, trigger) {
|
|
53
|
+
if (trigger.canonicalCheckoutOnly) {
|
|
54
|
+
if (!trigger.projectId || !trigger.branchName) {
|
|
55
|
+
throw new Error("A canonical-checkout-only synchronization requires projectId and branchName");
|
|
56
|
+
}
|
|
57
|
+
const project = projects.find((candidate) => candidate.projectId === trigger.projectId);
|
|
58
|
+
const checkout = project?.canonicalCheckouts.find((candidate) => candidate.branchName === trigger.branchName);
|
|
59
|
+
if (!project || !project.branches.includes(trigger.branchName) || !checkout) {
|
|
60
|
+
throw new Error(`Canonical resolver checkout ${trigger.projectId}/${trigger.branchName} is not present in this worker manifest`);
|
|
61
|
+
}
|
|
62
|
+
return [
|
|
63
|
+
{
|
|
64
|
+
...project,
|
|
65
|
+
branches: [trigger.branchName],
|
|
66
|
+
canonicalCheckouts: [checkout]
|
|
67
|
+
}
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
return projects.flatMap((project) => {
|
|
71
|
+
const canonicalBranches = new Set(project.canonicalCheckouts.map((checkout) => checkout.branchName));
|
|
72
|
+
const branches = project.branches.filter((branchName) => !canonicalBranches.has(branchName));
|
|
73
|
+
return branches.length > 0 || project.canonicalCheckouts.length > 0 ? [
|
|
74
|
+
{
|
|
75
|
+
...project,
|
|
76
|
+
branches
|
|
77
|
+
}
|
|
78
|
+
] : [];
|
|
79
|
+
});
|
|
80
|
+
}
|
|
48
81
|
function workspaceProjectBranchRelativePath(projectId, branchName) {
|
|
49
82
|
return path.posix.join("projects", projectId, "branches", encodeWorkspaceBranch(branchName));
|
|
50
83
|
}
|
|
@@ -69,12 +102,30 @@ function listGitEligibleFiles(checkoutPath) {
|
|
|
69
102
|
if (!fs.existsSync(path.join(checkoutPath, ".git"))) {
|
|
70
103
|
return [];
|
|
71
104
|
}
|
|
72
|
-
const result = Bun.spawnSync(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
105
|
+
const result = Bun.spawnSync(
|
|
106
|
+
[
|
|
107
|
+
"git",
|
|
108
|
+
"-c",
|
|
109
|
+
"submodule.recurse=false",
|
|
110
|
+
"-c",
|
|
111
|
+
"fetch.recurseSubmodules=false",
|
|
112
|
+
"-c",
|
|
113
|
+
"push.recurseSubmodules=false",
|
|
114
|
+
"ls-files",
|
|
115
|
+
"-z",
|
|
116
|
+
"--cached",
|
|
117
|
+
"--others",
|
|
118
|
+
"--exclude-standard",
|
|
119
|
+
"--",
|
|
120
|
+
"."
|
|
121
|
+
],
|
|
122
|
+
{
|
|
123
|
+
cwd: checkoutPath,
|
|
124
|
+
stdout: "pipe",
|
|
125
|
+
stderr: "pipe",
|
|
126
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
127
|
+
}
|
|
128
|
+
);
|
|
78
129
|
if (result.exitCode !== 0) {
|
|
79
130
|
throw new Error(`inspect eligible project files: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
|
|
80
131
|
}
|
|
@@ -87,9 +138,144 @@ function listGitEligibleFiles(checkoutPath) {
|
|
|
87
138
|
}
|
|
88
139
|
}).sort();
|
|
89
140
|
}
|
|
141
|
+
function runCheckoutGitRaw(checkoutPath, args, action) {
|
|
142
|
+
const result = Bun.spawnSync(
|
|
143
|
+
["git", "-c", "submodule.recurse=false", "-c", "fetch.recurseSubmodules=false", "-c", "push.recurseSubmodules=false", ...args],
|
|
144
|
+
{
|
|
145
|
+
cwd: checkoutPath,
|
|
146
|
+
stdout: "pipe",
|
|
147
|
+
stderr: "pipe",
|
|
148
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
149
|
+
}
|
|
150
|
+
);
|
|
151
|
+
if (result.exitCode !== 0) {
|
|
152
|
+
throw new Error(`${action}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
|
|
153
|
+
}
|
|
154
|
+
return result.stdout.toString();
|
|
155
|
+
}
|
|
156
|
+
function stagedCheckoutPaths(checkoutPath) {
|
|
157
|
+
return runCheckoutGitRaw(
|
|
158
|
+
checkoutPath,
|
|
159
|
+
["diff", "--cached", "--name-only", "--no-renames", "-z", "HEAD"],
|
|
160
|
+
"inspect staged canonical resolver paths"
|
|
161
|
+
).split("\0").filter(Boolean).sort();
|
|
162
|
+
}
|
|
163
|
+
function parseGitIndexEntries(output) {
|
|
164
|
+
return output.split("\0").filter(Boolean).flatMap((entry) => {
|
|
165
|
+
const separator = entry.indexOf(" ");
|
|
166
|
+
if (separator === -1) return [];
|
|
167
|
+
const [mode, objectId, stage] = entry.slice(0, separator).split(" ");
|
|
168
|
+
const filePath = entry.slice(separator + 1);
|
|
169
|
+
return mode && objectId && stage && filePath ? [{ mode, objectId, stage, filePath }] : [];
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function indexEntriesForPath(checkoutPath, filePath) {
|
|
173
|
+
return parseGitIndexEntries(
|
|
174
|
+
runCheckoutGitRaw(
|
|
175
|
+
checkoutPath,
|
|
176
|
+
["--literal-pathspecs", "ls-files", "--stage", "-z", "--", filePath],
|
|
177
|
+
`inspect canonical resolver index entry for ${JSON.stringify(filePath)}`
|
|
178
|
+
)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
function checkoutIndexEntries(checkoutPath) {
|
|
182
|
+
return parseGitIndexEntries(runCheckoutGitRaw(checkoutPath, ["ls-files", "--stage", "-z", "--", "."], "inspect project Git index"));
|
|
183
|
+
}
|
|
184
|
+
function checkoutGitlinks(checkoutPath) {
|
|
185
|
+
const entries = checkoutIndexEntries(checkoutPath);
|
|
186
|
+
const unmergedPaths = [...new Set(entries.filter((entry) => entry.stage !== "0").map((entry) => entry.filePath))].sort();
|
|
187
|
+
if (unmergedPaths.length > 0) {
|
|
188
|
+
throw new Error(`Project checkout has unmerged Git index stages: ${JSON.stringify(unmergedPaths)}`);
|
|
189
|
+
}
|
|
190
|
+
return entries.filter((entry) => entry.mode === "160000").map(({ filePath, objectId }) => ({ filePath, objectId })).sort((left, right) => left.filePath.localeCompare(right.filePath));
|
|
191
|
+
}
|
|
192
|
+
function revisionGitlinks(checkoutPath, revision) {
|
|
193
|
+
const output = runCheckoutGitRaw(checkoutPath, ["ls-tree", "-r", "-z", revision, "--", "."], `inspect ${revision} project gitlinks`);
|
|
194
|
+
return output.split("\0").filter(Boolean).flatMap((entry) => {
|
|
195
|
+
const separator = entry.indexOf(" ");
|
|
196
|
+
if (separator === -1) return [];
|
|
197
|
+
const [mode, type, objectId] = entry.slice(0, separator).split(" ");
|
|
198
|
+
const filePath = entry.slice(separator + 1);
|
|
199
|
+
return mode === "160000" && type === "commit" && objectId && filePath ? [{ filePath, objectId }] : [];
|
|
200
|
+
}).sort((left, right) => left.filePath.localeCompare(right.filePath));
|
|
201
|
+
}
|
|
202
|
+
function stagedDeletedGitlinkPaths(checkoutPath) {
|
|
203
|
+
const currentEntries = checkoutIndexEntries(checkoutPath);
|
|
204
|
+
return revisionGitlinks(checkoutPath, "HEAD").filter(
|
|
205
|
+
(entry) => !currentEntries.some((candidate) => candidate.filePath === entry.filePath || candidate.filePath.startsWith(`${entry.filePath}/`))
|
|
206
|
+
).map((entry) => entry.filePath).sort();
|
|
207
|
+
}
|
|
208
|
+
function lstatOrNull(filePath) {
|
|
209
|
+
try {
|
|
210
|
+
return fs.lstatSync(filePath);
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error.code === "ENOENT") return null;
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
function worktreeDiffersFromIndex(checkoutPath, filePath) {
|
|
217
|
+
const result = Bun.spawnSync(
|
|
218
|
+
["git", "--literal-pathspecs", "-c", "core.fileMode=true", "diff", "--quiet", "--no-ext-diff", "--", filePath],
|
|
219
|
+
{
|
|
220
|
+
cwd: checkoutPath,
|
|
221
|
+
stdout: "ignore",
|
|
222
|
+
stderr: "pipe",
|
|
223
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
if (result.exitCode === 0) return false;
|
|
227
|
+
if (result.exitCode === 1) return true;
|
|
228
|
+
throw new Error(
|
|
229
|
+
`compare canonical resolver index and worktree for ${JSON.stringify(filePath)}: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
function indexEntryTypeMatchesWorktree(entry, stat) {
|
|
233
|
+
if (entry.mode === "120000") return stat.isSymbolicLink();
|
|
234
|
+
if (entry.mode !== "100644" && entry.mode !== "100755") return false;
|
|
235
|
+
if (!stat.isFile()) return false;
|
|
236
|
+
return entry.mode === "100755" === ((stat.mode & 73) !== 0);
|
|
237
|
+
}
|
|
238
|
+
function assertCanonicalCheckoutIndexMatchesWorktree(checkoutPath) {
|
|
239
|
+
checkoutGitlinks(checkoutPath);
|
|
240
|
+
const mismatchedPaths = [];
|
|
241
|
+
for (const filePath of stagedCheckoutPaths(checkoutPath)) {
|
|
242
|
+
const entries = indexEntriesForPath(checkoutPath, filePath);
|
|
243
|
+
const worktreeEntry = lstatOrNull(path.join(checkoutPath, ...filePath.split("/")));
|
|
244
|
+
if (entries.length === 0) {
|
|
245
|
+
if (worktreeEntry && !worktreeEntry.isDirectory()) mismatchedPaths.push(filePath);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (entries.length === 1 && entries[0]?.stage === "0" && entries[0].mode === "160000") {
|
|
249
|
+
if (worktreeEntry && !worktreeEntry.isDirectory()) mismatchedPaths.push(filePath);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (entries.length !== 1 || entries[0]?.stage !== "0" || !worktreeEntry || !indexEntryTypeMatchesWorktree(entries[0], worktreeEntry) || worktreeDiffersFromIndex(checkoutPath, filePath)) {
|
|
253
|
+
mismatchedPaths.push(filePath);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (mismatchedPaths.length > 0) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`Canonical resolver checkout has staged Git index state that is not represented by its worktree: ${JSON.stringify(
|
|
259
|
+
mismatchedPaths
|
|
260
|
+
)}. Make the worktree match the index or unstage these paths before synchronizing.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
90
264
|
function checkoutGitSnapshot(checkoutPath) {
|
|
91
265
|
const gitPaths = Bun.spawnSync(
|
|
92
|
-
[
|
|
266
|
+
[
|
|
267
|
+
"git",
|
|
268
|
+
"-c",
|
|
269
|
+
"submodule.recurse=false",
|
|
270
|
+
"-c",
|
|
271
|
+
"fetch.recurseSubmodules=false",
|
|
272
|
+
"-c",
|
|
273
|
+
"push.recurseSubmodules=false",
|
|
274
|
+
"rev-parse",
|
|
275
|
+
"--git-path",
|
|
276
|
+
"index",
|
|
277
|
+
...ACTIVE_GIT_LOCK_PATHS.flatMap((lock) => ["--git-path", lock])
|
|
278
|
+
],
|
|
93
279
|
{
|
|
94
280
|
cwd: checkoutPath,
|
|
95
281
|
stdout: "pipe",
|
|
@@ -102,12 +288,26 @@ function checkoutGitSnapshot(checkoutPath) {
|
|
|
102
288
|
if (!indexPath || lockPaths.length !== ACTIVE_GIT_LOCK_PATHS.length || lockPaths.some((lockPath) => fs.existsSync(lockPath))) {
|
|
103
289
|
return null;
|
|
104
290
|
}
|
|
105
|
-
const head = Bun.spawnSync(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
291
|
+
const head = Bun.spawnSync(
|
|
292
|
+
[
|
|
293
|
+
"git",
|
|
294
|
+
"-c",
|
|
295
|
+
"submodule.recurse=false",
|
|
296
|
+
"-c",
|
|
297
|
+
"fetch.recurseSubmodules=false",
|
|
298
|
+
"-c",
|
|
299
|
+
"push.recurseSubmodules=false",
|
|
300
|
+
"rev-parse",
|
|
301
|
+
"--verify",
|
|
302
|
+
"HEAD"
|
|
303
|
+
],
|
|
304
|
+
{
|
|
305
|
+
cwd: checkoutPath,
|
|
306
|
+
stdout: "pipe",
|
|
307
|
+
stderr: "ignore",
|
|
308
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
309
|
+
}
|
|
310
|
+
);
|
|
111
311
|
let indexSignature = "missing";
|
|
112
312
|
try {
|
|
113
313
|
const stat = fs.statSync(indexPath);
|
|
@@ -123,7 +323,10 @@ function listShadowTrackedFiles(input, relativeRoot) {
|
|
|
123
323
|
const prefix = `${normalizedRoot}/`;
|
|
124
324
|
return output.split("\0").filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length)).filter(Boolean).sort();
|
|
125
325
|
}
|
|
126
|
-
function
|
|
326
|
+
function pathIsWithinOpaqueRoot(relativePath, opaqueRoots) {
|
|
327
|
+
return opaqueRoots.some((root) => relativePath === root || relativePath.startsWith(`${root}/`));
|
|
328
|
+
}
|
|
329
|
+
function listFilesRecursively(root, filter, opaqueRoots = []) {
|
|
127
330
|
if (!fs.existsSync(root)) return [];
|
|
128
331
|
const files = [];
|
|
129
332
|
const visit = (current, relativeDir) => {
|
|
@@ -131,6 +334,7 @@ function listFilesRecursively(root, filter) {
|
|
|
131
334
|
if (entry.name === ".git") continue;
|
|
132
335
|
const relativePath = relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name;
|
|
133
336
|
const absolutePath = path.join(current, entry.name);
|
|
337
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) continue;
|
|
134
338
|
if (entry.isDirectory()) {
|
|
135
339
|
visit(absolutePath, relativePath);
|
|
136
340
|
} else if (!filter || filter(relativePath)) {
|
|
@@ -141,14 +345,19 @@ function listFilesRecursively(root, filter) {
|
|
|
141
345
|
visit(root, "");
|
|
142
346
|
return files.sort();
|
|
143
347
|
}
|
|
144
|
-
function removeEmptyDirectories(root) {
|
|
348
|
+
function removeEmptyDirectories(root, opaqueRoots = []) {
|
|
145
349
|
if (!fs.existsSync(root)) return;
|
|
146
|
-
const visit = (current) => {
|
|
350
|
+
const visit = (current, relativeDir) => {
|
|
147
351
|
let empty = true;
|
|
148
352
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
149
353
|
const absolutePath = path.join(current, entry.name);
|
|
354
|
+
const relativePath = relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name;
|
|
355
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) {
|
|
356
|
+
empty = false;
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
150
359
|
if (entry.isDirectory()) {
|
|
151
|
-
if (!visit(absolutePath)) empty = false;
|
|
360
|
+
if (!visit(absolutePath, relativePath)) empty = false;
|
|
152
361
|
} else {
|
|
153
362
|
empty = false;
|
|
154
363
|
}
|
|
@@ -156,7 +365,7 @@ function removeEmptyDirectories(root) {
|
|
|
156
365
|
if (empty && current !== root) fs.rmdirSync(current);
|
|
157
366
|
return empty;
|
|
158
367
|
};
|
|
159
|
-
visit(root);
|
|
368
|
+
visit(root, "");
|
|
160
369
|
}
|
|
161
370
|
function copyEntry(sourceRoot, targetRoot, relativePath) {
|
|
162
371
|
const sourcePath = path.resolve(sourceRoot, ...relativePath.split("/"));
|
|
@@ -226,56 +435,218 @@ function entriesEqual(sourcePath, targetPath) {
|
|
|
226
435
|
}
|
|
227
436
|
return equal;
|
|
228
437
|
}
|
|
229
|
-
function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles) {
|
|
438
|
+
function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles, opaqueTargetRoots = [], opaqueSourceRoots = []) {
|
|
230
439
|
fs.mkdirSync(targetRoot, { recursive: true });
|
|
231
440
|
const sourceSet = new Set(sourceFiles);
|
|
232
441
|
for (const relativePath of targetFiles) {
|
|
442
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
|
|
233
443
|
if (sourceSet.has(relativePath)) continue;
|
|
234
444
|
const targetPath = path.resolve(targetRoot, ...relativePath.split("/"));
|
|
235
445
|
assertInside(targetRoot, targetPath, "Workspace deletion path");
|
|
236
446
|
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
237
447
|
}
|
|
238
448
|
for (const relativePath of sourceFiles) {
|
|
449
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueSourceRoots)) continue;
|
|
450
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
|
|
239
451
|
const sourcePath = path.resolve(sourceRoot, ...relativePath.split("/"));
|
|
240
|
-
|
|
241
|
-
|
|
452
|
+
const stat = lstatOrNull(sourcePath);
|
|
453
|
+
if (!stat) continue;
|
|
242
454
|
if (!stat.isFile() && !stat.isSymbolicLink()) continue;
|
|
243
455
|
const targetPath = path.resolve(targetRoot, ...relativePath.split("/"));
|
|
244
456
|
if (entriesEqual(sourcePath, targetPath)) continue;
|
|
245
457
|
copyEntry(sourceRoot, targetRoot, relativePath);
|
|
246
458
|
}
|
|
247
|
-
removeEmptyDirectories(targetRoot);
|
|
459
|
+
removeEmptyDirectories(targetRoot, opaqueTargetRoots);
|
|
460
|
+
}
|
|
461
|
+
function indexPathsUnderCheckoutPath(checkoutPath, filePath) {
|
|
462
|
+
return runCheckoutGitRaw(
|
|
463
|
+
checkoutPath,
|
|
464
|
+
["--literal-pathspecs", "ls-files", "-z", "--", filePath],
|
|
465
|
+
`inspect index paths beneath ${JSON.stringify(filePath)}`
|
|
466
|
+
).split("\0").filter(Boolean);
|
|
467
|
+
}
|
|
468
|
+
function replaceCheckoutIndexPathWithGitlink(checkoutPath, gitlink) {
|
|
469
|
+
for (const indexPath of indexPathsUnderCheckoutPath(checkoutPath, gitlink.filePath)) {
|
|
470
|
+
runCheckoutGitRaw(
|
|
471
|
+
checkoutPath,
|
|
472
|
+
["--literal-pathspecs", "update-index", "--force-remove", "--", indexPath],
|
|
473
|
+
`remove index entry beneath gitlink ${JSON.stringify(gitlink.filePath)}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
runCheckoutGitRaw(
|
|
477
|
+
checkoutPath,
|
|
478
|
+
["--literal-pathspecs", "update-index", "--add", "--cacheinfo", "160000", gitlink.objectId, gitlink.filePath],
|
|
479
|
+
`record gitlink ${JSON.stringify(gitlink.filePath)}`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
function removeCheckoutIndexPath(checkoutPath, filePath) {
|
|
483
|
+
runCheckoutGitRaw(
|
|
484
|
+
checkoutPath,
|
|
485
|
+
["--literal-pathspecs", "update-index", "--force-remove", "--", filePath],
|
|
486
|
+
`remove gitlink ${JSON.stringify(filePath)}`
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
function shadowProjectGitlinks(input, relativeRoot) {
|
|
490
|
+
const prefix = `${relativeRoot}/`;
|
|
491
|
+
const entries = parseGitIndexEntries(
|
|
492
|
+
runGit(input, input.shadowRoot, ["--literal-pathspecs", "ls-files", "--stage", "-z", "--", relativeRoot], "inspect workspace gitlinks")
|
|
493
|
+
);
|
|
494
|
+
const unmergedPaths = [...new Set(entries.filter((entry) => entry.stage !== "0").map((entry) => entry.filePath))].sort();
|
|
495
|
+
if (unmergedPaths.length > 0) {
|
|
496
|
+
throw new Error(`Canonical workspace has unmerged Git index stages: ${JSON.stringify(unmergedPaths)}`);
|
|
497
|
+
}
|
|
498
|
+
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));
|
|
499
|
+
}
|
|
500
|
+
function replaceShadowIndexPathWithGitlink(input, relativeRoot, gitlink) {
|
|
501
|
+
const workspacePath = path.posix.join(relativeRoot, gitlink.filePath);
|
|
502
|
+
const existingPaths = runGit(
|
|
503
|
+
input,
|
|
504
|
+
input.shadowRoot,
|
|
505
|
+
["--literal-pathspecs", "ls-files", "-z", "--", workspacePath],
|
|
506
|
+
`inspect workspace index beneath gitlink ${JSON.stringify(workspacePath)}`
|
|
507
|
+
).split("\0").filter(Boolean);
|
|
508
|
+
for (const indexPath of existingPaths) {
|
|
509
|
+
runGit(
|
|
510
|
+
input,
|
|
511
|
+
input.shadowRoot,
|
|
512
|
+
["--literal-pathspecs", "update-index", "--force-remove", "--", indexPath],
|
|
513
|
+
`remove workspace index entry beneath gitlink ${JSON.stringify(workspacePath)}`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
const placeholderPath = path.join(input.shadowRoot, ...workspacePath.split("/"));
|
|
517
|
+
fs.rmSync(placeholderPath, { recursive: true, force: true });
|
|
518
|
+
fs.mkdirSync(placeholderPath, { recursive: true });
|
|
519
|
+
runGit(
|
|
520
|
+
input,
|
|
521
|
+
input.shadowRoot,
|
|
522
|
+
["--literal-pathspecs", "update-index", "--add", "--cacheinfo", "160000", gitlink.objectId, workspacePath],
|
|
523
|
+
`record workspace gitlink ${JSON.stringify(workspacePath)}`
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
function mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks) {
|
|
527
|
+
const desiredByPath = new Map(visibleGitlinks.map((entry) => [entry.filePath, entry]));
|
|
528
|
+
for (const current of shadowProjectGitlinks(input, relativeRoot)) {
|
|
529
|
+
if (desiredByPath.has(current.filePath)) continue;
|
|
530
|
+
const workspacePath = path.posix.join(relativeRoot, current.filePath);
|
|
531
|
+
runGit(
|
|
532
|
+
input,
|
|
533
|
+
input.shadowRoot,
|
|
534
|
+
["--literal-pathspecs", "update-index", "--force-remove", "--", workspacePath],
|
|
535
|
+
`remove deleted workspace gitlink ${JSON.stringify(workspacePath)}`
|
|
536
|
+
);
|
|
537
|
+
fs.rmSync(path.join(input.shadowRoot, ...workspacePath.split("/")), { recursive: true, force: true });
|
|
538
|
+
}
|
|
539
|
+
for (const gitlink of visibleGitlinks) replaceShadowIndexPathWithGitlink(input, relativeRoot, gitlink);
|
|
540
|
+
}
|
|
541
|
+
function mirrorShadowGitlinksToVisible(desired, visibleRoot) {
|
|
542
|
+
const desiredByPath = new Map(desired.map((entry) => [entry.filePath, entry]));
|
|
543
|
+
for (const current of checkoutGitlinks(visibleRoot)) {
|
|
544
|
+
if (!desiredByPath.has(current.filePath)) removeCheckoutIndexPath(visibleRoot, current.filePath);
|
|
545
|
+
}
|
|
546
|
+
for (const gitlink of desired) {
|
|
547
|
+
const visiblePath = path.join(visibleRoot, ...gitlink.filePath.split("/"));
|
|
548
|
+
const existing = lstatOrNull(visiblePath);
|
|
549
|
+
if (existing && !existing.isDirectory()) fs.rmSync(visiblePath, { recursive: true, force: true });
|
|
550
|
+
fs.mkdirSync(visiblePath, { recursive: true });
|
|
551
|
+
replaceCheckoutIndexPathWithGitlink(visibleRoot, gitlink);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function assertSafeVisibleGitlinkTransitions(visibleRoot, currentGitlinks, desiredGitlinks, shadowFiles) {
|
|
555
|
+
const desiredGitlinkPaths = new Set(desiredGitlinks.map((entry) => entry.filePath));
|
|
556
|
+
for (const current of currentGitlinks) {
|
|
557
|
+
if (desiredGitlinkPaths.has(current.filePath)) continue;
|
|
558
|
+
const becomesOrdinaryPath = shadowFiles.some(
|
|
559
|
+
(filePath) => filePath === current.filePath || filePath.startsWith(`${current.filePath}/`)
|
|
560
|
+
);
|
|
561
|
+
if (!becomesOrdinaryPath) continue;
|
|
562
|
+
const existing = lstatOrNull(path.join(visibleRoot, ...current.filePath.split("/")));
|
|
563
|
+
if (!existing) continue;
|
|
564
|
+
if (existing.isDirectory() && fs.readdirSync(path.join(visibleRoot, ...current.filePath.split("/"))).length === 0) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
throw new Error(
|
|
568
|
+
`Cannot replace initialized or nonempty gitlink ${JSON.stringify(
|
|
569
|
+
current.filePath
|
|
570
|
+
)} with ordinary workspace content without deleting submodule data`
|
|
571
|
+
);
|
|
572
|
+
}
|
|
248
573
|
}
|
|
249
574
|
function restoreShadowProjectSnapshot(input, relativeRoot) {
|
|
250
575
|
const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
251
576
|
const headFiles = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", "HEAD", "--", relativeRoot]);
|
|
252
577
|
if (headFiles.exitCode === 0 && headFiles.stdout) {
|
|
253
|
-
runGit(
|
|
578
|
+
runGit(
|
|
579
|
+
input,
|
|
580
|
+
input.shadowRoot,
|
|
581
|
+
["restore", "--source=HEAD", "--staged", "--worktree", "--", relativeRoot],
|
|
582
|
+
"restore deferred checkout scan"
|
|
583
|
+
);
|
|
254
584
|
runGit(input, input.shadowRoot, ["clean", "-fd", "--", relativeRoot], "clean deferred checkout scan");
|
|
255
585
|
return;
|
|
256
586
|
}
|
|
587
|
+
runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
|
|
257
588
|
fs.rmSync(shadowRoot, { recursive: true, force: true });
|
|
258
589
|
}
|
|
259
590
|
function mirrorVisibleProjectToShadow(input, manifest, branchName) {
|
|
260
591
|
const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
|
|
261
|
-
if (!fs.existsSync(path.join(visibleRoot, ".git"))) return;
|
|
592
|
+
if (!fs.existsSync(path.join(visibleRoot, ".git"))) return { entries: [], opaqueRoots: [] };
|
|
593
|
+
const isCanonicalCheckout = manifest.canonicalCheckouts?.some((checkout) => checkout.branchName === branchName);
|
|
594
|
+
if (isCanonicalCheckout) {
|
|
595
|
+
assertCanonicalCheckoutIndexMatchesWorktree(visibleRoot);
|
|
596
|
+
}
|
|
262
597
|
const beforeSnapshot = checkoutGitSnapshot(visibleRoot);
|
|
263
|
-
if (!beforeSnapshot) return;
|
|
264
|
-
const
|
|
598
|
+
if (!beforeSnapshot) return { entries: [], opaqueRoots: [] };
|
|
599
|
+
const visibleGitlinks = checkoutGitlinks(visibleRoot);
|
|
600
|
+
const visibleIndexEntries = checkoutIndexEntries(visibleRoot).filter((entry) => entry.stage === "0");
|
|
601
|
+
const indexedGitlinkPaths = new Set(visibleGitlinks.map((entry) => entry.filePath));
|
|
602
|
+
const stagedDeletedGitlinkRoots = stagedDeletedGitlinkPaths(visibleRoot);
|
|
265
603
|
const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
|
|
604
|
+
const shadowGitlinkRoots = shadowProjectGitlinks(input, relativeRoot).filter(
|
|
605
|
+
(entry) => !indexedGitlinkPaths.has(entry.filePath) && !visibleIndexEntries.some(
|
|
606
|
+
(candidate) => candidate.mode !== "160000" && (candidate.filePath === entry.filePath || candidate.filePath.startsWith(`${entry.filePath}/`))
|
|
607
|
+
)
|
|
608
|
+
).map((entry) => entry.filePath);
|
|
609
|
+
const visibleGitlinkRoots = [.../* @__PURE__ */ new Set([...indexedGitlinkPaths, ...stagedDeletedGitlinkRoots, ...shadowGitlinkRoots])].sort();
|
|
610
|
+
const visibleFiles = listGitEligibleFiles(visibleRoot).filter((filePath) => !pathIsWithinOpaqueRoot(filePath, visibleGitlinkRoots));
|
|
266
611
|
const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
267
|
-
|
|
612
|
+
mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks);
|
|
613
|
+
mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot), visibleGitlinkRoots, visibleGitlinkRoots);
|
|
268
614
|
if (checkoutGitSnapshot(visibleRoot) !== beforeSnapshot) {
|
|
269
615
|
restoreShadowProjectSnapshot(input, relativeRoot);
|
|
616
|
+
return { entries: [], opaqueRoots: [] };
|
|
270
617
|
}
|
|
618
|
+
return {
|
|
619
|
+
entries: visibleGitlinks.map((entry) => ({
|
|
620
|
+
filePath: path.posix.join(relativeRoot, entry.filePath),
|
|
621
|
+
objectId: entry.objectId
|
|
622
|
+
})),
|
|
623
|
+
opaqueRoots: visibleGitlinkRoots.map((filePath) => path.posix.join(relativeRoot, filePath))
|
|
624
|
+
};
|
|
271
625
|
}
|
|
272
|
-
function mirrorShadowProjectToVisible(input, manifest, branchName) {
|
|
626
|
+
function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpaqueRoots = []) {
|
|
273
627
|
const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
|
|
274
628
|
if (!fs.existsSync(path.join(visibleRoot, ".git"))) return;
|
|
629
|
+
checkoutGitlinks(visibleRoot);
|
|
275
630
|
const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
|
|
276
631
|
const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
277
632
|
const shadowFiles = listShadowTrackedFiles(input, relativeRoot);
|
|
278
|
-
|
|
633
|
+
const desiredGitlinks = shadowProjectGitlinks(input, relativeRoot);
|
|
634
|
+
const desiredGitlinkPaths = new Set(desiredGitlinks.map((entry) => entry.filePath));
|
|
635
|
+
const visibleGitlinks = checkoutGitlinks(visibleRoot);
|
|
636
|
+
const protectedGitlinkRoots = [
|
|
637
|
+
.../* @__PURE__ */ new Set([...visibleGitlinks.map((entry) => entry.filePath), ...stagedDeletedGitlinkPaths(visibleRoot), ...additionalOpaqueRoots])
|
|
638
|
+
].sort();
|
|
639
|
+
assertSafeVisibleGitlinkTransitions(
|
|
640
|
+
visibleRoot,
|
|
641
|
+
protectedGitlinkRoots.map((filePath) => ({ filePath, objectId: "" })),
|
|
642
|
+
desiredGitlinks,
|
|
643
|
+
shadowFiles
|
|
644
|
+
);
|
|
645
|
+
const opaqueVisibleGitlinks = protectedGitlinkRoots.filter(
|
|
646
|
+
(filePath) => desiredGitlinkPaths.has(filePath) || !shadowFiles.some((shadowFile) => shadowFile === filePath || shadowFile.startsWith(`${filePath}/`))
|
|
647
|
+
);
|
|
648
|
+
mirrorFileSet(shadowRoot, visibleRoot, shadowFiles, listGitEligibleFiles(visibleRoot), opaqueVisibleGitlinks);
|
|
649
|
+
mirrorShadowGitlinksToVisible(desiredGitlinks, visibleRoot);
|
|
279
650
|
}
|
|
280
651
|
function mirrorLocalPlansToShadow(input, manifest, branchName) {
|
|
281
652
|
const sourceRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
|
|
@@ -296,25 +667,36 @@ function mirrorShadowPlansToLocal(input, manifest, branchName) {
|
|
|
296
667
|
listFilesRecursively(targetRoot, planFilter)
|
|
297
668
|
);
|
|
298
669
|
}
|
|
670
|
+
function mergeGitlinkProjection(target, source) {
|
|
671
|
+
target.entries.push(...source.entries);
|
|
672
|
+
target.opaqueRoots.push(...source.opaqueRoots);
|
|
673
|
+
}
|
|
299
674
|
function mirrorVisibleWorkspaceToShadow(input, excludedCheckouts = /* @__PURE__ */ new Set()) {
|
|
675
|
+
const projection = { entries: [], opaqueRoots: [] };
|
|
300
676
|
for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
|
|
301
677
|
for (const branchName of [...new Set(manifest.branches)].sort()) {
|
|
302
678
|
if (excludedCheckouts.has(`${manifest.projectId}\0${branchName}`)) continue;
|
|
303
|
-
mirrorVisibleProjectToShadow(input, manifest, branchName);
|
|
304
|
-
mirrorLocalPlansToShadow(input, manifest, branchName);
|
|
679
|
+
mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, branchName));
|
|
680
|
+
if (!input.trigger.canonicalCheckoutOnly) mirrorLocalPlansToShadow(input, manifest, branchName);
|
|
305
681
|
}
|
|
306
682
|
}
|
|
683
|
+
projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
|
|
684
|
+
return projection;
|
|
307
685
|
}
|
|
308
686
|
function reconcileNewVisibleCheckouts(input) {
|
|
687
|
+
const projection = { entries: [], opaqueRoots: [] };
|
|
309
688
|
for (const target of input.newVisibleCheckouts ?? []) {
|
|
310
689
|
const manifest = input.projects.find((project) => project.projectId === target.projectId);
|
|
311
690
|
if (!manifest || !manifest.branches.includes(target.branchName)) continue;
|
|
312
691
|
const projectRoot = workspaceProjectBranchRelativePath(target.projectId, target.branchName);
|
|
313
|
-
if (
|
|
692
|
+
if (input.trigger.canonicalCheckoutOnly) {
|
|
693
|
+
mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
|
|
694
|
+
} else if (listShadowTrackedFiles(input, projectRoot).length > 0 || restoreShadowRootFromRemote(input, projectRoot)) {
|
|
314
695
|
mirrorShadowProjectToVisible(input, manifest, target.branchName);
|
|
315
696
|
} else {
|
|
316
|
-
mirrorVisibleProjectToShadow(input, manifest, target.branchName);
|
|
697
|
+
mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
|
|
317
698
|
}
|
|
699
|
+
if (input.trigger.canonicalCheckoutOnly) continue;
|
|
318
700
|
const plansRoot = workspacePlansRelativePath(target.projectId, target.branchName);
|
|
319
701
|
if (listShadowTrackedFiles(input, plansRoot).length > 0 || restoreShadowRootFromRemote(input, plansRoot)) {
|
|
320
702
|
mirrorShadowPlansToLocal(input, manifest, target.branchName);
|
|
@@ -322,12 +704,93 @@ function reconcileNewVisibleCheckouts(input) {
|
|
|
322
704
|
mirrorLocalPlansToShadow(input, manifest, target.branchName);
|
|
323
705
|
}
|
|
324
706
|
}
|
|
707
|
+
projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
|
|
708
|
+
return projection;
|
|
325
709
|
}
|
|
326
|
-
function mirrorShadowWorkspaceToVisible(input) {
|
|
710
|
+
function mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots = []) {
|
|
327
711
|
for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
|
|
328
712
|
for (const branchName of [...new Set(manifest.branches)].sort()) {
|
|
329
|
-
|
|
330
|
-
|
|
713
|
+
const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
|
|
714
|
+
const opaqueProjectRoots = opaqueWorkspaceRoots.filter((root) => root.startsWith(`${relativeRoot}/`)).map((root) => root.slice(relativeRoot.length + 1));
|
|
715
|
+
mirrorShadowProjectToVisible(input, manifest, branchName, opaqueProjectRoots);
|
|
716
|
+
if (!input.trigger.canonicalCheckoutOnly) mirrorShadowPlansToLocal(input, manifest, branchName);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function forceStageCanonicalCheckoutFiles(input, preservedGitlinks = [], opaqueRoots = []) {
|
|
721
|
+
for (const manifest of input.projects) {
|
|
722
|
+
for (const checkout of manifest.canonicalCheckouts ?? []) {
|
|
723
|
+
if (!manifest.branches.includes(checkout.branchName)) continue;
|
|
724
|
+
const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, checkout.branchName);
|
|
725
|
+
const absoluteRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
726
|
+
if (!fs.existsSync(absoluteRoot)) continue;
|
|
727
|
+
const scopedOpaqueRoots = opaqueRoots.filter((root) => root.startsWith(`${relativeRoot}/`)).map((root) => root.slice(relativeRoot.length + 1));
|
|
728
|
+
runGit(
|
|
729
|
+
input,
|
|
730
|
+
input.shadowRoot,
|
|
731
|
+
[
|
|
732
|
+
"add",
|
|
733
|
+
"-f",
|
|
734
|
+
"-A",
|
|
735
|
+
"--",
|
|
736
|
+
`:(literal)${relativeRoot}`,
|
|
737
|
+
...scopedOpaqueRoots.map((root) => `:(exclude,literal)${path.posix.join(relativeRoot, root)}`)
|
|
738
|
+
],
|
|
739
|
+
`force-stage canonical resolver checkout ${manifest.projectId}/${checkout.branchName}`
|
|
740
|
+
);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
for (const gitlink of preservedGitlinks) {
|
|
744
|
+
replaceShadowIndexPathWithGitlink(input, "", gitlink);
|
|
745
|
+
}
|
|
746
|
+
const preservedPaths = new Set(preservedGitlinks.map((entry) => entry.filePath));
|
|
747
|
+
for (const opaqueRoot of opaqueRoots) {
|
|
748
|
+
if (preservedPaths.has(opaqueRoot)) continue;
|
|
749
|
+
runGit(
|
|
750
|
+
input,
|
|
751
|
+
input.shadowRoot,
|
|
752
|
+
["--literal-pathspecs", "update-index", "--force-remove", "--", opaqueRoot],
|
|
753
|
+
`remove explicitly deleted workspace gitlink ${JSON.stringify(opaqueRoot)}`
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function canonicalCheckoutRelativeRoots(input) {
|
|
758
|
+
const roots = /* @__PURE__ */ new Set();
|
|
759
|
+
for (const manifest of input.projects) {
|
|
760
|
+
for (const checkout of manifest.canonicalCheckouts) {
|
|
761
|
+
roots.add(workspaceProjectBranchRelativePath(manifest.projectId, checkout.branchName));
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
return [...roots].sort();
|
|
765
|
+
}
|
|
766
|
+
function restoreUnscopedCanonicalCheckoutSubtrees(input) {
|
|
767
|
+
if (input.trigger.canonicalCheckoutOnly) return;
|
|
768
|
+
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
769
|
+
const emptyTree = emptyTreeHash(input);
|
|
770
|
+
for (const relativeRoot of canonicalCheckoutRelativeRoots(input)) {
|
|
771
|
+
const remoteTree = remoteHead ? workspaceSubtreeTreeHashAtRevision(input, remoteHead, relativeRoot) : emptyTree;
|
|
772
|
+
if (remoteTree === emptyTree) {
|
|
773
|
+
runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
|
|
774
|
+
fs.rmSync(path.join(input.shadowRoot, ...relativeRoot.split("/")), { recursive: true, force: true });
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
runGit(
|
|
778
|
+
input,
|
|
779
|
+
input.shadowRoot,
|
|
780
|
+
["--literal-pathspecs", "restore", `--source=${remoteHead}`, "--staged", "--worktree", "--", relativeRoot],
|
|
781
|
+
`restore excluded canonical resolver subtree ${relativeRoot}`
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
function assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, revision) {
|
|
786
|
+
if (input.trigger.canonicalCheckoutOnly) return;
|
|
787
|
+
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
788
|
+
const emptyTree = emptyTreeHash(input);
|
|
789
|
+
for (const relativeRoot of canonicalCheckoutRelativeRoots(input)) {
|
|
790
|
+
const candidateTree = workspaceSubtreeTreeHashAtRevision(input, revision, relativeRoot);
|
|
791
|
+
const remoteTree = remoteHead ? workspaceSubtreeTreeHashAtRevision(input, remoteHead, relativeRoot) : emptyTree;
|
|
792
|
+
if (candidateTree !== remoteTree) {
|
|
793
|
+
throw new Error(`Generic workspace synchronization cannot publish canonical resolver subtree ${relativeRoot}`);
|
|
331
794
|
}
|
|
332
795
|
}
|
|
333
796
|
}
|
|
@@ -360,6 +823,64 @@ function revParse(input, revision) {
|
|
|
360
823
|
const result = runGitResult(input, input.shadowRoot, ["rev-parse", "--verify", revision]);
|
|
361
824
|
return result.exitCode === 0 ? result.stdout : null;
|
|
362
825
|
}
|
|
826
|
+
function emptyTreeHash(input) {
|
|
827
|
+
const result = Bun.spawnSync(gitArgs(input, ["mktree"]), {
|
|
828
|
+
cwd: input.shadowRoot,
|
|
829
|
+
stdin: Buffer.alloc(0),
|
|
830
|
+
stdout: "pipe",
|
|
831
|
+
stderr: "pipe",
|
|
832
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
833
|
+
});
|
|
834
|
+
if (result.exitCode !== 0) {
|
|
835
|
+
throw new Error(`create empty workspace tree: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
|
|
836
|
+
}
|
|
837
|
+
return result.stdout.toString().trim();
|
|
838
|
+
}
|
|
839
|
+
function workspaceSubtreeTreeHashAtRevision(input, revision, relativeRoot) {
|
|
840
|
+
const rootType = runGitResult(input, input.shadowRoot, ["cat-file", "-t", revision]);
|
|
841
|
+
if (rootType.exitCode !== 0 || rootType.stdout !== "commit" && rootType.stdout !== "tree") {
|
|
842
|
+
throw new Error(`Cannot inspect canonical resolver subtree at invalid workspace revision ${revision}`);
|
|
843
|
+
}
|
|
844
|
+
let treeHash = rootType.stdout === "commit" ? runGit(input, input.shadowRoot, ["rev-parse", "--verify", `${revision}^{tree}`], "resolve workspace root tree") : revision;
|
|
845
|
+
for (const segment of relativeRoot.split("/")) {
|
|
846
|
+
const entry = runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "ls-tree", "-z", treeHash, "--", segment]);
|
|
847
|
+
if (entry.exitCode !== 0) {
|
|
848
|
+
throw new Error(`Inspect canonical resolver workspace path ${relativeRoot}: ${entry.stderr || entry.stdout}`);
|
|
849
|
+
}
|
|
850
|
+
if (!entry.stdout) return emptyTreeHash(input);
|
|
851
|
+
const separator = entry.stdout.indexOf(" ");
|
|
852
|
+
const [mode, type, objectId] = separator === -1 ? [] : entry.stdout.slice(0, separator).split(" ");
|
|
853
|
+
const entryName = separator === -1 ? "" : entry.stdout.slice(separator + 1).replace(/\0+$/, "");
|
|
854
|
+
if (!mode || type !== "tree" || !objectId || entryName !== segment) {
|
|
855
|
+
throw new Error(`Canonical resolver workspace path ${relativeRoot} contains a non-tree component ${segment}`);
|
|
856
|
+
}
|
|
857
|
+
treeHash = objectId;
|
|
858
|
+
}
|
|
859
|
+
return treeHash;
|
|
860
|
+
}
|
|
861
|
+
function canonicalCheckoutTreeHashAtRevision(input, revision) {
|
|
862
|
+
if (!input.trigger.canonicalCheckoutOnly || !input.trigger.projectId || !input.trigger.branchName) {
|
|
863
|
+
throw new Error("Canonical checkout tree hashing requires an exact canonical-checkout-only trigger");
|
|
864
|
+
}
|
|
865
|
+
return workspaceSubtreeTreeHashAtRevision(
|
|
866
|
+
input,
|
|
867
|
+
revision,
|
|
868
|
+
workspaceProjectBranchRelativePath(input.trigger.projectId, input.trigger.branchName)
|
|
869
|
+
);
|
|
870
|
+
}
|
|
871
|
+
function sampleCanonicalCheckoutTree(input) {
|
|
872
|
+
if (!input.trigger.canonicalCheckoutOnly) return void 0;
|
|
873
|
+
const stagedWorkspaceTree = runGit(input, input.shadowRoot, ["write-tree"], "write canonical resolver workspace tree");
|
|
874
|
+
return canonicalCheckoutTreeHashAtRevision(input, stagedWorkspaceTree);
|
|
875
|
+
}
|
|
876
|
+
function assertPublishedCanonicalCheckoutTree(input, revision, sampledTreeHash) {
|
|
877
|
+
if (!input.trigger.canonicalCheckoutOnly) return;
|
|
878
|
+
if (!sampledTreeHash) throw new Error("Canonical resolver synchronization did not capture a staged subtree tree hash");
|
|
879
|
+
const publishedTreeHash = canonicalCheckoutTreeHashAtRevision(input, revision);
|
|
880
|
+
if (publishedTreeHash !== sampledTreeHash) {
|
|
881
|
+
throw new Error(`Canonical resolver subtree changed while synchronizing: sampled ${sampledTreeHash}, published ${publishedTreeHash}`);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
363
884
|
function gitStatus(input) {
|
|
364
885
|
return runGit(input, input.shadowRoot, ["status", "--porcelain=v1", "--untracked-files=all"], "read workspace status");
|
|
365
886
|
}
|
|
@@ -443,8 +964,9 @@ function revisionTrackedFileCount(input, revision, relativeRoot) {
|
|
|
443
964
|
return result.stdout.split("\0").filter(Boolean).length;
|
|
444
965
|
}
|
|
445
966
|
function destructiveCheckoutReductions(input, baseRevision) {
|
|
967
|
+
const authoritativeRoot = input.trigger.canonicalCheckoutOnly && input.trigger.projectId && input.trigger.branchName ? workspaceProjectBranchRelativePath(input.trigger.projectId, input.trigger.branchName) : null;
|
|
446
968
|
const roots = new Set(
|
|
447
|
-
stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root))
|
|
969
|
+
stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root) && root !== authoritativeRoot)
|
|
448
970
|
);
|
|
449
971
|
const destructive = [];
|
|
450
972
|
for (const root of roots) {
|
|
@@ -519,14 +1041,14 @@ function isNonFastForward(result) {
|
|
|
519
1041
|
${result.stderr}`.toLowerCase();
|
|
520
1042
|
return output.includes("non-fast-forward") || output.includes("fetch first") || output.includes("[rejected]");
|
|
521
1043
|
}
|
|
522
|
-
function resetShadowToRemote(input) {
|
|
1044
|
+
function resetShadowToRemote(input, opaqueWorkspaceRoots = []) {
|
|
523
1045
|
runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch canonical workspace before reset");
|
|
524
1046
|
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
525
1047
|
if (!remoteHead) return null;
|
|
526
1048
|
runGitResult(input, input.shadowRoot, ["rebase", "--abort"]);
|
|
527
1049
|
runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "reset workspace to canonical state");
|
|
528
1050
|
runGit(input, input.shadowRoot, ["clean", "-fd"], "clean reset workspace");
|
|
529
|
-
mirrorShadowWorkspaceToVisible(input);
|
|
1051
|
+
if (!input.trigger.canonicalCheckoutOnly) mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots);
|
|
530
1052
|
return remoteHead;
|
|
531
1053
|
}
|
|
532
1054
|
function baseResult(input, startingHead) {
|
|
@@ -546,12 +1068,17 @@ function baseResult(input, startingHead) {
|
|
|
546
1068
|
};
|
|
547
1069
|
}
|
|
548
1070
|
async function synchronizeWorkspace(rawInput) {
|
|
549
|
-
|
|
1071
|
+
let input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
|
|
550
1072
|
try {
|
|
1073
|
+
input = { ...input, projects: workspaceProjectsForSync(input.projects, input.trigger) };
|
|
1074
|
+
let mirroredGitlinkProjection = { entries: [], opaqueRoots: [] };
|
|
551
1075
|
ensureShadowWorkspace(input);
|
|
552
1076
|
const remoteHeadAtStart = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
553
1077
|
const result = baseResult(input, remoteHeadAtStart);
|
|
554
1078
|
if (input.resetToCanonical) {
|
|
1079
|
+
if (input.trigger.canonicalCheckoutOnly) {
|
|
1080
|
+
throw new Error("A canonical-checkout-only synchronization cannot reset the authoritative resolver checkout");
|
|
1081
|
+
}
|
|
555
1082
|
const publishedHead = resetShadowToRemote(input);
|
|
556
1083
|
return {
|
|
557
1084
|
...result,
|
|
@@ -561,14 +1088,19 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
561
1088
|
gitStatus: gitStatus(input)
|
|
562
1089
|
};
|
|
563
1090
|
}
|
|
1091
|
+
if (!input.skipVisibleMirror) resetUncommittedShadowSnapshot(input);
|
|
1092
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
564
1093
|
if (!input.skipVisibleMirror) {
|
|
565
|
-
resetUncommittedShadowSnapshot(input);
|
|
566
1094
|
const newCheckoutKeys = new Set((input.newVisibleCheckouts ?? []).map((target) => `${target.projectId}\0${target.branchName}`));
|
|
567
|
-
mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
|
|
1095
|
+
mirroredGitlinkProjection = mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
|
|
568
1096
|
fastForwardCleanShadowForNewCheckouts(input);
|
|
569
|
-
reconcileNewVisibleCheckouts(input);
|
|
1097
|
+
mergeGitlinkProjection(mirroredGitlinkProjection, reconcileNewVisibleCheckouts(input));
|
|
1098
|
+
mirroredGitlinkProjection.opaqueRoots = [...new Set(mirroredGitlinkProjection.opaqueRoots)].sort();
|
|
570
1099
|
}
|
|
571
|
-
|
|
1100
|
+
const stagePathspecs = mirroredGitlinkProjection.opaqueRoots.flatMap((root) => [`:(exclude,literal)${root}`]);
|
|
1101
|
+
runGit(input, input.shadowRoot, ["add", "-A", "--", ".", ...stagePathspecs], "stage workspace changes");
|
|
1102
|
+
forceStageCanonicalCheckoutFiles(input, mirroredGitlinkProjection.entries, mirroredGitlinkProjection.opaqueRoots);
|
|
1103
|
+
const sampledCanonicalCheckoutTreeHash = sampleCanonicalCheckoutTree(input);
|
|
572
1104
|
const stagedWorkingPaths = stagedPaths(input);
|
|
573
1105
|
const baseRevision = candidateBaseRevision(input);
|
|
574
1106
|
const paths = stagedPaths(input, baseRevision);
|
|
@@ -579,7 +1111,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
579
1111
|
diffSizeBytes,
|
|
580
1112
|
gitStatus: statusBeforeCommit,
|
|
581
1113
|
affectedPaths: reportedPaths(paths),
|
|
582
|
-
affectedProjects: affectedProjects(paths)
|
|
1114
|
+
affectedProjects: affectedProjects(paths),
|
|
1115
|
+
...sampledCanonicalCheckoutTreeHash ? { sampledCanonicalCheckoutTreeHash } : {}
|
|
583
1116
|
};
|
|
584
1117
|
const destructiveReductions = destructiveCheckoutReductions(input, baseRevision);
|
|
585
1118
|
if (destructiveReductions.length > 0 && !input.confirmedLargeDiff) {
|
|
@@ -590,7 +1123,7 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
590
1123
|
error: `Workspace safety guard blocked a destructive checkout reduction: ${summary}`
|
|
591
1124
|
};
|
|
592
1125
|
}
|
|
593
|
-
if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff) {
|
|
1126
|
+
if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff && !input.trigger.canonicalCheckoutOnly) {
|
|
594
1127
|
return {
|
|
595
1128
|
...observed,
|
|
596
1129
|
outcome: "large_diff_blocked",
|
|
@@ -601,6 +1134,7 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
601
1134
|
runGit(input, input.shadowRoot, ["commit", "-m", commitMessage(input)], "commit workspace changes");
|
|
602
1135
|
}
|
|
603
1136
|
const candidateHead = revParse(input, "HEAD");
|
|
1137
|
+
if (candidateHead) assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, candidateHead);
|
|
604
1138
|
const hasRemoteMain = Boolean(revParse(input, `origin/${WORKSPACE_BRANCH}`));
|
|
605
1139
|
const hasUnpushedCommit = candidateHead ? !hasRemoteMain || !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", candidateHead, `origin/${WORKSPACE_BRANCH}`]) : false;
|
|
606
1140
|
if (!hasUnpushedCommit) {
|
|
@@ -609,7 +1143,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
609
1143
|
if (currentRemoteHead && localHead !== currentRemoteHead) {
|
|
610
1144
|
runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "update workspace from canonical state");
|
|
611
1145
|
runGit(input, input.shadowRoot, ["clean", "-fd"], "clean updated workspace");
|
|
612
|
-
|
|
1146
|
+
assertPublishedCanonicalCheckoutTree(input, currentRemoteHead, sampledCanonicalCheckoutTreeHash);
|
|
1147
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
613
1148
|
return {
|
|
614
1149
|
...observed,
|
|
615
1150
|
outcome: "updated",
|
|
@@ -618,7 +1153,13 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
618
1153
|
gitStatus: gitStatus(input)
|
|
619
1154
|
};
|
|
620
1155
|
}
|
|
621
|
-
|
|
1156
|
+
const authoritativeHead = currentRemoteHead ?? localHead;
|
|
1157
|
+
if (authoritativeHead) {
|
|
1158
|
+
assertPublishedCanonicalCheckoutTree(input, authoritativeHead, sampledCanonicalCheckoutTreeHash);
|
|
1159
|
+
}
|
|
1160
|
+
if (input.skipVisibleMirror) {
|
|
1161
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
1162
|
+
}
|
|
622
1163
|
return {
|
|
623
1164
|
...observed,
|
|
624
1165
|
outcome: "no_change",
|
|
@@ -632,7 +1173,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
632
1173
|
const push = runGitResult(input, input.shadowRoot, ["push", "origin", `HEAD:refs/heads/${WORKSPACE_BRANCH}`]);
|
|
633
1174
|
if (push.exitCode === 0) {
|
|
634
1175
|
const publishedHead = revParse(input, "HEAD") ?? candidateHead;
|
|
635
|
-
|
|
1176
|
+
if (publishedHead) assertPublishedCanonicalCheckoutTree(input, publishedHead, sampledCanonicalCheckoutTreeHash);
|
|
1177
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
636
1178
|
return {
|
|
637
1179
|
...observed,
|
|
638
1180
|
outcome: "published",
|
|
@@ -658,7 +1200,7 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
658
1200
|
if (rebase.exitCode !== 0) {
|
|
659
1201
|
const conflicted = runGitResult(input, input.shadowRoot, ["diff", "--name-only", "--diff-filter=U", "-z"]);
|
|
660
1202
|
const discardedPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
|
|
661
|
-
const publishedHead = resetShadowToRemote(input);
|
|
1203
|
+
const publishedHead = resetShadowToRemote(input, mirroredGitlinkProjection.opaqueRoots);
|
|
662
1204
|
return {
|
|
663
1205
|
...observed,
|
|
664
1206
|
outcome: "conflict_reset",
|
|
@@ -670,6 +1212,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
670
1212
|
gitStatus: gitStatus(input)
|
|
671
1213
|
};
|
|
672
1214
|
}
|
|
1215
|
+
assertPublishedCanonicalCheckoutTree(input, "HEAD", sampledCanonicalCheckoutTreeHash);
|
|
1216
|
+
assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, "HEAD");
|
|
673
1217
|
}
|
|
674
1218
|
return {
|
|
675
1219
|
...observed,
|
|
@@ -688,13 +1232,27 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
688
1232
|
};
|
|
689
1233
|
}
|
|
690
1234
|
}
|
|
691
|
-
function calculateWorkspaceDiffFingerprint(
|
|
1235
|
+
function calculateWorkspaceDiffFingerprint(rawInput) {
|
|
1236
|
+
const input = {
|
|
1237
|
+
...rawInput,
|
|
1238
|
+
projects: workspaceProjectsForSync(rawInput.projects, rawInput.trigger)
|
|
1239
|
+
};
|
|
692
1240
|
ensureShadowWorkspace(input);
|
|
1241
|
+
let gitlinkProjection = { entries: [], opaqueRoots: [] };
|
|
693
1242
|
if (!input.skipVisibleMirror) {
|
|
694
1243
|
resetUncommittedShadowSnapshot(input);
|
|
695
|
-
|
|
1244
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
1245
|
+
gitlinkProjection = mirrorVisibleWorkspaceToShadow(input);
|
|
1246
|
+
} else {
|
|
1247
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
696
1248
|
}
|
|
697
|
-
runGit(
|
|
1249
|
+
runGit(
|
|
1250
|
+
input,
|
|
1251
|
+
input.shadowRoot,
|
|
1252
|
+
["add", "-A", "--", ".", ...gitlinkProjection.opaqueRoots.map((root) => `:(exclude,literal)${root}`)],
|
|
1253
|
+
"stage workspace fingerprint"
|
|
1254
|
+
);
|
|
1255
|
+
forceStageCanonicalCheckoutFiles(input, gitlinkProjection.entries, gitlinkProjection.opaqueRoots);
|
|
698
1256
|
const stagedTree = runGit(input, input.shadowRoot, ["write-tree"], "write workspace fingerprint tree");
|
|
699
1257
|
const headTree = revParse(input, "HEAD^{tree}");
|
|
700
1258
|
const localHead = revParse(input, "HEAD");
|
|
@@ -735,6 +1293,7 @@ export {
|
|
|
735
1293
|
WORKSPACE_BRANCH,
|
|
736
1294
|
WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
|
|
737
1295
|
WorkspaceSyncSingleFlight,
|
|
1296
|
+
assertCanonicalCheckoutIndexMatchesWorktree,
|
|
738
1297
|
calculateWorkspaceDiffFingerprint,
|
|
739
1298
|
encodeWorkspaceBranch,
|
|
740
1299
|
mirrorShadowWorkspaceToVisible,
|
|
@@ -742,5 +1301,6 @@ export {
|
|
|
742
1301
|
synchronizeWorkspace,
|
|
743
1302
|
visibleProjectBranchPath,
|
|
744
1303
|
workspacePlansRelativePath,
|
|
745
|
-
workspaceProjectBranchRelativePath
|
|
1304
|
+
workspaceProjectBranchRelativePath,
|
|
1305
|
+
workspaceProjectsForSync
|
|
746
1306
|
};
|