@ricsam/r5d-worker 0.0.45 → 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 +542 -153
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/workspace-sync.cjs +790 -90
- package/dist/mjs/main.mjs +541 -154
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/workspace-sync.mjs +787 -89
- package/dist/types/main.d.ts +59 -11
- package/dist/types/workspace-sync.d.ts +33 -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");
|
|
@@ -53,12 +55,19 @@ const MAX_REPORTED_WORKSPACE_PATHS = 2e3;
|
|
|
53
55
|
const MAX_REPORTED_WORKSPACE_STATUS_BYTES = 256 * 1024;
|
|
54
56
|
const MAX_MIRROR_COMPARISON_CACHE_ENTRIES = 2e5;
|
|
55
57
|
const MIRROR_COMPARE_BUFFER_BYTES = 64 * 1024;
|
|
58
|
+
const DESTRUCTIVE_CHECKOUT_MIN_TRACKED_FILES = 20;
|
|
59
|
+
const DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO = 0.8;
|
|
60
|
+
const ACTIVE_GIT_LOCK_PATHS = ["index.lock", "HEAD.lock", "packed-refs.lock", "shallow.lock"];
|
|
56
61
|
const mirrorComparisonCache = /* @__PURE__ */ new Map();
|
|
57
62
|
function gitArgs(input, args) {
|
|
58
|
-
|
|
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;
|
|
59
68
|
}
|
|
60
69
|
function runGitResult(input, cwd, args) {
|
|
61
|
-
const result = Bun.spawnSync(gitArgs(input, args), {
|
|
70
|
+
const result = Bun.spawnSync(gitArgs(input, withoutSubmoduleRecursion(args)), {
|
|
62
71
|
cwd,
|
|
63
72
|
stdout: "pipe",
|
|
64
73
|
stderr: "pipe",
|
|
@@ -86,6 +95,35 @@ function tryGit(input, cwd, args) {
|
|
|
86
95
|
function encodeWorkspaceBranch(branchName) {
|
|
87
96
|
return encodeURIComponent(branchName);
|
|
88
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
|
+
}
|
|
89
127
|
function workspaceProjectBranchRelativePath(projectId, branchName) {
|
|
90
128
|
return import_node_path.default.posix.join("projects", projectId, "branches", encodeWorkspaceBranch(branchName));
|
|
91
129
|
}
|
|
@@ -110,12 +148,30 @@ function listGitEligibleFiles(checkoutPath) {
|
|
|
110
148
|
if (!import_node_fs.default.existsSync(import_node_path.default.join(checkoutPath, ".git"))) {
|
|
111
149
|
return [];
|
|
112
150
|
}
|
|
113
|
-
const result = Bun.spawnSync(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
+
);
|
|
119
175
|
if (result.exitCode !== 0) {
|
|
120
176
|
throw new Error(`inspect eligible project files: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
|
|
121
177
|
}
|
|
@@ -128,13 +184,195 @@ function listGitEligibleFiles(checkoutPath) {
|
|
|
128
184
|
}
|
|
129
185
|
}).sort();
|
|
130
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
|
+
}
|
|
310
|
+
function checkoutGitSnapshot(checkoutPath) {
|
|
311
|
+
const gitPaths = Bun.spawnSync(
|
|
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
|
+
],
|
|
325
|
+
{
|
|
326
|
+
cwd: checkoutPath,
|
|
327
|
+
stdout: "pipe",
|
|
328
|
+
stderr: "ignore",
|
|
329
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
330
|
+
}
|
|
331
|
+
);
|
|
332
|
+
if (gitPaths.exitCode !== 0) return null;
|
|
333
|
+
const [indexPath, ...lockPaths] = gitPaths.stdout.toString().trim().split(/\r?\n/).map((gitPath) => import_node_path.default.resolve(checkoutPath, gitPath));
|
|
334
|
+
if (!indexPath || lockPaths.length !== ACTIVE_GIT_LOCK_PATHS.length || lockPaths.some((lockPath) => import_node_fs.default.existsSync(lockPath))) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
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
|
+
);
|
|
357
|
+
let indexSignature = "missing";
|
|
358
|
+
try {
|
|
359
|
+
const stat = import_node_fs.default.statSync(indexPath);
|
|
360
|
+
indexSignature = entrySignature(stat);
|
|
361
|
+
} catch {
|
|
362
|
+
}
|
|
363
|
+
if (lockPaths.some((lockPath) => import_node_fs.default.existsSync(lockPath))) return null;
|
|
364
|
+
return `${head.exitCode === 0 ? head.stdout.toString().trim() : "unborn"}\0${indexSignature}`;
|
|
365
|
+
}
|
|
131
366
|
function listShadowTrackedFiles(input, relativeRoot) {
|
|
132
367
|
const normalizedRoot = relativeRoot.split(import_node_path.default.sep).join("/").replace(/^\/+|\/+$/g, "");
|
|
133
368
|
const output = runGit(input, input.shadowRoot, ["ls-files", "-z", "--cached", "--", normalizedRoot], "list canonical workspace files");
|
|
134
369
|
const prefix = `${normalizedRoot}/`;
|
|
135
370
|
return output.split("\0").filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length)).filter(Boolean).sort();
|
|
136
371
|
}
|
|
137
|
-
function
|
|
372
|
+
function pathIsWithinOpaqueRoot(relativePath, opaqueRoots) {
|
|
373
|
+
return opaqueRoots.some((root) => relativePath === root || relativePath.startsWith(`${root}/`));
|
|
374
|
+
}
|
|
375
|
+
function listFilesRecursively(root, filter, opaqueRoots = []) {
|
|
138
376
|
if (!import_node_fs.default.existsSync(root)) return [];
|
|
139
377
|
const files = [];
|
|
140
378
|
const visit = (current, relativeDir) => {
|
|
@@ -142,6 +380,7 @@ function listFilesRecursively(root, filter) {
|
|
|
142
380
|
if (entry.name === ".git") continue;
|
|
143
381
|
const relativePath = relativeDir ? import_node_path.default.posix.join(relativeDir, entry.name) : entry.name;
|
|
144
382
|
const absolutePath = import_node_path.default.join(current, entry.name);
|
|
383
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) continue;
|
|
145
384
|
if (entry.isDirectory()) {
|
|
146
385
|
visit(absolutePath, relativePath);
|
|
147
386
|
} else if (!filter || filter(relativePath)) {
|
|
@@ -152,14 +391,19 @@ function listFilesRecursively(root, filter) {
|
|
|
152
391
|
visit(root, "");
|
|
153
392
|
return files.sort();
|
|
154
393
|
}
|
|
155
|
-
function removeEmptyDirectories(root) {
|
|
394
|
+
function removeEmptyDirectories(root, opaqueRoots = []) {
|
|
156
395
|
if (!import_node_fs.default.existsSync(root)) return;
|
|
157
|
-
const visit = (current) => {
|
|
396
|
+
const visit = (current, relativeDir) => {
|
|
158
397
|
let empty = true;
|
|
159
398
|
for (const entry of import_node_fs.default.readdirSync(current, { withFileTypes: true })) {
|
|
160
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
|
+
}
|
|
161
405
|
if (entry.isDirectory()) {
|
|
162
|
-
if (!visit(absolutePath)) empty = false;
|
|
406
|
+
if (!visit(absolutePath, relativePath)) empty = false;
|
|
163
407
|
} else {
|
|
164
408
|
empty = false;
|
|
165
409
|
}
|
|
@@ -167,7 +411,7 @@ function removeEmptyDirectories(root) {
|
|
|
167
411
|
if (empty && current !== root) import_node_fs.default.rmdirSync(current);
|
|
168
412
|
return empty;
|
|
169
413
|
};
|
|
170
|
-
visit(root);
|
|
414
|
+
visit(root, "");
|
|
171
415
|
}
|
|
172
416
|
function copyEntry(sourceRoot, targetRoot, relativePath) {
|
|
173
417
|
const sourcePath = import_node_path.default.resolve(sourceRoot, ...relativePath.split("/"));
|
|
@@ -237,39 +481,218 @@ function entriesEqual(sourcePath, targetPath) {
|
|
|
237
481
|
}
|
|
238
482
|
return equal;
|
|
239
483
|
}
|
|
240
|
-
function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles) {
|
|
484
|
+
function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles, opaqueTargetRoots = [], opaqueSourceRoots = []) {
|
|
241
485
|
import_node_fs.default.mkdirSync(targetRoot, { recursive: true });
|
|
242
486
|
const sourceSet = new Set(sourceFiles);
|
|
243
487
|
for (const relativePath of targetFiles) {
|
|
488
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
|
|
244
489
|
if (sourceSet.has(relativePath)) continue;
|
|
245
490
|
const targetPath = import_node_path.default.resolve(targetRoot, ...relativePath.split("/"));
|
|
246
491
|
assertInside(targetRoot, targetPath, "Workspace deletion path");
|
|
247
492
|
import_node_fs.default.rmSync(targetPath, { recursive: true, force: true });
|
|
248
493
|
}
|
|
249
494
|
for (const relativePath of sourceFiles) {
|
|
495
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueSourceRoots)) continue;
|
|
496
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
|
|
250
497
|
const sourcePath = import_node_path.default.resolve(sourceRoot, ...relativePath.split("/"));
|
|
251
|
-
|
|
252
|
-
|
|
498
|
+
const stat = lstatOrNull(sourcePath);
|
|
499
|
+
if (!stat) continue;
|
|
253
500
|
if (!stat.isFile() && !stat.isSymbolicLink()) continue;
|
|
254
501
|
const targetPath = import_node_path.default.resolve(targetRoot, ...relativePath.split("/"));
|
|
255
502
|
if (entriesEqual(sourcePath, targetPath)) continue;
|
|
256
503
|
copyEntry(sourceRoot, targetRoot, relativePath);
|
|
257
504
|
}
|
|
258
|
-
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
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function restoreShadowProjectSnapshot(input, relativeRoot) {
|
|
621
|
+
const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
622
|
+
const headFiles = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", "HEAD", "--", relativeRoot]);
|
|
623
|
+
if (headFiles.exitCode === 0 && headFiles.stdout) {
|
|
624
|
+
runGit(
|
|
625
|
+
input,
|
|
626
|
+
input.shadowRoot,
|
|
627
|
+
["restore", "--source=HEAD", "--staged", "--worktree", "--", relativeRoot],
|
|
628
|
+
"restore deferred checkout scan"
|
|
629
|
+
);
|
|
630
|
+
runGit(input, input.shadowRoot, ["clean", "-fd", "--", relativeRoot], "clean deferred checkout scan");
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
|
|
634
|
+
import_node_fs.default.rmSync(shadowRoot, { recursive: true, force: true });
|
|
259
635
|
}
|
|
260
636
|
function mirrorVisibleProjectToShadow(input, manifest, branchName) {
|
|
261
637
|
const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
|
|
262
|
-
if (!import_node_fs.default.existsSync(import_node_path.default.join(visibleRoot, ".git"))) return;
|
|
263
|
-
const
|
|
264
|
-
|
|
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
|
+
}
|
|
643
|
+
const beforeSnapshot = checkoutGitSnapshot(visibleRoot);
|
|
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);
|
|
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));
|
|
657
|
+
const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
658
|
+
mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks);
|
|
659
|
+
mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot), visibleGitlinkRoots, visibleGitlinkRoots);
|
|
660
|
+
if (checkoutGitSnapshot(visibleRoot) !== beforeSnapshot) {
|
|
661
|
+
restoreShadowProjectSnapshot(input, relativeRoot);
|
|
662
|
+
return { entries: [], opaqueRoots: [] };
|
|
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
|
+
};
|
|
265
671
|
}
|
|
266
|
-
function mirrorShadowProjectToVisible(input, manifest, branchName) {
|
|
672
|
+
function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpaqueRoots = []) {
|
|
267
673
|
const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
|
|
268
674
|
if (!import_node_fs.default.existsSync(import_node_path.default.join(visibleRoot, ".git"))) return;
|
|
675
|
+
checkoutGitlinks(visibleRoot);
|
|
269
676
|
const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
|
|
270
677
|
const shadowRoot = import_node_path.default.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
271
678
|
const shadowFiles = listShadowTrackedFiles(input, relativeRoot);
|
|
272
|
-
|
|
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);
|
|
273
696
|
}
|
|
274
697
|
function mirrorLocalPlansToShadow(input, manifest, branchName) {
|
|
275
698
|
const sourceRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
|
|
@@ -290,60 +713,130 @@ function mirrorShadowPlansToLocal(input, manifest, branchName) {
|
|
|
290
713
|
listFilesRecursively(targetRoot, planFilter)
|
|
291
714
|
);
|
|
292
715
|
}
|
|
293
|
-
function
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
if (allowedNames.has(entry)) continue;
|
|
297
|
-
const target = import_node_path.default.resolve(root, entry);
|
|
298
|
-
assertInside(root, target, "Workspace manifest prune path");
|
|
299
|
-
import_node_fs.default.rmSync(target, { recursive: true, force: true });
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
function pruneShadowToManifest(input) {
|
|
303
|
-
const projectIds = new Set(input.projects.map((project) => project.projectId));
|
|
304
|
-
const projectsRoot = import_node_path.default.join(input.shadowRoot, "projects");
|
|
305
|
-
const plansRoot = import_node_path.default.join(input.shadowRoot, "plans");
|
|
306
|
-
pruneDirectoryChildren(projectsRoot, projectIds);
|
|
307
|
-
pruneDirectoryChildren(plansRoot, projectIds);
|
|
308
|
-
for (const manifest of input.projects) {
|
|
309
|
-
const encodedBranches = new Set(manifest.branches.map(encodeWorkspaceBranch));
|
|
310
|
-
pruneDirectoryChildren(import_node_path.default.join(projectsRoot, manifest.projectId, "branches"), encodedBranches);
|
|
311
|
-
pruneDirectoryChildren(import_node_path.default.join(plansRoot, manifest.projectId), encodedBranches);
|
|
312
|
-
}
|
|
716
|
+
function mergeGitlinkProjection(target, source) {
|
|
717
|
+
target.entries.push(...source.entries);
|
|
718
|
+
target.opaqueRoots.push(...source.opaqueRoots);
|
|
313
719
|
}
|
|
314
720
|
function mirrorVisibleWorkspaceToShadow(input, excludedCheckouts = /* @__PURE__ */ new Set()) {
|
|
315
|
-
|
|
721
|
+
const projection = { entries: [], opaqueRoots: [] };
|
|
316
722
|
for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
|
|
317
723
|
for (const branchName of [...new Set(manifest.branches)].sort()) {
|
|
318
724
|
if (excludedCheckouts.has(`${manifest.projectId}\0${branchName}`)) continue;
|
|
319
|
-
mirrorVisibleProjectToShadow(input, manifest, branchName);
|
|
320
|
-
mirrorLocalPlansToShadow(input, manifest, branchName);
|
|
725
|
+
mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, branchName));
|
|
726
|
+
if (!input.trigger.canonicalCheckoutOnly) mirrorLocalPlansToShadow(input, manifest, branchName);
|
|
321
727
|
}
|
|
322
728
|
}
|
|
729
|
+
projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
|
|
730
|
+
return projection;
|
|
323
731
|
}
|
|
324
732
|
function reconcileNewVisibleCheckouts(input) {
|
|
733
|
+
const projection = { entries: [], opaqueRoots: [] };
|
|
325
734
|
for (const target of input.newVisibleCheckouts ?? []) {
|
|
326
735
|
const manifest = input.projects.find((project) => project.projectId === target.projectId);
|
|
327
736
|
if (!manifest || !manifest.branches.includes(target.branchName)) continue;
|
|
328
737
|
const projectRoot = workspaceProjectBranchRelativePath(target.projectId, target.branchName);
|
|
329
|
-
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)) {
|
|
330
741
|
mirrorShadowProjectToVisible(input, manifest, target.branchName);
|
|
331
742
|
} else {
|
|
332
|
-
mirrorVisibleProjectToShadow(input, manifest, target.branchName);
|
|
743
|
+
mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
|
|
333
744
|
}
|
|
745
|
+
if (input.trigger.canonicalCheckoutOnly) continue;
|
|
334
746
|
const plansRoot = workspacePlansRelativePath(target.projectId, target.branchName);
|
|
335
|
-
if (listShadowTrackedFiles(input, plansRoot).length > 0) {
|
|
747
|
+
if (listShadowTrackedFiles(input, plansRoot).length > 0 || restoreShadowRootFromRemote(input, plansRoot)) {
|
|
336
748
|
mirrorShadowPlansToLocal(input, manifest, target.branchName);
|
|
337
749
|
} else {
|
|
338
750
|
mirrorLocalPlansToShadow(input, manifest, target.branchName);
|
|
339
751
|
}
|
|
340
752
|
}
|
|
753
|
+
projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
|
|
754
|
+
return projection;
|
|
341
755
|
}
|
|
342
|
-
function mirrorShadowWorkspaceToVisible(input) {
|
|
756
|
+
function mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots = []) {
|
|
343
757
|
for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
|
|
344
758
|
for (const branchName of [...new Set(manifest.branches)].sort()) {
|
|
345
|
-
|
|
346
|
-
|
|
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}`);
|
|
347
840
|
}
|
|
348
841
|
}
|
|
349
842
|
}
|
|
@@ -376,14 +869,165 @@ function revParse(input, revision) {
|
|
|
376
869
|
const result = runGitResult(input, input.shadowRoot, ["rev-parse", "--verify", revision]);
|
|
377
870
|
return result.exitCode === 0 ? result.stdout : null;
|
|
378
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
|
+
}
|
|
379
930
|
function gitStatus(input) {
|
|
380
931
|
return runGit(input, input.shadowRoot, ["status", "--porcelain=v1", "--untracked-files=all"], "read workspace status");
|
|
381
932
|
}
|
|
382
|
-
function
|
|
383
|
-
|
|
933
|
+
function resetUncommittedShadowSnapshot(input) {
|
|
934
|
+
if (!gitStatus(input)) return;
|
|
935
|
+
if (tryGit(input, input.shadowRoot, ["rev-parse", "--verify", "HEAD"])) {
|
|
936
|
+
runGit(input, input.shadowRoot, ["reset", "--hard", "HEAD"], "reset interrupted workspace snapshot");
|
|
937
|
+
} else {
|
|
938
|
+
runGit(input, input.shadowRoot, ["read-tree", "--empty"], "reset interrupted unborn workspace snapshot");
|
|
939
|
+
}
|
|
940
|
+
runGit(input, input.shadowRoot, ["clean", "-fd"], "clean interrupted workspace snapshot");
|
|
941
|
+
}
|
|
942
|
+
function fastForwardCleanShadowForNewCheckouts(input) {
|
|
943
|
+
if (!input.newVisibleCheckouts?.length) return;
|
|
944
|
+
runGit(input, input.shadowRoot, ["add", "-A"], "stage existing workspace changes before checkout hydration");
|
|
945
|
+
if (gitStatus(input)) return;
|
|
946
|
+
const localHead = revParse(input, "HEAD");
|
|
947
|
+
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
948
|
+
if (!localHead || !remoteHead || localHead === remoteHead) return;
|
|
949
|
+
if (!tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", localHead, remoteHead])) return;
|
|
950
|
+
runGit(input, input.shadowRoot, ["reset", "--hard", remoteHead], "fast-forward before checkout hydration");
|
|
951
|
+
}
|
|
952
|
+
function restoreShadowRootFromRemote(input, relativeRoot) {
|
|
953
|
+
const remoteRevision = `origin/${WORKSPACE_BRANCH}`;
|
|
954
|
+
const remoteFiles = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", remoteRevision, "--", relativeRoot]);
|
|
955
|
+
if (remoteFiles.exitCode !== 0 || !remoteFiles.stdout) return false;
|
|
956
|
+
runGit(
|
|
957
|
+
input,
|
|
958
|
+
input.shadowRoot,
|
|
959
|
+
["restore", `--source=${remoteRevision}`, "--staged", "--worktree", "--", relativeRoot],
|
|
960
|
+
`hydrate ${relativeRoot} from canonical workspace`
|
|
961
|
+
);
|
|
962
|
+
return true;
|
|
963
|
+
}
|
|
964
|
+
function candidateBaseRevision(input) {
|
|
965
|
+
const localHead = revParse(input, "HEAD");
|
|
966
|
+
if (!localHead) return null;
|
|
967
|
+
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
968
|
+
if (!remoteHead) {
|
|
969
|
+
const emptyTree = Bun.spawnSync(gitArgs(input, ["mktree"]), {
|
|
970
|
+
cwd: input.shadowRoot,
|
|
971
|
+
stdin: Buffer.alloc(0),
|
|
972
|
+
stdout: "pipe",
|
|
973
|
+
stderr: "pipe",
|
|
974
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
975
|
+
});
|
|
976
|
+
if (emptyTree.exitCode !== 0) {
|
|
977
|
+
throw new Error(
|
|
978
|
+
`create empty workspace comparison tree: ${emptyTree.stderr.toString().trim() || `git exited ${emptyTree.exitCode}`}`
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
return emptyTree.stdout.toString().trim();
|
|
982
|
+
}
|
|
983
|
+
const mergeBase = runGitResult(input, input.shadowRoot, ["merge-base", localHead, remoteHead]);
|
|
984
|
+
return mergeBase.exitCode === 0 && mergeBase.stdout ? mergeBase.stdout : remoteHead;
|
|
384
985
|
}
|
|
385
|
-
|
|
386
|
-
|
|
986
|
+
function stagedPaths(input, baseRevision) {
|
|
987
|
+
return runGit(
|
|
988
|
+
input,
|
|
989
|
+
input.shadowRoot,
|
|
990
|
+
["diff", "--cached", "--name-only", "-z", ...baseRevision ? [baseRevision] : []],
|
|
991
|
+
"list workspace changes"
|
|
992
|
+
).split("\0").filter(Boolean).sort();
|
|
993
|
+
}
|
|
994
|
+
function stagedDeletedPaths(input, baseRevision) {
|
|
995
|
+
return runGit(
|
|
996
|
+
input,
|
|
997
|
+
input.shadowRoot,
|
|
998
|
+
["diff", "--cached", "--diff-filter=D", "--name-only", "-z", ...baseRevision ? [baseRevision] : []],
|
|
999
|
+
"list workspace deletions"
|
|
1000
|
+
).split("\0").filter(Boolean).sort();
|
|
1001
|
+
}
|
|
1002
|
+
function managedCheckoutRoot(filePath) {
|
|
1003
|
+
const projectMatch = /^projects\/([^/]+)\/branches\/([^/]+)\//.exec(filePath);
|
|
1004
|
+
return projectMatch ? `projects/${projectMatch[1]}/branches/${projectMatch[2]}` : null;
|
|
1005
|
+
}
|
|
1006
|
+
function revisionTrackedFileCount(input, revision, relativeRoot) {
|
|
1007
|
+
if (!revision) return 0;
|
|
1008
|
+
const result = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", revision, "--", relativeRoot]);
|
|
1009
|
+
if (result.exitCode !== 0) return 0;
|
|
1010
|
+
return result.stdout.split("\0").filter(Boolean).length;
|
|
1011
|
+
}
|
|
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;
|
|
1014
|
+
const roots = new Set(
|
|
1015
|
+
stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root) && root !== authoritativeRoot)
|
|
1016
|
+
);
|
|
1017
|
+
const destructive = [];
|
|
1018
|
+
for (const root of roots) {
|
|
1019
|
+
const trackedBefore = revisionTrackedFileCount(input, baseRevision, root);
|
|
1020
|
+
if (trackedBefore === 0) continue;
|
|
1021
|
+
const trackedAfter = listShadowTrackedFiles(input, root).length;
|
|
1022
|
+
const removedRatio = (trackedBefore - trackedAfter) / trackedBefore;
|
|
1023
|
+
if (trackedAfter === 0 || trackedBefore >= DESTRUCTIVE_CHECKOUT_MIN_TRACKED_FILES && removedRatio >= DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO) {
|
|
1024
|
+
destructive.push({ root, trackedBefore, trackedAfter });
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return destructive.sort((left, right) => left.root.localeCompare(right.root));
|
|
1028
|
+
}
|
|
1029
|
+
async function stagedDiffSizeBytes(input, baseRevision) {
|
|
1030
|
+
const subprocess = Bun.spawn(gitArgs(input, ["diff", "--cached", "--binary", "--no-ext-diff", ...baseRevision ? [baseRevision] : []]), {
|
|
387
1031
|
cwd: input.shadowRoot,
|
|
388
1032
|
stdout: "pipe",
|
|
389
1033
|
stderr: "pipe",
|
|
@@ -443,14 +1087,14 @@ function isNonFastForward(result) {
|
|
|
443
1087
|
${result.stderr}`.toLowerCase();
|
|
444
1088
|
return output.includes("non-fast-forward") || output.includes("fetch first") || output.includes("[rejected]");
|
|
445
1089
|
}
|
|
446
|
-
function resetShadowToRemote(input) {
|
|
1090
|
+
function resetShadowToRemote(input, opaqueWorkspaceRoots = []) {
|
|
447
1091
|
runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch canonical workspace before reset");
|
|
448
1092
|
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
449
1093
|
if (!remoteHead) return null;
|
|
450
1094
|
runGitResult(input, input.shadowRoot, ["rebase", "--abort"]);
|
|
451
1095
|
runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "reset workspace to canonical state");
|
|
452
1096
|
runGit(input, input.shadowRoot, ["clean", "-fd"], "clean reset workspace");
|
|
453
|
-
mirrorShadowWorkspaceToVisible(input);
|
|
1097
|
+
if (!input.trigger.canonicalCheckoutOnly) mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots);
|
|
454
1098
|
return remoteHead;
|
|
455
1099
|
}
|
|
456
1100
|
function baseResult(input, startingHead) {
|
|
@@ -470,12 +1114,17 @@ function baseResult(input, startingHead) {
|
|
|
470
1114
|
};
|
|
471
1115
|
}
|
|
472
1116
|
async function synchronizeWorkspace(rawInput) {
|
|
473
|
-
|
|
1117
|
+
let input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
|
|
474
1118
|
try {
|
|
1119
|
+
input = { ...input, projects: workspaceProjectsForSync(input.projects, input.trigger) };
|
|
1120
|
+
let mirroredGitlinkProjection = { entries: [], opaqueRoots: [] };
|
|
475
1121
|
ensureShadowWorkspace(input);
|
|
476
1122
|
const remoteHeadAtStart = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
477
1123
|
const result = baseResult(input, remoteHeadAtStart);
|
|
478
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
|
+
}
|
|
479
1128
|
const publishedHead = resetShadowToRemote(input);
|
|
480
1129
|
return {
|
|
481
1130
|
...result,
|
|
@@ -485,29 +1134,53 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
485
1134
|
gitStatus: gitStatus(input)
|
|
486
1135
|
};
|
|
487
1136
|
}
|
|
1137
|
+
if (!input.skipVisibleMirror) resetUncommittedShadowSnapshot(input);
|
|
1138
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
488
1139
|
if (!input.skipVisibleMirror) {
|
|
489
1140
|
const newCheckoutKeys = new Set((input.newVisibleCheckouts ?? []).map((target) => `${target.projectId}\0${target.branchName}`));
|
|
490
|
-
mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
|
|
491
|
-
|
|
1141
|
+
mirroredGitlinkProjection = mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
|
|
1142
|
+
fastForwardCleanShadowForNewCheckouts(input);
|
|
1143
|
+
mergeGitlinkProjection(mirroredGitlinkProjection, reconcileNewVisibleCheckouts(input));
|
|
1144
|
+
mirroredGitlinkProjection.opaqueRoots = [...new Set(mirroredGitlinkProjection.opaqueRoots)].sort();
|
|
492
1145
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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);
|
|
1150
|
+
const stagedWorkingPaths = stagedPaths(input);
|
|
1151
|
+
const baseRevision = candidateBaseRevision(input);
|
|
1152
|
+
const paths = stagedPaths(input, baseRevision);
|
|
1153
|
+
const diffSizeBytes = await stagedDiffSizeBytes(input, baseRevision);
|
|
496
1154
|
const statusBeforeCommit = reportedStatus(gitStatus(input));
|
|
497
1155
|
const observed = {
|
|
498
1156
|
...result,
|
|
499
1157
|
diffSizeBytes,
|
|
500
1158
|
gitStatus: statusBeforeCommit,
|
|
501
1159
|
affectedPaths: reportedPaths(paths),
|
|
502
|
-
affectedProjects: affectedProjects(paths)
|
|
1160
|
+
affectedProjects: affectedProjects(paths),
|
|
1161
|
+
...sampledCanonicalCheckoutTreeHash ? { sampledCanonicalCheckoutTreeHash } : {}
|
|
503
1162
|
};
|
|
504
|
-
|
|
505
|
-
|
|
1163
|
+
const destructiveReductions = destructiveCheckoutReductions(input, baseRevision);
|
|
1164
|
+
if (destructiveReductions.length > 0 && !input.confirmedLargeDiff) {
|
|
1165
|
+
const summary = destructiveReductions.map(({ root, trackedBefore, trackedAfter }) => `${root} (${trackedBefore} -> ${trackedAfter} tracked files)`).join(", ");
|
|
1166
|
+
return {
|
|
1167
|
+
...observed,
|
|
1168
|
+
outcome: "large_diff_blocked",
|
|
1169
|
+
error: `Workspace safety guard blocked a destructive checkout reduction: ${summary}`
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff && !input.trigger.canonicalCheckoutOnly) {
|
|
1173
|
+
return {
|
|
1174
|
+
...observed,
|
|
1175
|
+
outcome: "large_diff_blocked",
|
|
1176
|
+
error: `Workspace safety guard blocked a ${diffSizeBytes}-byte diff above the ${MAX_WORKSPACE_SYNC_DIFF_BYTES}-byte automatic publication limit.`
|
|
1177
|
+
};
|
|
506
1178
|
}
|
|
507
|
-
if (
|
|
1179
|
+
if (stagedWorkingPaths.length > 0) {
|
|
508
1180
|
runGit(input, input.shadowRoot, ["commit", "-m", commitMessage(input)], "commit workspace changes");
|
|
509
1181
|
}
|
|
510
1182
|
const candidateHead = revParse(input, "HEAD");
|
|
1183
|
+
if (candidateHead) assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, candidateHead);
|
|
511
1184
|
const hasRemoteMain = Boolean(revParse(input, `origin/${WORKSPACE_BRANCH}`));
|
|
512
1185
|
const hasUnpushedCommit = candidateHead ? !hasRemoteMain || !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", candidateHead, `origin/${WORKSPACE_BRANCH}`]) : false;
|
|
513
1186
|
if (!hasUnpushedCommit) {
|
|
@@ -516,7 +1189,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
516
1189
|
if (currentRemoteHead && localHead !== currentRemoteHead) {
|
|
517
1190
|
runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "update workspace from canonical state");
|
|
518
1191
|
runGit(input, input.shadowRoot, ["clean", "-fd"], "clean updated workspace");
|
|
519
|
-
|
|
1192
|
+
assertPublishedCanonicalCheckoutTree(input, currentRemoteHead, sampledCanonicalCheckoutTreeHash);
|
|
1193
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
520
1194
|
return {
|
|
521
1195
|
...observed,
|
|
522
1196
|
outcome: "updated",
|
|
@@ -525,7 +1199,13 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
525
1199
|
gitStatus: gitStatus(input)
|
|
526
1200
|
};
|
|
527
1201
|
}
|
|
528
|
-
|
|
1202
|
+
const authoritativeHead = currentRemoteHead ?? localHead;
|
|
1203
|
+
if (authoritativeHead) {
|
|
1204
|
+
assertPublishedCanonicalCheckoutTree(input, authoritativeHead, sampledCanonicalCheckoutTreeHash);
|
|
1205
|
+
}
|
|
1206
|
+
if (input.skipVisibleMirror) {
|
|
1207
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
1208
|
+
}
|
|
529
1209
|
return {
|
|
530
1210
|
...observed,
|
|
531
1211
|
outcome: "no_change",
|
|
@@ -539,7 +1219,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
539
1219
|
const push = runGitResult(input, input.shadowRoot, ["push", "origin", `HEAD:refs/heads/${WORKSPACE_BRANCH}`]);
|
|
540
1220
|
if (push.exitCode === 0) {
|
|
541
1221
|
const publishedHead = revParse(input, "HEAD") ?? candidateHead;
|
|
542
|
-
|
|
1222
|
+
if (publishedHead) assertPublishedCanonicalCheckoutTree(input, publishedHead, sampledCanonicalCheckoutTreeHash);
|
|
1223
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
543
1224
|
return {
|
|
544
1225
|
...observed,
|
|
545
1226
|
outcome: "published",
|
|
@@ -565,7 +1246,7 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
565
1246
|
if (rebase.exitCode !== 0) {
|
|
566
1247
|
const conflicted = runGitResult(input, input.shadowRoot, ["diff", "--name-only", "--diff-filter=U", "-z"]);
|
|
567
1248
|
const discardedPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
|
|
568
|
-
const publishedHead = resetShadowToRemote(input);
|
|
1249
|
+
const publishedHead = resetShadowToRemote(input, mirroredGitlinkProjection.opaqueRoots);
|
|
569
1250
|
return {
|
|
570
1251
|
...observed,
|
|
571
1252
|
outcome: "conflict_reset",
|
|
@@ -577,6 +1258,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
577
1258
|
gitStatus: gitStatus(input)
|
|
578
1259
|
};
|
|
579
1260
|
}
|
|
1261
|
+
assertPublishedCanonicalCheckoutTree(input, "HEAD", sampledCanonicalCheckoutTreeHash);
|
|
1262
|
+
assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, "HEAD");
|
|
580
1263
|
}
|
|
581
1264
|
return {
|
|
582
1265
|
...observed,
|
|
@@ -595,10 +1278,27 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
595
1278
|
};
|
|
596
1279
|
}
|
|
597
1280
|
}
|
|
598
|
-
function calculateWorkspaceDiffFingerprint(
|
|
1281
|
+
function calculateWorkspaceDiffFingerprint(rawInput) {
|
|
1282
|
+
const input = {
|
|
1283
|
+
...rawInput,
|
|
1284
|
+
projects: workspaceProjectsForSync(rawInput.projects, rawInput.trigger)
|
|
1285
|
+
};
|
|
599
1286
|
ensureShadowWorkspace(input);
|
|
600
|
-
|
|
601
|
-
|
|
1287
|
+
let gitlinkProjection = { entries: [], opaqueRoots: [] };
|
|
1288
|
+
if (!input.skipVisibleMirror) {
|
|
1289
|
+
resetUncommittedShadowSnapshot(input);
|
|
1290
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
1291
|
+
gitlinkProjection = mirrorVisibleWorkspaceToShadow(input);
|
|
1292
|
+
} else {
|
|
1293
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
1294
|
+
}
|
|
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);
|
|
602
1302
|
const stagedTree = runGit(input, input.shadowRoot, ["write-tree"], "write workspace fingerprint tree");
|
|
603
1303
|
const headTree = revParse(input, "HEAD^{tree}");
|
|
604
1304
|
const localHead = revParse(input, "HEAD");
|
|
@@ -610,27 +1310,25 @@ function calculateWorkspaceDiffFingerprint(input) {
|
|
|
610
1310
|
}
|
|
611
1311
|
class WorkspaceSyncSingleFlight {
|
|
612
1312
|
queue = Promise.resolve();
|
|
613
|
-
|
|
614
|
-
const queued = this.queue.then(
|
|
615
|
-
() => synchronizeWorkspace(input),
|
|
616
|
-
() => synchronizeWorkspace(input)
|
|
617
|
-
);
|
|
1313
|
+
enqueue(task) {
|
|
1314
|
+
const queued = this.queue.then(task, task);
|
|
618
1315
|
this.queue = queued.then(
|
|
619
1316
|
() => void 0,
|
|
620
1317
|
() => void 0
|
|
621
1318
|
);
|
|
622
1319
|
return queued;
|
|
623
1320
|
}
|
|
1321
|
+
run(input) {
|
|
1322
|
+
return this.enqueue(() => synchronizeWorkspace(input));
|
|
1323
|
+
}
|
|
1324
|
+
runPrepared(prepare) {
|
|
1325
|
+
return this.enqueue(() => synchronizeWorkspace(prepare()));
|
|
1326
|
+
}
|
|
624
1327
|
fingerprint(input) {
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
);
|
|
629
|
-
this.queue = queued.then(
|
|
630
|
-
() => void 0,
|
|
631
|
-
() => void 0
|
|
632
|
-
);
|
|
633
|
-
return queued;
|
|
1328
|
+
return this.enqueue(() => calculateWorkspaceDiffFingerprint(input));
|
|
1329
|
+
}
|
|
1330
|
+
fingerprintPrepared(prepare) {
|
|
1331
|
+
return this.enqueue(() => calculateWorkspaceDiffFingerprint(prepare()));
|
|
634
1332
|
}
|
|
635
1333
|
afterCurrent() {
|
|
636
1334
|
return this.queue;
|
|
@@ -642,6 +1340,7 @@ class WorkspaceSyncSingleFlight {
|
|
|
642
1340
|
WORKSPACE_BRANCH,
|
|
643
1341
|
WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
|
|
644
1342
|
WorkspaceSyncSingleFlight,
|
|
1343
|
+
assertCanonicalCheckoutIndexMatchesWorktree,
|
|
645
1344
|
calculateWorkspaceDiffFingerprint,
|
|
646
1345
|
encodeWorkspaceBranch,
|
|
647
1346
|
mirrorShadowWorkspaceToVisible,
|
|
@@ -649,5 +1348,6 @@ class WorkspaceSyncSingleFlight {
|
|
|
649
1348
|
synchronizeWorkspace,
|
|
650
1349
|
visibleProjectBranchPath,
|
|
651
1350
|
workspacePlansRelativePath,
|
|
652
|
-
workspaceProjectBranchRelativePath
|
|
1351
|
+
workspaceProjectBranchRelativePath,
|
|
1352
|
+
workspaceProjectsForSync
|
|
653
1353
|
});
|