@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
|
@@ -9,12 +9,19 @@ const MAX_REPORTED_WORKSPACE_PATHS = 2e3;
|
|
|
9
9
|
const MAX_REPORTED_WORKSPACE_STATUS_BYTES = 256 * 1024;
|
|
10
10
|
const MAX_MIRROR_COMPARISON_CACHE_ENTRIES = 2e5;
|
|
11
11
|
const MIRROR_COMPARE_BUFFER_BYTES = 64 * 1024;
|
|
12
|
+
const DESTRUCTIVE_CHECKOUT_MIN_TRACKED_FILES = 20;
|
|
13
|
+
const DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO = 0.8;
|
|
14
|
+
const ACTIVE_GIT_LOCK_PATHS = ["index.lock", "HEAD.lock", "packed-refs.lock", "shallow.lock"];
|
|
12
15
|
const mirrorComparisonCache = /* @__PURE__ */ new Map();
|
|
13
16
|
function gitArgs(input, args) {
|
|
14
|
-
|
|
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;
|
|
15
22
|
}
|
|
16
23
|
function runGitResult(input, cwd, args) {
|
|
17
|
-
const result = Bun.spawnSync(gitArgs(input, args), {
|
|
24
|
+
const result = Bun.spawnSync(gitArgs(input, withoutSubmoduleRecursion(args)), {
|
|
18
25
|
cwd,
|
|
19
26
|
stdout: "pipe",
|
|
20
27
|
stderr: "pipe",
|
|
@@ -42,6 +49,35 @@ function tryGit(input, cwd, args) {
|
|
|
42
49
|
function encodeWorkspaceBranch(branchName) {
|
|
43
50
|
return encodeURIComponent(branchName);
|
|
44
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
|
+
}
|
|
45
81
|
function workspaceProjectBranchRelativePath(projectId, branchName) {
|
|
46
82
|
return path.posix.join("projects", projectId, "branches", encodeWorkspaceBranch(branchName));
|
|
47
83
|
}
|
|
@@ -66,12 +102,30 @@ function listGitEligibleFiles(checkoutPath) {
|
|
|
66
102
|
if (!fs.existsSync(path.join(checkoutPath, ".git"))) {
|
|
67
103
|
return [];
|
|
68
104
|
}
|
|
69
|
-
const result = Bun.spawnSync(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
);
|
|
75
129
|
if (result.exitCode !== 0) {
|
|
76
130
|
throw new Error(`inspect eligible project files: ${result.stderr.toString().trim() || `git exited ${result.exitCode}`}`);
|
|
77
131
|
}
|
|
@@ -84,13 +138,195 @@ function listGitEligibleFiles(checkoutPath) {
|
|
|
84
138
|
}
|
|
85
139
|
}).sort();
|
|
86
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
|
+
}
|
|
264
|
+
function checkoutGitSnapshot(checkoutPath) {
|
|
265
|
+
const gitPaths = Bun.spawnSync(
|
|
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
|
+
],
|
|
279
|
+
{
|
|
280
|
+
cwd: checkoutPath,
|
|
281
|
+
stdout: "pipe",
|
|
282
|
+
stderr: "ignore",
|
|
283
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
if (gitPaths.exitCode !== 0) return null;
|
|
287
|
+
const [indexPath, ...lockPaths] = gitPaths.stdout.toString().trim().split(/\r?\n/).map((gitPath) => path.resolve(checkoutPath, gitPath));
|
|
288
|
+
if (!indexPath || lockPaths.length !== ACTIVE_GIT_LOCK_PATHS.length || lockPaths.some((lockPath) => fs.existsSync(lockPath))) {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
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
|
+
);
|
|
311
|
+
let indexSignature = "missing";
|
|
312
|
+
try {
|
|
313
|
+
const stat = fs.statSync(indexPath);
|
|
314
|
+
indexSignature = entrySignature(stat);
|
|
315
|
+
} catch {
|
|
316
|
+
}
|
|
317
|
+
if (lockPaths.some((lockPath) => fs.existsSync(lockPath))) return null;
|
|
318
|
+
return `${head.exitCode === 0 ? head.stdout.toString().trim() : "unborn"}\0${indexSignature}`;
|
|
319
|
+
}
|
|
87
320
|
function listShadowTrackedFiles(input, relativeRoot) {
|
|
88
321
|
const normalizedRoot = relativeRoot.split(path.sep).join("/").replace(/^\/+|\/+$/g, "");
|
|
89
322
|
const output = runGit(input, input.shadowRoot, ["ls-files", "-z", "--cached", "--", normalizedRoot], "list canonical workspace files");
|
|
90
323
|
const prefix = `${normalizedRoot}/`;
|
|
91
324
|
return output.split("\0").filter((entry) => entry.startsWith(prefix)).map((entry) => entry.slice(prefix.length)).filter(Boolean).sort();
|
|
92
325
|
}
|
|
93
|
-
function
|
|
326
|
+
function pathIsWithinOpaqueRoot(relativePath, opaqueRoots) {
|
|
327
|
+
return opaqueRoots.some((root) => relativePath === root || relativePath.startsWith(`${root}/`));
|
|
328
|
+
}
|
|
329
|
+
function listFilesRecursively(root, filter, opaqueRoots = []) {
|
|
94
330
|
if (!fs.existsSync(root)) return [];
|
|
95
331
|
const files = [];
|
|
96
332
|
const visit = (current, relativeDir) => {
|
|
@@ -98,6 +334,7 @@ function listFilesRecursively(root, filter) {
|
|
|
98
334
|
if (entry.name === ".git") continue;
|
|
99
335
|
const relativePath = relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name;
|
|
100
336
|
const absolutePath = path.join(current, entry.name);
|
|
337
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueRoots)) continue;
|
|
101
338
|
if (entry.isDirectory()) {
|
|
102
339
|
visit(absolutePath, relativePath);
|
|
103
340
|
} else if (!filter || filter(relativePath)) {
|
|
@@ -108,14 +345,19 @@ function listFilesRecursively(root, filter) {
|
|
|
108
345
|
visit(root, "");
|
|
109
346
|
return files.sort();
|
|
110
347
|
}
|
|
111
|
-
function removeEmptyDirectories(root) {
|
|
348
|
+
function removeEmptyDirectories(root, opaqueRoots = []) {
|
|
112
349
|
if (!fs.existsSync(root)) return;
|
|
113
|
-
const visit = (current) => {
|
|
350
|
+
const visit = (current, relativeDir) => {
|
|
114
351
|
let empty = true;
|
|
115
352
|
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
|
116
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
|
+
}
|
|
117
359
|
if (entry.isDirectory()) {
|
|
118
|
-
if (!visit(absolutePath)) empty = false;
|
|
360
|
+
if (!visit(absolutePath, relativePath)) empty = false;
|
|
119
361
|
} else {
|
|
120
362
|
empty = false;
|
|
121
363
|
}
|
|
@@ -123,7 +365,7 @@ function removeEmptyDirectories(root) {
|
|
|
123
365
|
if (empty && current !== root) fs.rmdirSync(current);
|
|
124
366
|
return empty;
|
|
125
367
|
};
|
|
126
|
-
visit(root);
|
|
368
|
+
visit(root, "");
|
|
127
369
|
}
|
|
128
370
|
function copyEntry(sourceRoot, targetRoot, relativePath) {
|
|
129
371
|
const sourcePath = path.resolve(sourceRoot, ...relativePath.split("/"));
|
|
@@ -193,39 +435,218 @@ function entriesEqual(sourcePath, targetPath) {
|
|
|
193
435
|
}
|
|
194
436
|
return equal;
|
|
195
437
|
}
|
|
196
|
-
function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles) {
|
|
438
|
+
function mirrorFileSet(sourceRoot, targetRoot, sourceFiles, targetFiles, opaqueTargetRoots = [], opaqueSourceRoots = []) {
|
|
197
439
|
fs.mkdirSync(targetRoot, { recursive: true });
|
|
198
440
|
const sourceSet = new Set(sourceFiles);
|
|
199
441
|
for (const relativePath of targetFiles) {
|
|
442
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
|
|
200
443
|
if (sourceSet.has(relativePath)) continue;
|
|
201
444
|
const targetPath = path.resolve(targetRoot, ...relativePath.split("/"));
|
|
202
445
|
assertInside(targetRoot, targetPath, "Workspace deletion path");
|
|
203
446
|
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
204
447
|
}
|
|
205
448
|
for (const relativePath of sourceFiles) {
|
|
449
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueSourceRoots)) continue;
|
|
450
|
+
if (pathIsWithinOpaqueRoot(relativePath, opaqueTargetRoots)) continue;
|
|
206
451
|
const sourcePath = path.resolve(sourceRoot, ...relativePath.split("/"));
|
|
207
|
-
|
|
208
|
-
|
|
452
|
+
const stat = lstatOrNull(sourcePath);
|
|
453
|
+
if (!stat) continue;
|
|
209
454
|
if (!stat.isFile() && !stat.isSymbolicLink()) continue;
|
|
210
455
|
const targetPath = path.resolve(targetRoot, ...relativePath.split("/"));
|
|
211
456
|
if (entriesEqual(sourcePath, targetPath)) continue;
|
|
212
457
|
copyEntry(sourceRoot, targetRoot, relativePath);
|
|
213
458
|
}
|
|
214
|
-
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
|
+
}
|
|
573
|
+
}
|
|
574
|
+
function restoreShadowProjectSnapshot(input, relativeRoot) {
|
|
575
|
+
const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
576
|
+
const headFiles = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", "HEAD", "--", relativeRoot]);
|
|
577
|
+
if (headFiles.exitCode === 0 && headFiles.stdout) {
|
|
578
|
+
runGit(
|
|
579
|
+
input,
|
|
580
|
+
input.shadowRoot,
|
|
581
|
+
["restore", "--source=HEAD", "--staged", "--worktree", "--", relativeRoot],
|
|
582
|
+
"restore deferred checkout scan"
|
|
583
|
+
);
|
|
584
|
+
runGit(input, input.shadowRoot, ["clean", "-fd", "--", relativeRoot], "clean deferred checkout scan");
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
runGitResult(input, input.shadowRoot, ["--literal-pathspecs", "rm", "-r", "-f", "--cached", "--ignore-unmatch", "--", relativeRoot]);
|
|
588
|
+
fs.rmSync(shadowRoot, { recursive: true, force: true });
|
|
215
589
|
}
|
|
216
590
|
function mirrorVisibleProjectToShadow(input, manifest, branchName) {
|
|
217
591
|
const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
|
|
218
|
-
if (!fs.existsSync(path.join(visibleRoot, ".git"))) return;
|
|
219
|
-
const
|
|
220
|
-
|
|
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
|
+
}
|
|
597
|
+
const beforeSnapshot = checkoutGitSnapshot(visibleRoot);
|
|
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);
|
|
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));
|
|
611
|
+
const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
612
|
+
mirrorVisibleGitlinksToShadow(input, relativeRoot, visibleGitlinks);
|
|
613
|
+
mirrorFileSet(visibleRoot, shadowRoot, visibleFiles, listFilesRecursively(shadowRoot), visibleGitlinkRoots, visibleGitlinkRoots);
|
|
614
|
+
if (checkoutGitSnapshot(visibleRoot) !== beforeSnapshot) {
|
|
615
|
+
restoreShadowProjectSnapshot(input, relativeRoot);
|
|
616
|
+
return { entries: [], opaqueRoots: [] };
|
|
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
|
+
};
|
|
221
625
|
}
|
|
222
|
-
function mirrorShadowProjectToVisible(input, manifest, branchName) {
|
|
626
|
+
function mirrorShadowProjectToVisible(input, manifest, branchName, additionalOpaqueRoots = []) {
|
|
223
627
|
const visibleRoot = visibleProjectBranchPath(input.projectsRoot, manifest, branchName);
|
|
224
628
|
if (!fs.existsSync(path.join(visibleRoot, ".git"))) return;
|
|
629
|
+
checkoutGitlinks(visibleRoot);
|
|
225
630
|
const relativeRoot = workspaceProjectBranchRelativePath(manifest.projectId, branchName);
|
|
226
631
|
const shadowRoot = path.join(input.shadowRoot, ...relativeRoot.split("/"));
|
|
227
632
|
const shadowFiles = listShadowTrackedFiles(input, relativeRoot);
|
|
228
|
-
|
|
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);
|
|
229
650
|
}
|
|
230
651
|
function mirrorLocalPlansToShadow(input, manifest, branchName) {
|
|
231
652
|
const sourceRoot = localPlansBranchPath(input.plansRoot, manifest.projectId, branchName);
|
|
@@ -246,60 +667,130 @@ function mirrorShadowPlansToLocal(input, manifest, branchName) {
|
|
|
246
667
|
listFilesRecursively(targetRoot, planFilter)
|
|
247
668
|
);
|
|
248
669
|
}
|
|
249
|
-
function
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
if (allowedNames.has(entry)) continue;
|
|
253
|
-
const target = path.resolve(root, entry);
|
|
254
|
-
assertInside(root, target, "Workspace manifest prune path");
|
|
255
|
-
fs.rmSync(target, { recursive: true, force: true });
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
function pruneShadowToManifest(input) {
|
|
259
|
-
const projectIds = new Set(input.projects.map((project) => project.projectId));
|
|
260
|
-
const projectsRoot = path.join(input.shadowRoot, "projects");
|
|
261
|
-
const plansRoot = path.join(input.shadowRoot, "plans");
|
|
262
|
-
pruneDirectoryChildren(projectsRoot, projectIds);
|
|
263
|
-
pruneDirectoryChildren(plansRoot, projectIds);
|
|
264
|
-
for (const manifest of input.projects) {
|
|
265
|
-
const encodedBranches = new Set(manifest.branches.map(encodeWorkspaceBranch));
|
|
266
|
-
pruneDirectoryChildren(path.join(projectsRoot, manifest.projectId, "branches"), encodedBranches);
|
|
267
|
-
pruneDirectoryChildren(path.join(plansRoot, manifest.projectId), encodedBranches);
|
|
268
|
-
}
|
|
670
|
+
function mergeGitlinkProjection(target, source) {
|
|
671
|
+
target.entries.push(...source.entries);
|
|
672
|
+
target.opaqueRoots.push(...source.opaqueRoots);
|
|
269
673
|
}
|
|
270
674
|
function mirrorVisibleWorkspaceToShadow(input, excludedCheckouts = /* @__PURE__ */ new Set()) {
|
|
271
|
-
|
|
675
|
+
const projection = { entries: [], opaqueRoots: [] };
|
|
272
676
|
for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
|
|
273
677
|
for (const branchName of [...new Set(manifest.branches)].sort()) {
|
|
274
678
|
if (excludedCheckouts.has(`${manifest.projectId}\0${branchName}`)) continue;
|
|
275
|
-
mirrorVisibleProjectToShadow(input, manifest, branchName);
|
|
276
|
-
mirrorLocalPlansToShadow(input, manifest, branchName);
|
|
679
|
+
mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, branchName));
|
|
680
|
+
if (!input.trigger.canonicalCheckoutOnly) mirrorLocalPlansToShadow(input, manifest, branchName);
|
|
277
681
|
}
|
|
278
682
|
}
|
|
683
|
+
projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
|
|
684
|
+
return projection;
|
|
279
685
|
}
|
|
280
686
|
function reconcileNewVisibleCheckouts(input) {
|
|
687
|
+
const projection = { entries: [], opaqueRoots: [] };
|
|
281
688
|
for (const target of input.newVisibleCheckouts ?? []) {
|
|
282
689
|
const manifest = input.projects.find((project) => project.projectId === target.projectId);
|
|
283
690
|
if (!manifest || !manifest.branches.includes(target.branchName)) continue;
|
|
284
691
|
const projectRoot = workspaceProjectBranchRelativePath(target.projectId, target.branchName);
|
|
285
|
-
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)) {
|
|
286
695
|
mirrorShadowProjectToVisible(input, manifest, target.branchName);
|
|
287
696
|
} else {
|
|
288
|
-
mirrorVisibleProjectToShadow(input, manifest, target.branchName);
|
|
697
|
+
mergeGitlinkProjection(projection, mirrorVisibleProjectToShadow(input, manifest, target.branchName));
|
|
289
698
|
}
|
|
699
|
+
if (input.trigger.canonicalCheckoutOnly) continue;
|
|
290
700
|
const plansRoot = workspacePlansRelativePath(target.projectId, target.branchName);
|
|
291
|
-
if (listShadowTrackedFiles(input, plansRoot).length > 0) {
|
|
701
|
+
if (listShadowTrackedFiles(input, plansRoot).length > 0 || restoreShadowRootFromRemote(input, plansRoot)) {
|
|
292
702
|
mirrorShadowPlansToLocal(input, manifest, target.branchName);
|
|
293
703
|
} else {
|
|
294
704
|
mirrorLocalPlansToShadow(input, manifest, target.branchName);
|
|
295
705
|
}
|
|
296
706
|
}
|
|
707
|
+
projection.opaqueRoots = [...new Set(projection.opaqueRoots)].sort();
|
|
708
|
+
return projection;
|
|
297
709
|
}
|
|
298
|
-
function mirrorShadowWorkspaceToVisible(input) {
|
|
710
|
+
function mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots = []) {
|
|
299
711
|
for (const manifest of [...input.projects].sort((left, right) => left.projectId.localeCompare(right.projectId))) {
|
|
300
712
|
for (const branchName of [...new Set(manifest.branches)].sort()) {
|
|
301
|
-
|
|
302
|
-
|
|
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}`);
|
|
303
794
|
}
|
|
304
795
|
}
|
|
305
796
|
}
|
|
@@ -332,14 +823,165 @@ function revParse(input, revision) {
|
|
|
332
823
|
const result = runGitResult(input, input.shadowRoot, ["rev-parse", "--verify", revision]);
|
|
333
824
|
return result.exitCode === 0 ? result.stdout : null;
|
|
334
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
|
+
}
|
|
335
884
|
function gitStatus(input) {
|
|
336
885
|
return runGit(input, input.shadowRoot, ["status", "--porcelain=v1", "--untracked-files=all"], "read workspace status");
|
|
337
886
|
}
|
|
338
|
-
function
|
|
339
|
-
|
|
887
|
+
function resetUncommittedShadowSnapshot(input) {
|
|
888
|
+
if (!gitStatus(input)) return;
|
|
889
|
+
if (tryGit(input, input.shadowRoot, ["rev-parse", "--verify", "HEAD"])) {
|
|
890
|
+
runGit(input, input.shadowRoot, ["reset", "--hard", "HEAD"], "reset interrupted workspace snapshot");
|
|
891
|
+
} else {
|
|
892
|
+
runGit(input, input.shadowRoot, ["read-tree", "--empty"], "reset interrupted unborn workspace snapshot");
|
|
893
|
+
}
|
|
894
|
+
runGit(input, input.shadowRoot, ["clean", "-fd"], "clean interrupted workspace snapshot");
|
|
895
|
+
}
|
|
896
|
+
function fastForwardCleanShadowForNewCheckouts(input) {
|
|
897
|
+
if (!input.newVisibleCheckouts?.length) return;
|
|
898
|
+
runGit(input, input.shadowRoot, ["add", "-A"], "stage existing workspace changes before checkout hydration");
|
|
899
|
+
if (gitStatus(input)) return;
|
|
900
|
+
const localHead = revParse(input, "HEAD");
|
|
901
|
+
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
902
|
+
if (!localHead || !remoteHead || localHead === remoteHead) return;
|
|
903
|
+
if (!tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", localHead, remoteHead])) return;
|
|
904
|
+
runGit(input, input.shadowRoot, ["reset", "--hard", remoteHead], "fast-forward before checkout hydration");
|
|
905
|
+
}
|
|
906
|
+
function restoreShadowRootFromRemote(input, relativeRoot) {
|
|
907
|
+
const remoteRevision = `origin/${WORKSPACE_BRANCH}`;
|
|
908
|
+
const remoteFiles = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", remoteRevision, "--", relativeRoot]);
|
|
909
|
+
if (remoteFiles.exitCode !== 0 || !remoteFiles.stdout) return false;
|
|
910
|
+
runGit(
|
|
911
|
+
input,
|
|
912
|
+
input.shadowRoot,
|
|
913
|
+
["restore", `--source=${remoteRevision}`, "--staged", "--worktree", "--", relativeRoot],
|
|
914
|
+
`hydrate ${relativeRoot} from canonical workspace`
|
|
915
|
+
);
|
|
916
|
+
return true;
|
|
917
|
+
}
|
|
918
|
+
function candidateBaseRevision(input) {
|
|
919
|
+
const localHead = revParse(input, "HEAD");
|
|
920
|
+
if (!localHead) return null;
|
|
921
|
+
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
922
|
+
if (!remoteHead) {
|
|
923
|
+
const emptyTree = Bun.spawnSync(gitArgs(input, ["mktree"]), {
|
|
924
|
+
cwd: input.shadowRoot,
|
|
925
|
+
stdin: Buffer.alloc(0),
|
|
926
|
+
stdout: "pipe",
|
|
927
|
+
stderr: "pipe",
|
|
928
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" }
|
|
929
|
+
});
|
|
930
|
+
if (emptyTree.exitCode !== 0) {
|
|
931
|
+
throw new Error(
|
|
932
|
+
`create empty workspace comparison tree: ${emptyTree.stderr.toString().trim() || `git exited ${emptyTree.exitCode}`}`
|
|
933
|
+
);
|
|
934
|
+
}
|
|
935
|
+
return emptyTree.stdout.toString().trim();
|
|
936
|
+
}
|
|
937
|
+
const mergeBase = runGitResult(input, input.shadowRoot, ["merge-base", localHead, remoteHead]);
|
|
938
|
+
return mergeBase.exitCode === 0 && mergeBase.stdout ? mergeBase.stdout : remoteHead;
|
|
340
939
|
}
|
|
341
|
-
|
|
342
|
-
|
|
940
|
+
function stagedPaths(input, baseRevision) {
|
|
941
|
+
return runGit(
|
|
942
|
+
input,
|
|
943
|
+
input.shadowRoot,
|
|
944
|
+
["diff", "--cached", "--name-only", "-z", ...baseRevision ? [baseRevision] : []],
|
|
945
|
+
"list workspace changes"
|
|
946
|
+
).split("\0").filter(Boolean).sort();
|
|
947
|
+
}
|
|
948
|
+
function stagedDeletedPaths(input, baseRevision) {
|
|
949
|
+
return runGit(
|
|
950
|
+
input,
|
|
951
|
+
input.shadowRoot,
|
|
952
|
+
["diff", "--cached", "--diff-filter=D", "--name-only", "-z", ...baseRevision ? [baseRevision] : []],
|
|
953
|
+
"list workspace deletions"
|
|
954
|
+
).split("\0").filter(Boolean).sort();
|
|
955
|
+
}
|
|
956
|
+
function managedCheckoutRoot(filePath) {
|
|
957
|
+
const projectMatch = /^projects\/([^/]+)\/branches\/([^/]+)\//.exec(filePath);
|
|
958
|
+
return projectMatch ? `projects/${projectMatch[1]}/branches/${projectMatch[2]}` : null;
|
|
959
|
+
}
|
|
960
|
+
function revisionTrackedFileCount(input, revision, relativeRoot) {
|
|
961
|
+
if (!revision) return 0;
|
|
962
|
+
const result = runGitResult(input, input.shadowRoot, ["ls-tree", "-r", "--name-only", "-z", revision, "--", relativeRoot]);
|
|
963
|
+
if (result.exitCode !== 0) return 0;
|
|
964
|
+
return result.stdout.split("\0").filter(Boolean).length;
|
|
965
|
+
}
|
|
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;
|
|
968
|
+
const roots = new Set(
|
|
969
|
+
stagedDeletedPaths(input, baseRevision).map(managedCheckoutRoot).filter((root) => Boolean(root) && root !== authoritativeRoot)
|
|
970
|
+
);
|
|
971
|
+
const destructive = [];
|
|
972
|
+
for (const root of roots) {
|
|
973
|
+
const trackedBefore = revisionTrackedFileCount(input, baseRevision, root);
|
|
974
|
+
if (trackedBefore === 0) continue;
|
|
975
|
+
const trackedAfter = listShadowTrackedFiles(input, root).length;
|
|
976
|
+
const removedRatio = (trackedBefore - trackedAfter) / trackedBefore;
|
|
977
|
+
if (trackedAfter === 0 || trackedBefore >= DESTRUCTIVE_CHECKOUT_MIN_TRACKED_FILES && removedRatio >= DESTRUCTIVE_CHECKOUT_REMOVAL_RATIO) {
|
|
978
|
+
destructive.push({ root, trackedBefore, trackedAfter });
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
return destructive.sort((left, right) => left.root.localeCompare(right.root));
|
|
982
|
+
}
|
|
983
|
+
async function stagedDiffSizeBytes(input, baseRevision) {
|
|
984
|
+
const subprocess = Bun.spawn(gitArgs(input, ["diff", "--cached", "--binary", "--no-ext-diff", ...baseRevision ? [baseRevision] : []]), {
|
|
343
985
|
cwd: input.shadowRoot,
|
|
344
986
|
stdout: "pipe",
|
|
345
987
|
stderr: "pipe",
|
|
@@ -399,14 +1041,14 @@ function isNonFastForward(result) {
|
|
|
399
1041
|
${result.stderr}`.toLowerCase();
|
|
400
1042
|
return output.includes("non-fast-forward") || output.includes("fetch first") || output.includes("[rejected]");
|
|
401
1043
|
}
|
|
402
|
-
function resetShadowToRemote(input) {
|
|
1044
|
+
function resetShadowToRemote(input, opaqueWorkspaceRoots = []) {
|
|
403
1045
|
runGit(input, input.shadowRoot, ["fetch", "origin", "--prune"], "fetch canonical workspace before reset");
|
|
404
1046
|
const remoteHead = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
405
1047
|
if (!remoteHead) return null;
|
|
406
1048
|
runGitResult(input, input.shadowRoot, ["rebase", "--abort"]);
|
|
407
1049
|
runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "reset workspace to canonical state");
|
|
408
1050
|
runGit(input, input.shadowRoot, ["clean", "-fd"], "clean reset workspace");
|
|
409
|
-
mirrorShadowWorkspaceToVisible(input);
|
|
1051
|
+
if (!input.trigger.canonicalCheckoutOnly) mirrorShadowWorkspaceToVisible(input, opaqueWorkspaceRoots);
|
|
410
1052
|
return remoteHead;
|
|
411
1053
|
}
|
|
412
1054
|
function baseResult(input, startingHead) {
|
|
@@ -426,12 +1068,17 @@ function baseResult(input, startingHead) {
|
|
|
426
1068
|
};
|
|
427
1069
|
}
|
|
428
1070
|
async function synchronizeWorkspace(rawInput) {
|
|
429
|
-
|
|
1071
|
+
let input = { ...rawInput, attemptId: rawInput.attemptId ?? crypto.randomUUID() };
|
|
430
1072
|
try {
|
|
1073
|
+
input = { ...input, projects: workspaceProjectsForSync(input.projects, input.trigger) };
|
|
1074
|
+
let mirroredGitlinkProjection = { entries: [], opaqueRoots: [] };
|
|
431
1075
|
ensureShadowWorkspace(input);
|
|
432
1076
|
const remoteHeadAtStart = revParse(input, `origin/${WORKSPACE_BRANCH}`);
|
|
433
1077
|
const result = baseResult(input, remoteHeadAtStart);
|
|
434
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
|
+
}
|
|
435
1082
|
const publishedHead = resetShadowToRemote(input);
|
|
436
1083
|
return {
|
|
437
1084
|
...result,
|
|
@@ -441,29 +1088,53 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
441
1088
|
gitStatus: gitStatus(input)
|
|
442
1089
|
};
|
|
443
1090
|
}
|
|
1091
|
+
if (!input.skipVisibleMirror) resetUncommittedShadowSnapshot(input);
|
|
1092
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
444
1093
|
if (!input.skipVisibleMirror) {
|
|
445
1094
|
const newCheckoutKeys = new Set((input.newVisibleCheckouts ?? []).map((target) => `${target.projectId}\0${target.branchName}`));
|
|
446
|
-
mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
|
|
447
|
-
|
|
1095
|
+
mirroredGitlinkProjection = mirrorVisibleWorkspaceToShadow(input, newCheckoutKeys);
|
|
1096
|
+
fastForwardCleanShadowForNewCheckouts(input);
|
|
1097
|
+
mergeGitlinkProjection(mirroredGitlinkProjection, reconcileNewVisibleCheckouts(input));
|
|
1098
|
+
mirroredGitlinkProjection.opaqueRoots = [...new Set(mirroredGitlinkProjection.opaqueRoots)].sort();
|
|
448
1099
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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);
|
|
1104
|
+
const stagedWorkingPaths = stagedPaths(input);
|
|
1105
|
+
const baseRevision = candidateBaseRevision(input);
|
|
1106
|
+
const paths = stagedPaths(input, baseRevision);
|
|
1107
|
+
const diffSizeBytes = await stagedDiffSizeBytes(input, baseRevision);
|
|
452
1108
|
const statusBeforeCommit = reportedStatus(gitStatus(input));
|
|
453
1109
|
const observed = {
|
|
454
1110
|
...result,
|
|
455
1111
|
diffSizeBytes,
|
|
456
1112
|
gitStatus: statusBeforeCommit,
|
|
457
1113
|
affectedPaths: reportedPaths(paths),
|
|
458
|
-
affectedProjects: affectedProjects(paths)
|
|
1114
|
+
affectedProjects: affectedProjects(paths),
|
|
1115
|
+
...sampledCanonicalCheckoutTreeHash ? { sampledCanonicalCheckoutTreeHash } : {}
|
|
459
1116
|
};
|
|
460
|
-
|
|
461
|
-
|
|
1117
|
+
const destructiveReductions = destructiveCheckoutReductions(input, baseRevision);
|
|
1118
|
+
if (destructiveReductions.length > 0 && !input.confirmedLargeDiff) {
|
|
1119
|
+
const summary = destructiveReductions.map(({ root, trackedBefore, trackedAfter }) => `${root} (${trackedBefore} -> ${trackedAfter} tracked files)`).join(", ");
|
|
1120
|
+
return {
|
|
1121
|
+
...observed,
|
|
1122
|
+
outcome: "large_diff_blocked",
|
|
1123
|
+
error: `Workspace safety guard blocked a destructive checkout reduction: ${summary}`
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
if (paths.length > 0 && diffSizeBytes > MAX_WORKSPACE_SYNC_DIFF_BYTES && !input.confirmedLargeDiff && !input.trigger.canonicalCheckoutOnly) {
|
|
1127
|
+
return {
|
|
1128
|
+
...observed,
|
|
1129
|
+
outcome: "large_diff_blocked",
|
|
1130
|
+
error: `Workspace safety guard blocked a ${diffSizeBytes}-byte diff above the ${MAX_WORKSPACE_SYNC_DIFF_BYTES}-byte automatic publication limit.`
|
|
1131
|
+
};
|
|
462
1132
|
}
|
|
463
|
-
if (
|
|
1133
|
+
if (stagedWorkingPaths.length > 0) {
|
|
464
1134
|
runGit(input, input.shadowRoot, ["commit", "-m", commitMessage(input)], "commit workspace changes");
|
|
465
1135
|
}
|
|
466
1136
|
const candidateHead = revParse(input, "HEAD");
|
|
1137
|
+
if (candidateHead) assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, candidateHead);
|
|
467
1138
|
const hasRemoteMain = Boolean(revParse(input, `origin/${WORKSPACE_BRANCH}`));
|
|
468
1139
|
const hasUnpushedCommit = candidateHead ? !hasRemoteMain || !tryGit(input, input.shadowRoot, ["merge-base", "--is-ancestor", candidateHead, `origin/${WORKSPACE_BRANCH}`]) : false;
|
|
469
1140
|
if (!hasUnpushedCommit) {
|
|
@@ -472,7 +1143,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
472
1143
|
if (currentRemoteHead && localHead !== currentRemoteHead) {
|
|
473
1144
|
runGit(input, input.shadowRoot, ["reset", "--hard", `origin/${WORKSPACE_BRANCH}`], "update workspace from canonical state");
|
|
474
1145
|
runGit(input, input.shadowRoot, ["clean", "-fd"], "clean updated workspace");
|
|
475
|
-
|
|
1146
|
+
assertPublishedCanonicalCheckoutTree(input, currentRemoteHead, sampledCanonicalCheckoutTreeHash);
|
|
1147
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
476
1148
|
return {
|
|
477
1149
|
...observed,
|
|
478
1150
|
outcome: "updated",
|
|
@@ -481,7 +1153,13 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
481
1153
|
gitStatus: gitStatus(input)
|
|
482
1154
|
};
|
|
483
1155
|
}
|
|
484
|
-
|
|
1156
|
+
const authoritativeHead = currentRemoteHead ?? localHead;
|
|
1157
|
+
if (authoritativeHead) {
|
|
1158
|
+
assertPublishedCanonicalCheckoutTree(input, authoritativeHead, sampledCanonicalCheckoutTreeHash);
|
|
1159
|
+
}
|
|
1160
|
+
if (input.skipVisibleMirror) {
|
|
1161
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
1162
|
+
}
|
|
485
1163
|
return {
|
|
486
1164
|
...observed,
|
|
487
1165
|
outcome: "no_change",
|
|
@@ -495,7 +1173,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
495
1173
|
const push = runGitResult(input, input.shadowRoot, ["push", "origin", `HEAD:refs/heads/${WORKSPACE_BRANCH}`]);
|
|
496
1174
|
if (push.exitCode === 0) {
|
|
497
1175
|
const publishedHead = revParse(input, "HEAD") ?? candidateHead;
|
|
498
|
-
|
|
1176
|
+
if (publishedHead) assertPublishedCanonicalCheckoutTree(input, publishedHead, sampledCanonicalCheckoutTreeHash);
|
|
1177
|
+
mirrorShadowWorkspaceToVisible(input, mirroredGitlinkProjection.opaqueRoots);
|
|
499
1178
|
return {
|
|
500
1179
|
...observed,
|
|
501
1180
|
outcome: "published",
|
|
@@ -521,7 +1200,7 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
521
1200
|
if (rebase.exitCode !== 0) {
|
|
522
1201
|
const conflicted = runGitResult(input, input.shadowRoot, ["diff", "--name-only", "--diff-filter=U", "-z"]);
|
|
523
1202
|
const discardedPaths = reportedPaths([.../* @__PURE__ */ new Set([...paths, ...conflicted.stdout.split("\0").filter(Boolean)])].sort());
|
|
524
|
-
const publishedHead = resetShadowToRemote(input);
|
|
1203
|
+
const publishedHead = resetShadowToRemote(input, mirroredGitlinkProjection.opaqueRoots);
|
|
525
1204
|
return {
|
|
526
1205
|
...observed,
|
|
527
1206
|
outcome: "conflict_reset",
|
|
@@ -533,6 +1212,8 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
533
1212
|
gitStatus: gitStatus(input)
|
|
534
1213
|
};
|
|
535
1214
|
}
|
|
1215
|
+
assertPublishedCanonicalCheckoutTree(input, "HEAD", sampledCanonicalCheckoutTreeHash);
|
|
1216
|
+
assertUnscopedCanonicalCheckoutSubtreesMatchRemote(input, "HEAD");
|
|
536
1217
|
}
|
|
537
1218
|
return {
|
|
538
1219
|
...observed,
|
|
@@ -551,10 +1232,27 @@ async function synchronizeWorkspace(rawInput) {
|
|
|
551
1232
|
};
|
|
552
1233
|
}
|
|
553
1234
|
}
|
|
554
|
-
function calculateWorkspaceDiffFingerprint(
|
|
1235
|
+
function calculateWorkspaceDiffFingerprint(rawInput) {
|
|
1236
|
+
const input = {
|
|
1237
|
+
...rawInput,
|
|
1238
|
+
projects: workspaceProjectsForSync(rawInput.projects, rawInput.trigger)
|
|
1239
|
+
};
|
|
555
1240
|
ensureShadowWorkspace(input);
|
|
556
|
-
|
|
557
|
-
|
|
1241
|
+
let gitlinkProjection = { entries: [], opaqueRoots: [] };
|
|
1242
|
+
if (!input.skipVisibleMirror) {
|
|
1243
|
+
resetUncommittedShadowSnapshot(input);
|
|
1244
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
1245
|
+
gitlinkProjection = mirrorVisibleWorkspaceToShadow(input);
|
|
1246
|
+
} else {
|
|
1247
|
+
restoreUnscopedCanonicalCheckoutSubtrees(input);
|
|
1248
|
+
}
|
|
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);
|
|
558
1256
|
const stagedTree = runGit(input, input.shadowRoot, ["write-tree"], "write workspace fingerprint tree");
|
|
559
1257
|
const headTree = revParse(input, "HEAD^{tree}");
|
|
560
1258
|
const localHead = revParse(input, "HEAD");
|
|
@@ -566,27 +1264,25 @@ function calculateWorkspaceDiffFingerprint(input) {
|
|
|
566
1264
|
}
|
|
567
1265
|
class WorkspaceSyncSingleFlight {
|
|
568
1266
|
queue = Promise.resolve();
|
|
569
|
-
|
|
570
|
-
const queued = this.queue.then(
|
|
571
|
-
() => synchronizeWorkspace(input),
|
|
572
|
-
() => synchronizeWorkspace(input)
|
|
573
|
-
);
|
|
1267
|
+
enqueue(task) {
|
|
1268
|
+
const queued = this.queue.then(task, task);
|
|
574
1269
|
this.queue = queued.then(
|
|
575
1270
|
() => void 0,
|
|
576
1271
|
() => void 0
|
|
577
1272
|
);
|
|
578
1273
|
return queued;
|
|
579
1274
|
}
|
|
1275
|
+
run(input) {
|
|
1276
|
+
return this.enqueue(() => synchronizeWorkspace(input));
|
|
1277
|
+
}
|
|
1278
|
+
runPrepared(prepare) {
|
|
1279
|
+
return this.enqueue(() => synchronizeWorkspace(prepare()));
|
|
1280
|
+
}
|
|
580
1281
|
fingerprint(input) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
);
|
|
585
|
-
this.queue = queued.then(
|
|
586
|
-
() => void 0,
|
|
587
|
-
() => void 0
|
|
588
|
-
);
|
|
589
|
-
return queued;
|
|
1282
|
+
return this.enqueue(() => calculateWorkspaceDiffFingerprint(input));
|
|
1283
|
+
}
|
|
1284
|
+
fingerprintPrepared(prepare) {
|
|
1285
|
+
return this.enqueue(() => calculateWorkspaceDiffFingerprint(prepare()));
|
|
590
1286
|
}
|
|
591
1287
|
afterCurrent() {
|
|
592
1288
|
return this.queue;
|
|
@@ -597,6 +1293,7 @@ export {
|
|
|
597
1293
|
WORKSPACE_BRANCH,
|
|
598
1294
|
WORKSPACE_PERIODIC_SCAN_INTERVAL_MS,
|
|
599
1295
|
WorkspaceSyncSingleFlight,
|
|
1296
|
+
assertCanonicalCheckoutIndexMatchesWorktree,
|
|
600
1297
|
calculateWorkspaceDiffFingerprint,
|
|
601
1298
|
encodeWorkspaceBranch,
|
|
602
1299
|
mirrorShadowWorkspaceToVisible,
|
|
@@ -604,5 +1301,6 @@ export {
|
|
|
604
1301
|
synchronizeWorkspace,
|
|
605
1302
|
visibleProjectBranchPath,
|
|
606
1303
|
workspacePlansRelativePath,
|
|
607
|
-
workspaceProjectBranchRelativePath
|
|
1304
|
+
workspaceProjectBranchRelativePath,
|
|
1305
|
+
workspaceProjectsForSync
|
|
608
1306
|
};
|