@ricsam/r5d-worker 0.0.78 → 0.0.80
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/README.md +6 -0
- package/dist/cjs/main.cjs +1550 -335
- package/dist/cjs/managed-paths.cjs +101 -1
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-workspace-state.cjs +184 -5
- package/dist/cjs/project-worktrees.cjs +676 -58
- package/dist/cjs/registry-auth.cjs +310 -0
- package/dist/cjs/repository-transition-policy.cjs +49 -0
- package/dist/cjs/supervisor.cjs +62 -11
- package/dist/cjs/working-tree-mirror.cjs +196 -36
- package/dist/cjs/workspace-automatic-sync-policy.cjs +69 -0
- package/dist/cjs/workspace-branch-incarnation-policy.cjs +37 -0
- package/dist/cjs/workspace-command-sync-policy.cjs +18 -0
- package/dist/cjs/workspace-git-sync.cjs +1037 -54
- package/dist/cjs/workspace-mount-boundary.cjs +71 -0
- package/dist/cjs/workspace-path-move.cjs +195 -0
- package/dist/cjs/workspace-preserve-only-policy.cjs +36 -0
- package/dist/cjs/workspace-project-config-policy.cjs +46 -0
- package/dist/cjs/workspace-publication-evidence.cjs +46 -0
- package/dist/mjs/main.mjs +1584 -341
- package/dist/mjs/managed-paths.mjs +100 -1
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-workspace-state.mjs +181 -5
- package/dist/mjs/project-worktrees.mjs +667 -57
- package/dist/mjs/registry-auth.mjs +269 -0
- package/dist/mjs/repository-transition-policy.mjs +22 -0
- package/dist/mjs/supervisor.mjs +61 -11
- package/dist/mjs/working-tree-mirror.mjs +195 -36
- package/dist/mjs/workspace-automatic-sync-policy.mjs +40 -0
- package/dist/mjs/workspace-branch-incarnation-policy.mjs +13 -0
- package/dist/mjs/workspace-command-sync-policy.mjs +17 -0
- package/dist/mjs/workspace-git-sync.mjs +1032 -54
- package/dist/mjs/workspace-mount-boundary.mjs +37 -0
- package/dist/mjs/workspace-path-move.mjs +160 -0
- package/dist/mjs/workspace-preserve-only-policy.mjs +11 -0
- package/dist/mjs/workspace-project-config-policy.mjs +21 -0
- package/dist/mjs/workspace-publication-evidence.mjs +21 -0
- package/dist/types/credential-authority-lock-fixture.d.ts +1 -0
- package/dist/types/main.d.ts +175 -1
- package/dist/types/managed-paths.d.ts +11 -0
- package/dist/types/project-workspace-state.d.ts +45 -1
- package/dist/types/project-worktrees.d.ts +119 -2
- package/dist/types/registry-auth.d.ts +47 -0
- package/dist/types/repository-transition-policy.d.ts +26 -0
- package/dist/types/supervisor-daemonized-fixture.d.ts +1 -0
- package/dist/types/supervisor-signal-fixture.d.ts +1 -0
- package/dist/types/supervisor.d.ts +6 -4
- package/dist/types/working-tree-mirror.d.ts +14 -0
- package/dist/types/workspace-automatic-sync-policy.d.ts +37 -0
- package/dist/types/workspace-branch-incarnation-policy.d.ts +14 -0
- package/dist/types/workspace-command-sync-policy.d.ts +9 -0
- package/dist/types/workspace-git-sync.d.ts +33 -3
- package/dist/types/workspace-mount-boundary.d.ts +10 -0
- package/dist/types/workspace-path-move.d.ts +21 -0
- package/dist/types/workspace-preserve-only-policy.d.ts +15 -0
- package/dist/types/workspace-project-config-policy.d.ts +34 -0
- package/dist/types/workspace-publication-evidence.d.ts +17 -0
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
@@ -6,9 +6,26 @@ import { gitCredentialUsernameConfigKey, gitTransportSecurityArgs, workerGitProc
|
|
|
6
6
|
import { validateManagedBranchName } from "./managed-paths.mjs";
|
|
7
7
|
import { mirrorWorkingTree } from "./working-tree-mirror.mjs";
|
|
8
8
|
const PROJECT_WORKTREE_SNAPSHOT_PREFIX = "r5d-project-worktrees-";
|
|
9
|
+
const PROJECT_WORKTREE_SNAPSHOT_MANIFEST = "transaction.json";
|
|
10
|
+
const PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION = 1;
|
|
11
|
+
const MAX_PROJECT_WORKTREE_SNAPSHOT_MANIFEST_BYTES = 64 * 1024;
|
|
12
|
+
const PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID = randomUUID();
|
|
13
|
+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
9
14
|
const PROJECT_WORKTREE_SNAPSHOT_NAME = new RegExp(
|
|
10
15
|
`^${PROJECT_WORKTREE_SNAPSHOT_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([1-9]\\d*)-([A-Za-z0-9]{6})$`
|
|
11
16
|
);
|
|
17
|
+
class ProjectBranchCreationRollbackIncompleteError extends Error {
|
|
18
|
+
branchMayExist = true;
|
|
19
|
+
cleanupFailures;
|
|
20
|
+
constructor(branchName, cleanupFailures, cause) {
|
|
21
|
+
super(`Project branch ${branchName} may still exist after incomplete rollback: ${cleanupFailures.join("; ")}`, { cause });
|
|
22
|
+
this.name = "ProjectBranchCreationRollbackIncompleteError";
|
|
23
|
+
this.cleanupFailures = [...cleanupFailures];
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function projectBranchMayExistAfterCreateFailure(error) {
|
|
27
|
+
return error instanceof ProjectBranchCreationRollbackIncompleteError || typeof error === "object" && error !== null && "branchMayExist" in error && error.branchMayExist === true;
|
|
28
|
+
}
|
|
12
29
|
function projectWorktreeConfigurationFingerprint(input) {
|
|
13
30
|
return createHash("sha256").update(
|
|
14
31
|
JSON.stringify({
|
|
@@ -17,7 +34,12 @@ function projectWorktreeConfigurationFingerprint(input) {
|
|
|
17
34
|
repoHttpUrl: input.repoHttpUrl,
|
|
18
35
|
repoAuthHeader: input.repoAuthHeader,
|
|
19
36
|
defaultBranch: input.defaultBranch,
|
|
20
|
-
|
|
37
|
+
repositoryTransitionId: input.repositoryTransitionId ?? null,
|
|
38
|
+
executionDisabled: input.executionDisabled ?? false,
|
|
39
|
+
mirrorWritesDisabled: input.mirrorWritesDisabled ?? false,
|
|
40
|
+
preserveOnlyBranches: [...input.preserveOnlyBranches ?? []].sort((left, right) => left.branchName.localeCompare(right.branchName)).map(({ branchId, branchName }) => ({ branchId: branchId ?? null, branchName })),
|
|
41
|
+
branches: [...input.branches].sort((left, right) => left.branchName.localeCompare(right.branchName)).map(({ branchId, branchName, sourceBranchName, baseCommitHash }) => ({
|
|
42
|
+
branchId: branchId ?? null,
|
|
21
43
|
branchName,
|
|
22
44
|
sourceBranchName: sourceBranchName ?? null,
|
|
23
45
|
baseCommitHash
|
|
@@ -67,6 +89,27 @@ function branchPath(projectRoot, branchName) {
|
|
|
67
89
|
}
|
|
68
90
|
return result;
|
|
69
91
|
}
|
|
92
|
+
function assertNoSymlinkBranchPathComponents(projectRoot, branchPath2) {
|
|
93
|
+
const resolvedRoot = path.resolve(projectRoot);
|
|
94
|
+
const resolvedBranchPath = path.resolve(branchPath2);
|
|
95
|
+
const relative = path.relative(resolvedRoot, resolvedBranchPath);
|
|
96
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
97
|
+
throw new Error(`Branch path escapes its project root: ${branchPath2}`);
|
|
98
|
+
}
|
|
99
|
+
let current = resolvedRoot;
|
|
100
|
+
for (const component of ["", ...relative.split(path.sep).filter(Boolean)]) {
|
|
101
|
+
if (component) current = path.join(current, component);
|
|
102
|
+
try {
|
|
103
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
104
|
+
throw new Error(`Project branch path contains a symbolic link: ${current}`);
|
|
105
|
+
}
|
|
106
|
+
} catch (error) {
|
|
107
|
+
const code = error.code;
|
|
108
|
+
if (code === "ENOENT" || code === "ENOTDIR") return;
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
70
113
|
function assertNonOverlappingBranches(branches) {
|
|
71
114
|
const names = branches.map(({ branchName }) => branchName).sort();
|
|
72
115
|
if (new Set(names).size !== names.length) throw new Error("Project worktree branch names must be unique");
|
|
@@ -119,26 +162,310 @@ function hasCompatibleLinkedProjectWorktreeLayout(input) {
|
|
|
119
162
|
return kind === null || kind === "file" && commonGitDirectory(checkoutPath) === primaryCommonDir;
|
|
120
163
|
});
|
|
121
164
|
}
|
|
122
|
-
function
|
|
123
|
-
|
|
165
|
+
function fsyncDirectory(directory) {
|
|
166
|
+
const descriptor = fs.openSync(directory, "r");
|
|
167
|
+
try {
|
|
168
|
+
fs.fsyncSync(descriptor);
|
|
169
|
+
} finally {
|
|
170
|
+
fs.closeSync(descriptor);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function fsyncTree(targetPath) {
|
|
174
|
+
const stat = fs.lstatSync(targetPath);
|
|
175
|
+
if (stat.isSymbolicLink()) return;
|
|
176
|
+
if (stat.isDirectory()) {
|
|
177
|
+
for (const entry of fs.readdirSync(targetPath).sort()) fsyncTree(path.join(targetPath, entry));
|
|
178
|
+
fsyncDirectory(targetPath);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (!stat.isFile()) return;
|
|
182
|
+
const descriptor = fs.openSync(targetPath, "r");
|
|
183
|
+
try {
|
|
184
|
+
fs.fsyncSync(descriptor);
|
|
185
|
+
} finally {
|
|
186
|
+
fs.closeSync(descriptor);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
function fsyncDirectoryChain(directory, root) {
|
|
190
|
+
const resolvedDirectory = path.resolve(directory);
|
|
191
|
+
const resolvedRoot = path.resolve(root);
|
|
192
|
+
const relative = path.relative(resolvedRoot, resolvedDirectory);
|
|
193
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
194
|
+
throw new Error(`Directory is outside its durability root: ${resolvedDirectory}`);
|
|
195
|
+
}
|
|
196
|
+
let current = resolvedDirectory;
|
|
197
|
+
while (true) {
|
|
198
|
+
assertRealDirectory(current, "Durability path");
|
|
199
|
+
fsyncDirectory(current);
|
|
200
|
+
if (current === resolvedRoot) break;
|
|
201
|
+
current = path.dirname(current);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
function removeSnapshotStorage(snapshotRoot) {
|
|
205
|
+
const storageRoot = path.dirname(path.resolve(snapshotRoot));
|
|
206
|
+
fs.rmSync(snapshotRoot, { recursive: true, force: true });
|
|
207
|
+
fsyncDirectory(storageRoot);
|
|
208
|
+
}
|
|
209
|
+
function fsyncManagedProjectRootEntry(projectRoot) {
|
|
210
|
+
fsyncDirectoryChain(projectRoot, path.dirname(path.dirname(projectRoot)));
|
|
211
|
+
}
|
|
212
|
+
function createProjectWorktreeSnapshotRoot(temporaryRoot = os.tmpdir()) {
|
|
213
|
+
const resolvedTemporaryRoot = path.resolve(temporaryRoot);
|
|
214
|
+
const storageRootExisted = fs.existsSync(resolvedTemporaryRoot);
|
|
215
|
+
fs.mkdirSync(resolvedTemporaryRoot, { recursive: true, mode: 448 });
|
|
216
|
+
assertRealDirectory(resolvedTemporaryRoot, "Project worktree snapshot storage root");
|
|
217
|
+
if (!storageRootExisted) fsyncDirectory(path.dirname(resolvedTemporaryRoot));
|
|
218
|
+
fsyncDirectory(resolvedTemporaryRoot);
|
|
219
|
+
const snapshotRoot = fs.mkdtempSync(path.join(resolvedTemporaryRoot, `${PROJECT_WORKTREE_SNAPSHOT_PREFIX}${process.pid}-`));
|
|
220
|
+
fsyncDirectory(snapshotRoot);
|
|
221
|
+
fsyncDirectory(resolvedTemporaryRoot);
|
|
222
|
+
return snapshotRoot;
|
|
223
|
+
}
|
|
224
|
+
function snapshotManifestPath(snapshotRoot) {
|
|
225
|
+
return path.join(snapshotRoot, PROJECT_WORKTREE_SNAPSHOT_MANIFEST);
|
|
226
|
+
}
|
|
227
|
+
function assertRealDirectory(directory, label) {
|
|
228
|
+
const stat = fs.lstatSync(directory);
|
|
229
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`${label} must be a real directory`);
|
|
230
|
+
}
|
|
231
|
+
function normalizeSnapshotManifest(value) {
|
|
232
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
233
|
+
throw new Error("Project worktree snapshot manifest must be an object");
|
|
234
|
+
}
|
|
235
|
+
const record = value;
|
|
236
|
+
if (record.version !== PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION) {
|
|
237
|
+
throw new Error("Unsupported project worktree snapshot manifest version");
|
|
238
|
+
}
|
|
239
|
+
if (record.state !== "building" && record.state !== "prepared" && record.state !== "consumed") {
|
|
240
|
+
throw new Error("Project worktree snapshot manifest has an invalid state");
|
|
241
|
+
}
|
|
242
|
+
if (!Number.isSafeInteger(record.ownerProcessId) || record.ownerProcessId <= 0) {
|
|
243
|
+
throw new Error("Project worktree snapshot manifest has an invalid owner process");
|
|
244
|
+
}
|
|
245
|
+
if (typeof record.ownerSessionId !== "string" || !UUID_PATTERN.test(record.ownerSessionId)) {
|
|
246
|
+
throw new Error("Project worktree snapshot manifest has an invalid owner session");
|
|
247
|
+
}
|
|
248
|
+
if (typeof record.projectRoot !== "string" || !path.isAbsolute(record.projectRoot) || path.resolve(record.projectRoot) !== record.projectRoot) {
|
|
249
|
+
throw new Error("Project worktree snapshot manifest has an invalid project root");
|
|
250
|
+
}
|
|
251
|
+
if (typeof record.createdAt !== "string" || !Number.isFinite(Date.parse(record.createdAt))) {
|
|
252
|
+
throw new Error("Project worktree snapshot manifest has an invalid creation time");
|
|
253
|
+
}
|
|
254
|
+
if (!Array.isArray(record.branches)) throw new Error("Project worktree snapshot manifest has invalid branches");
|
|
255
|
+
const branches = record.branches.map((value2) => {
|
|
256
|
+
if (typeof value2 !== "object" || value2 === null || Array.isArray(value2)) {
|
|
257
|
+
throw new Error("Project worktree snapshot manifest has an invalid branch");
|
|
258
|
+
}
|
|
259
|
+
const branch = value2;
|
|
260
|
+
if (typeof branch.branchName !== "string" || typeof branch.snapshotPresent !== "boolean") {
|
|
261
|
+
throw new Error("Project worktree snapshot manifest has an invalid branch");
|
|
262
|
+
}
|
|
263
|
+
validateManagedBranchName(branch.branchName);
|
|
264
|
+
return { branchName: branch.branchName, snapshotPresent: branch.snapshotPresent };
|
|
265
|
+
});
|
|
266
|
+
const names = branches.map(({ branchName }) => branchName).sort();
|
|
267
|
+
if (new Set(names).size !== names.length) throw new Error("Project worktree snapshot branch names must be unique");
|
|
268
|
+
for (let index = 0; index < names.length; index += 1) {
|
|
269
|
+
const name = names[index];
|
|
270
|
+
if (names.slice(index + 1).some((candidate) => candidate.startsWith(`${name}/`))) {
|
|
271
|
+
throw new Error("Project worktree snapshot branch paths overlap");
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return {
|
|
275
|
+
version: PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION,
|
|
276
|
+
state: record.state,
|
|
277
|
+
ownerProcessId: record.ownerProcessId,
|
|
278
|
+
ownerSessionId: record.ownerSessionId,
|
|
279
|
+
projectRoot: record.projectRoot,
|
|
280
|
+
branches,
|
|
281
|
+
createdAt: record.createdAt
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function readSnapshotManifest(snapshotRoot) {
|
|
285
|
+
assertRealDirectory(snapshotRoot, "Project worktree snapshot root");
|
|
286
|
+
const manifestPath = snapshotManifestPath(snapshotRoot);
|
|
287
|
+
const stat = fs.lstatSync(manifestPath);
|
|
288
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Project worktree snapshot manifest must be a regular file");
|
|
289
|
+
if (stat.size > MAX_PROJECT_WORKTREE_SNAPSHOT_MANIFEST_BYTES) throw new Error("Project worktree snapshot manifest is too large");
|
|
290
|
+
return normalizeSnapshotManifest(JSON.parse(fs.readFileSync(manifestPath, "utf8")));
|
|
291
|
+
}
|
|
292
|
+
function writeSnapshotManifest(snapshotRoot, manifest) {
|
|
293
|
+
assertRealDirectory(snapshotRoot, "Project worktree snapshot root");
|
|
294
|
+
const normalized = normalizeSnapshotManifest(manifest);
|
|
295
|
+
const temporaryPath = path.join(snapshotRoot, `.${PROJECT_WORKTREE_SNAPSHOT_MANIFEST}.${process.pid}.${randomUUID()}.tmp`);
|
|
296
|
+
let descriptor;
|
|
297
|
+
try {
|
|
298
|
+
descriptor = fs.openSync(temporaryPath, "wx", 384);
|
|
299
|
+
fs.writeFileSync(descriptor, `${JSON.stringify(normalized, null, 2)}
|
|
300
|
+
`, "utf8");
|
|
301
|
+
fs.fsyncSync(descriptor);
|
|
302
|
+
fs.closeSync(descriptor);
|
|
303
|
+
descriptor = void 0;
|
|
304
|
+
fs.renameSync(temporaryPath, snapshotManifestPath(snapshotRoot));
|
|
305
|
+
fsyncDirectory(snapshotRoot);
|
|
306
|
+
} finally {
|
|
307
|
+
if (descriptor !== void 0) fs.closeSync(descriptor);
|
|
308
|
+
fs.rmSync(temporaryPath, { force: true });
|
|
309
|
+
}
|
|
310
|
+
return readSnapshotManifest(snapshotRoot);
|
|
311
|
+
}
|
|
312
|
+
function assertSameSnapshotIdentity(expected, actual) {
|
|
313
|
+
if (expected.ownerProcessId !== actual.ownerProcessId || expected.ownerSessionId !== actual.ownerSessionId || expected.projectRoot !== actual.projectRoot || expected.createdAt !== actual.createdAt || JSON.stringify(expected.branches) !== JSON.stringify(actual.branches)) {
|
|
314
|
+
throw new Error("Project worktree snapshot identity changed");
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function advanceSnapshotState(snapshotRoot, expected, state) {
|
|
318
|
+
const current = readSnapshotManifest(snapshotRoot);
|
|
319
|
+
assertSameSnapshotIdentity(expected, current);
|
|
320
|
+
const allowed = current.state === "building" && state === "prepared" || current.state === "prepared" && state === "consumed" || current.state === state;
|
|
321
|
+
if (!allowed) throw new Error(`Project worktree snapshot cannot advance from ${current.state} to ${state}`);
|
|
322
|
+
return current.state === state ? current : writeSnapshotManifest(snapshotRoot, { ...current, state });
|
|
323
|
+
}
|
|
324
|
+
function fullSnapshotPath(snapshotRoot, branchName) {
|
|
325
|
+
validateManagedBranchName(branchName);
|
|
326
|
+
return path.join(snapshotRoot, "trees", ...branchName.split("/"));
|
|
327
|
+
}
|
|
328
|
+
function copyCheckoutTree(sourceRoot, targetRoot) {
|
|
329
|
+
const sourceStat = fs.lstatSync(sourceRoot);
|
|
330
|
+
if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) {
|
|
331
|
+
throw new Error(`Project checkout snapshot source must be a real directory: ${sourceRoot}`);
|
|
332
|
+
}
|
|
333
|
+
fs.mkdirSync(path.dirname(targetRoot), { recursive: true });
|
|
334
|
+
fs.cpSync(sourceRoot, targetRoot, {
|
|
335
|
+
recursive: true,
|
|
336
|
+
dereference: false,
|
|
337
|
+
errorOnExist: true,
|
|
338
|
+
force: false,
|
|
339
|
+
mode: fs.constants.COPYFILE_FICLONE,
|
|
340
|
+
preserveTimestamps: true,
|
|
341
|
+
verbatimSymlinks: true
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
|
|
345
|
+
let entries;
|
|
346
|
+
try {
|
|
347
|
+
assertRealDirectory(temporaryRoot, "Project worktree snapshot storage root");
|
|
348
|
+
entries = fs.readdirSync(temporaryRoot, { withFileTypes: true });
|
|
349
|
+
} catch (error) {
|
|
350
|
+
if (error.code === "ENOENT") return;
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
for (const entry of entries) {
|
|
354
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !PROJECT_WORKTREE_SNAPSHOT_NAME.test(entry.name)) continue;
|
|
355
|
+
let manifest;
|
|
356
|
+
try {
|
|
357
|
+
manifest = readSnapshotManifest(path.join(temporaryRoot, entry.name));
|
|
358
|
+
} catch (error) {
|
|
359
|
+
throw new Error(`Project checkout snapshot state is unreadable at ${path.join(temporaryRoot, entry.name)}`, { cause: error });
|
|
360
|
+
}
|
|
361
|
+
if (manifest.projectRoot === projectRoot && manifest.state !== "consumed") {
|
|
362
|
+
throw new Error(`Project checkout has an unfinished recovery snapshot at ${path.join(temporaryRoot, entry.name)}`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
124
365
|
}
|
|
125
|
-
function snapshotBranchTrees(projectRoot, branches) {
|
|
126
|
-
const
|
|
366
|
+
function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
|
|
367
|
+
const resolvedProjectRoot = path.resolve(projectRoot);
|
|
368
|
+
const resolvedTemporaryRoot = path.resolve(temporaryRoot ?? os.tmpdir());
|
|
369
|
+
assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
|
|
370
|
+
const root = createProjectWorktreeSnapshotRoot(resolvedTemporaryRoot);
|
|
127
371
|
const paths = /* @__PURE__ */ new Map();
|
|
128
372
|
try {
|
|
129
|
-
|
|
130
|
-
const checkoutPath = branchPath(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
373
|
+
const branchSnapshots = branches.map(({ branchName }) => {
|
|
374
|
+
const checkoutPath = branchPath(resolvedProjectRoot, branchName);
|
|
375
|
+
let snapshotPresent = false;
|
|
376
|
+
try {
|
|
377
|
+
fs.lstatSync(checkoutPath);
|
|
378
|
+
snapshotPresent = true;
|
|
379
|
+
} catch (error) {
|
|
380
|
+
if (error.code !== "ENOENT") throw error;
|
|
381
|
+
}
|
|
382
|
+
return { branchName, snapshotPresent };
|
|
383
|
+
});
|
|
384
|
+
let manifest = writeSnapshotManifest(root, {
|
|
385
|
+
version: PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION,
|
|
386
|
+
state: "building",
|
|
387
|
+
ownerProcessId: process.pid,
|
|
388
|
+
ownerSessionId: PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID,
|
|
389
|
+
projectRoot: resolvedProjectRoot,
|
|
390
|
+
branches: branchSnapshots,
|
|
391
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
392
|
+
});
|
|
393
|
+
for (const { branchName, snapshotPresent } of branchSnapshots) {
|
|
394
|
+
if (!snapshotPresent) continue;
|
|
395
|
+
const snapshotPath = fullSnapshotPath(root, branchName);
|
|
396
|
+
copyCheckoutTree(branchPath(resolvedProjectRoot, branchName), snapshotPath);
|
|
134
397
|
paths.set(branchName, snapshotPath);
|
|
135
398
|
}
|
|
136
|
-
|
|
399
|
+
const treesRoot = path.join(root, "trees");
|
|
400
|
+
if (fs.existsSync(treesRoot)) fsyncTree(treesRoot);
|
|
401
|
+
manifest = advanceSnapshotState(root, manifest, "prepared");
|
|
402
|
+
return { root, manifest, paths };
|
|
137
403
|
} catch (error) {
|
|
138
|
-
|
|
404
|
+
removeSnapshotStorage(root);
|
|
139
405
|
throw error;
|
|
140
406
|
}
|
|
141
407
|
}
|
|
408
|
+
function restoreProjectWorktreeSnapshot(snapshotRoot, manifest) {
|
|
409
|
+
const current = readSnapshotManifest(snapshotRoot);
|
|
410
|
+
assertSameSnapshotIdentity(manifest, current);
|
|
411
|
+
if (current.state !== "prepared") throw new Error(`Project worktree snapshot is ${current.state}, not prepared`);
|
|
412
|
+
for (const { branchName, snapshotPresent } of current.branches) {
|
|
413
|
+
const targetPath = branchPath(current.projectRoot, branchName);
|
|
414
|
+
assertNoSymlinkBranchPathComponents(current.projectRoot, targetPath);
|
|
415
|
+
if (!snapshotPresent) continue;
|
|
416
|
+
const sourcePath = fullSnapshotPath(snapshotRoot, branchName);
|
|
417
|
+
const stat = fs.lstatSync(sourcePath);
|
|
418
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
419
|
+
throw new Error(`Project worktree snapshot branch must be a real directory: ${branchName}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
for (const { branchName } of [...current.branches].sort((left, right) => right.branchName.length - left.branchName.length)) {
|
|
423
|
+
const targetPath = branchPath(current.projectRoot, branchName);
|
|
424
|
+
const targetParent = path.dirname(targetPath);
|
|
425
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
426
|
+
if (fs.existsSync(targetParent)) fsyncDirectoryChain(targetParent, current.projectRoot);
|
|
427
|
+
}
|
|
428
|
+
for (const { branchName, snapshotPresent } of current.branches) {
|
|
429
|
+
if (!snapshotPresent) continue;
|
|
430
|
+
const targetPath = branchPath(current.projectRoot, branchName);
|
|
431
|
+
copyCheckoutTree(fullSnapshotPath(snapshotRoot, branchName), targetPath);
|
|
432
|
+
fsyncTree(targetPath);
|
|
433
|
+
fsyncDirectoryChain(path.dirname(targetPath), current.projectRoot);
|
|
434
|
+
}
|
|
435
|
+
fs.mkdirSync(current.projectRoot, { recursive: true });
|
|
436
|
+
fsyncManagedProjectRootEntry(current.projectRoot);
|
|
437
|
+
}
|
|
438
|
+
function consumeAndRemoveProjectWorktreeSnapshot(snapshotRoot, manifest) {
|
|
439
|
+
const consumed = advanceSnapshotState(snapshotRoot, manifest, "consumed");
|
|
440
|
+
try {
|
|
441
|
+
assertSameSnapshotIdentity(manifest, consumed);
|
|
442
|
+
removeSnapshotStorage(snapshotRoot);
|
|
443
|
+
} catch {
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFsync) {
|
|
447
|
+
fsyncTree(snapshot.manifest.projectRoot);
|
|
448
|
+
fsyncManagedProjectRootEntry(snapshot.manifest.projectRoot);
|
|
449
|
+
afterProjectTreeFsync?.();
|
|
450
|
+
consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, snapshot.manifest);
|
|
451
|
+
}
|
|
452
|
+
function rollbackProjectWorktreeSnapshot(snapshot) {
|
|
453
|
+
const current = readSnapshotManifest(snapshot.root);
|
|
454
|
+
assertSameSnapshotIdentity(snapshot.manifest, current);
|
|
455
|
+
if (current.state === "prepared") {
|
|
456
|
+
restoreProjectWorktreeSnapshot(snapshot.root, current);
|
|
457
|
+
consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, current);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (current.state === "consumed") {
|
|
461
|
+
try {
|
|
462
|
+
removeSnapshotStorage(snapshot.root);
|
|
463
|
+
} catch {
|
|
464
|
+
}
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
throw new Error(`Project worktree snapshot is unexpectedly ${current.state}`);
|
|
468
|
+
}
|
|
142
469
|
function isProcessAlive(processId) {
|
|
143
470
|
try {
|
|
144
471
|
process.kill(processId, 0);
|
|
@@ -147,35 +474,105 @@ function isProcessAlive(processId) {
|
|
|
147
474
|
return error.code !== "ESRCH";
|
|
148
475
|
}
|
|
149
476
|
}
|
|
150
|
-
function
|
|
477
|
+
function isPathInside(root, candidate) {
|
|
478
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
479
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
480
|
+
}
|
|
481
|
+
function assertNoSymlinkPathComponents(root, candidate) {
|
|
482
|
+
const resolvedRoot = path.resolve(root);
|
|
483
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
484
|
+
if (!isPathInside(resolvedRoot, resolvedCandidate)) throw new Error("Project worktree snapshot target escapes the projects root");
|
|
485
|
+
let current = resolvedRoot;
|
|
486
|
+
for (const component of ["", ...path.relative(resolvedRoot, resolvedCandidate).split(path.sep).filter(Boolean)]) {
|
|
487
|
+
if (component) current = path.join(current, component);
|
|
488
|
+
try {
|
|
489
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
490
|
+
throw new Error(`Project worktree snapshot target contains a symbolic link: ${current}`);
|
|
491
|
+
}
|
|
492
|
+
} catch (error) {
|
|
493
|
+
const code = error.code;
|
|
494
|
+
if (code === "ENOENT" || code === "ENOTDIR") return;
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
function recoverStaleProjectWorktreeSnapshots(input) {
|
|
151
500
|
const temporaryRoot = path.resolve(input.temporaryRoot ?? os.tmpdir());
|
|
501
|
+
const projectsRoot = path.resolve(input.projectsRoot);
|
|
152
502
|
const processAlive = input.processAlive ?? isProcessAlive;
|
|
503
|
+
const currentProcessId = input.currentProcessId ?? process.pid;
|
|
504
|
+
const currentOwnerSessionId = input.currentOwnerSessionId ?? PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID;
|
|
505
|
+
if (!Number.isSafeInteger(currentProcessId) || currentProcessId <= 0) throw new Error("Current snapshot recovery process ID is invalid");
|
|
506
|
+
if (!UUID_PATTERN.test(currentOwnerSessionId)) throw new Error("Current snapshot recovery owner session is invalid");
|
|
507
|
+
const restored = [];
|
|
153
508
|
const removed = [];
|
|
154
509
|
const failed = [];
|
|
155
510
|
let entries;
|
|
156
511
|
try {
|
|
512
|
+
assertRealDirectory(temporaryRoot, "Project worktree snapshot storage root");
|
|
157
513
|
entries = fs.readdirSync(temporaryRoot, { withFileTypes: true });
|
|
158
514
|
} catch (error) {
|
|
159
|
-
if (error.code === "ENOENT") return { removed, failed };
|
|
160
|
-
return { removed, failed: [{ path: temporaryRoot, error: error instanceof Error ? error.message : String(error) }] };
|
|
515
|
+
if (error.code === "ENOENT") return { restored, removed, failed };
|
|
516
|
+
return { restored, removed, failed: [{ path: temporaryRoot, error: error instanceof Error ? error.message : String(error) }] };
|
|
161
517
|
}
|
|
162
518
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
163
519
|
const match = PROJECT_WORKTREE_SNAPSHOT_NAME.exec(entry.name);
|
|
164
520
|
if (!match) continue;
|
|
165
521
|
const ownerProcessId = Number(match[1]);
|
|
166
|
-
if (!Number.isSafeInteger(ownerProcessId)
|
|
522
|
+
if (!Number.isSafeInteger(ownerProcessId)) continue;
|
|
167
523
|
const candidate = path.join(temporaryRoot, entry.name);
|
|
168
524
|
try {
|
|
169
525
|
const stat = fs.lstatSync(candidate);
|
|
170
526
|
if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
|
|
171
|
-
|
|
172
|
-
|
|
527
|
+
const manifest = readSnapshotManifest(candidate);
|
|
528
|
+
if (manifest.ownerProcessId !== ownerProcessId) {
|
|
529
|
+
throw new Error("Project worktree snapshot owner does not match its directory name");
|
|
530
|
+
}
|
|
531
|
+
if (!isPathInside(projectsRoot, manifest.projectRoot)) {
|
|
532
|
+
throw new Error("Project worktree snapshot target is outside the configured projects root");
|
|
533
|
+
}
|
|
534
|
+
assertNoSymlinkPathComponents(projectsRoot, manifest.projectRoot);
|
|
535
|
+
const ownedByThisProcessSession = manifest.ownerProcessId === currentProcessId && manifest.ownerSessionId === currentOwnerSessionId;
|
|
536
|
+
if (ownedByThisProcessSession || ownerProcessId !== currentProcessId && processAlive(ownerProcessId)) continue;
|
|
537
|
+
if (manifest.state === "prepared") {
|
|
538
|
+
restoreProjectWorktreeSnapshot(candidate, manifest);
|
|
539
|
+
const consumed = advanceSnapshotState(candidate, manifest, "consumed");
|
|
540
|
+
removeSnapshotStorage(candidate);
|
|
541
|
+
assertSameSnapshotIdentity(manifest, consumed);
|
|
542
|
+
restored.push(candidate);
|
|
543
|
+
} else {
|
|
544
|
+
removeSnapshotStorage(candidate);
|
|
545
|
+
removed.push(candidate);
|
|
546
|
+
}
|
|
173
547
|
} catch (error) {
|
|
174
|
-
if (
|
|
548
|
+
if (ownerProcessId !== currentProcessId && processAlive(ownerProcessId)) continue;
|
|
549
|
+
if (error.code === "ENOENT") {
|
|
550
|
+
try {
|
|
551
|
+
fs.lstatSync(candidate);
|
|
552
|
+
} catch (candidateError) {
|
|
553
|
+
if (candidateError.code === "ENOENT") continue;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
175
556
|
failed.push({ path: candidate, error: error instanceof Error ? error.message : String(error) });
|
|
176
557
|
}
|
|
177
558
|
}
|
|
178
|
-
return { removed, failed };
|
|
559
|
+
return { restored, removed, failed };
|
|
560
|
+
}
|
|
561
|
+
function projectOriginBranchName(input) {
|
|
562
|
+
validateManagedBranchName(input.branchName);
|
|
563
|
+
validateManagedBranchName(input.primaryBranchName);
|
|
564
|
+
validateManagedBranchName(input.defaultBranch);
|
|
565
|
+
return input.branchName === input.primaryBranchName ? input.defaultBranch : input.branchName;
|
|
566
|
+
}
|
|
567
|
+
function configureProjectBranchTracking(checkoutPath, branchName, originBranchName) {
|
|
568
|
+
validateManagedBranchName(branchName);
|
|
569
|
+
validateManagedBranchName(originBranchName);
|
|
570
|
+
git(checkoutPath, ["config", "--local", `branch.${branchName}.remote`, "origin"], `configure origin for ${branchName}`);
|
|
571
|
+
git(
|
|
572
|
+
checkoutPath,
|
|
573
|
+
["config", "--local", `branch.${branchName}.merge`, `refs/heads/${originBranchName}`],
|
|
574
|
+
`configure upstream branch for ${branchName}`
|
|
575
|
+
);
|
|
179
576
|
}
|
|
180
577
|
function configureRepository(input) {
|
|
181
578
|
if (tryGit(input.primaryPath, ["remote", "get-url", "origin"])) {
|
|
@@ -207,6 +604,17 @@ function configureRepository(input) {
|
|
|
207
604
|
git(input.primaryPath, ["config", "--local", "user.name", name], "configure project Git user name");
|
|
208
605
|
git(input.primaryPath, ["config", "--local", "user.email", email], "configure project Git user email");
|
|
209
606
|
}
|
|
607
|
+
if (input.primaryBranchName && input.defaultBranch && input.branches) {
|
|
608
|
+
git(input.primaryPath, ["config", "--local", "push.default", "upstream"], "configure project push mapping");
|
|
609
|
+
for (const { branchName } of input.branches) {
|
|
610
|
+
const originBranchName = projectOriginBranchName({
|
|
611
|
+
branchName,
|
|
612
|
+
primaryBranchName: input.primaryBranchName,
|
|
613
|
+
defaultBranch: input.defaultBranch
|
|
614
|
+
});
|
|
615
|
+
configureProjectBranchTracking(input.primaryPath, branchName, originBranchName);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
210
618
|
}
|
|
211
619
|
function fetchProjectHeads(input) {
|
|
212
620
|
const originFetch = gitResult(input.primaryPath, [
|
|
@@ -256,6 +664,9 @@ function ensureCommitAvailable(input) {
|
|
|
256
664
|
function seedForBranch(input) {
|
|
257
665
|
const mirrorRef = `refs/r5d/mirror/${input.branch.branchName}`;
|
|
258
666
|
const mirrorHead = tryGit(input.primaryPath, ["show-ref", "--verify", "--quiet", mirrorRef]) ? git(input.primaryPath, ["rev-parse", mirrorRef], "resolve project mirror head") : null;
|
|
667
|
+
if (input.requireMirrorHead && !mirrorHead) {
|
|
668
|
+
throw new Error(`Exact hidden project mirror head is missing for ${input.branch.branchName}`);
|
|
669
|
+
}
|
|
259
670
|
const seed = mirrorHead ?? input.branch.baseCommitHash;
|
|
260
671
|
ensureCommitAvailable({ ...input, commitHash: seed });
|
|
261
672
|
return { seed, mirrorHead };
|
|
@@ -277,7 +688,12 @@ function initializeLinkedLayout(input) {
|
|
|
277
688
|
const seeds = /* @__PURE__ */ new Map();
|
|
278
689
|
const mirrorHeads = /* @__PURE__ */ new Map();
|
|
279
690
|
for (const branch of input.branches) {
|
|
280
|
-
const resolved = seedForBranch({
|
|
691
|
+
const resolved = seedForBranch({
|
|
692
|
+
...input,
|
|
693
|
+
primaryPath,
|
|
694
|
+
branch,
|
|
695
|
+
requireMirrorHead: input.exactMirrorBranches?.has(branch.branchName)
|
|
696
|
+
});
|
|
281
697
|
seeds.set(branch.branchName, resolved.seed);
|
|
282
698
|
mirrorHeads.set(branch.branchName, resolved.mirrorHead);
|
|
283
699
|
}
|
|
@@ -301,24 +717,37 @@ function reconcileLinkedLayout(input) {
|
|
|
301
717
|
tryGit(primaryPath, ["worktree", "prune"]);
|
|
302
718
|
fetchProjectHeads({ ...input, primaryPath });
|
|
303
719
|
const mirrorHeads = /* @__PURE__ */ new Map();
|
|
720
|
+
const mutatedBranches = /* @__PURE__ */ new Set();
|
|
304
721
|
for (const branch of input.branches) {
|
|
305
722
|
const checkoutPath = branchPath(input.projectRoot, branch.branchName);
|
|
306
|
-
const resolved = seedForBranch({
|
|
723
|
+
const resolved = seedForBranch({
|
|
724
|
+
...input,
|
|
725
|
+
primaryPath,
|
|
726
|
+
branch,
|
|
727
|
+
requireMirrorHead: input.exactMirrorBranches?.has(branch.branchName)
|
|
728
|
+
});
|
|
307
729
|
mirrorHeads.set(branch.branchName, resolved.mirrorHead);
|
|
308
730
|
if (branch.branchName === input.primaryBranchName || commonGitDirectory(checkoutPath) === commonGitDirectory(primaryPath)) {
|
|
731
|
+
const currentHead = git(checkoutPath, ["rev-parse", "HEAD"], `resolve linked worktree head ${branch.branchName}`);
|
|
732
|
+
if (currentHead === resolved.seed) continue;
|
|
733
|
+
input.beforeDestructiveMutation();
|
|
734
|
+
mutatedBranches.add(branch.branchName);
|
|
309
735
|
preserveHead(checkoutPath);
|
|
310
736
|
git(checkoutPath, ["reset", "--hard", resolved.seed], `anchor linked worktree ${branch.branchName}`);
|
|
311
737
|
continue;
|
|
312
738
|
}
|
|
739
|
+
input.beforeDestructiveMutation();
|
|
740
|
+
mutatedBranches.add(branch.branchName);
|
|
313
741
|
fs.rmSync(checkoutPath, { recursive: true, force: true });
|
|
314
742
|
git(primaryPath, ["branch", "-f", branch.branchName, resolved.seed], `anchor project branch ${branch.branchName}`);
|
|
315
743
|
fs.mkdirSync(path.dirname(checkoutPath), { recursive: true });
|
|
316
744
|
git(primaryPath, ["worktree", "add", "--force", checkoutPath, branch.branchName], `create linked worktree ${branch.branchName}`);
|
|
317
745
|
}
|
|
318
|
-
return mirrorHeads;
|
|
746
|
+
return { mirrorHeads, mutatedBranches };
|
|
319
747
|
}
|
|
320
|
-
function aheadBehind(checkoutPath, branchName) {
|
|
321
|
-
const
|
|
748
|
+
function aheadBehind(checkoutPath, branchName, primaryBranchName, defaultBranch) {
|
|
749
|
+
const originBranchName = projectOriginBranchName({ branchName, primaryBranchName, defaultBranch });
|
|
750
|
+
const originRef = `refs/remotes/origin/${originBranchName}`;
|
|
322
751
|
if (!tryGit(checkoutPath, ["show-ref", "--verify", "--quiet", originRef])) return { ahead: null, behind: null };
|
|
323
752
|
const output = git(checkoutPath, ["rev-list", "--left-right", "--count", `${originRef}...HEAD`], "measure project origin divergence");
|
|
324
753
|
const [behind, ahead] = output.split(/\s+/).map(Number);
|
|
@@ -326,11 +755,18 @@ function aheadBehind(checkoutPath, branchName) {
|
|
|
326
755
|
}
|
|
327
756
|
function ensureProjectWorktrees(input) {
|
|
328
757
|
const projectRoot = path.resolve(input.projectRoot);
|
|
758
|
+
const defaultBranch = input.defaultBranch ?? input.primaryBranchName;
|
|
329
759
|
assertNonOverlappingBranches(input.branches);
|
|
330
760
|
if (!input.branches.some(({ branchName }) => branchName === input.primaryBranchName)) {
|
|
331
761
|
throw new Error(`Primary project branch ${input.primaryBranchName} is missing from the workspace configuration`);
|
|
332
762
|
}
|
|
333
|
-
const
|
|
763
|
+
const snapshotStorageRoot = path.resolve(input.snapshotRoot ?? os.tmpdir());
|
|
764
|
+
assertNoOutstandingSnapshotForProject(snapshotStorageRoot, projectRoot);
|
|
765
|
+
let snapshot;
|
|
766
|
+
const ensureSnapshot = () => {
|
|
767
|
+
snapshot ??= snapshotBranchTrees(projectRoot, input.branches, snapshotStorageRoot);
|
|
768
|
+
return snapshot;
|
|
769
|
+
};
|
|
334
770
|
try {
|
|
335
771
|
fs.mkdirSync(projectRoot, { recursive: true });
|
|
336
772
|
const linked = hasCompatibleLinkedProjectWorktreeLayout({
|
|
@@ -338,9 +774,27 @@ function ensureProjectWorktrees(input) {
|
|
|
338
774
|
primaryBranchName: input.primaryBranchName,
|
|
339
775
|
branches: input.branches
|
|
340
776
|
});
|
|
341
|
-
|
|
777
|
+
let mirrorHeads;
|
|
778
|
+
let mutatedBranches;
|
|
779
|
+
if (linked) {
|
|
780
|
+
({ mirrorHeads, mutatedBranches } = reconcileLinkedLayout({
|
|
781
|
+
...input,
|
|
782
|
+
projectRoot,
|
|
783
|
+
defaultBranch,
|
|
784
|
+
beforeDestructiveMutation: ensureSnapshot
|
|
785
|
+
}));
|
|
786
|
+
} else {
|
|
787
|
+
ensureSnapshot();
|
|
788
|
+
mirrorHeads = initializeLinkedLayout({ ...input, projectRoot, defaultBranch });
|
|
789
|
+
mutatedBranches = new Set(input.branches.map(({ branchName }) => branchName));
|
|
790
|
+
}
|
|
791
|
+
const preparedSnapshot = snapshot;
|
|
792
|
+
if (mutatedBranches.size > 0 && !preparedSnapshot) {
|
|
793
|
+
throw new Error("Project worktree mutation started without a prepared recovery snapshot");
|
|
794
|
+
}
|
|
342
795
|
for (const branch of input.branches) {
|
|
343
|
-
|
|
796
|
+
if (!mutatedBranches.has(branch.branchName)) continue;
|
|
797
|
+
const snapshotPath = preparedSnapshot?.paths.get(branch.branchName);
|
|
344
798
|
if (!snapshotPath) continue;
|
|
345
799
|
mirrorWorkingTree({
|
|
346
800
|
sourceRoot: snapshotPath,
|
|
@@ -349,9 +803,9 @@ function ensureProjectWorktrees(input) {
|
|
|
349
803
|
deletionMode: "git"
|
|
350
804
|
});
|
|
351
805
|
}
|
|
352
|
-
|
|
806
|
+
const states = input.branches.map((branch) => {
|
|
353
807
|
const checkoutPath = branchPath(projectRoot, branch.branchName);
|
|
354
|
-
const divergence = aheadBehind(checkoutPath, branch.branchName);
|
|
808
|
+
const divergence = aheadBehind(checkoutPath, branch.branchName, input.primaryBranchName, defaultBranch);
|
|
355
809
|
return {
|
|
356
810
|
branchName: branch.branchName,
|
|
357
811
|
branchPath: checkoutPath,
|
|
@@ -360,8 +814,19 @@ function ensureProjectWorktrees(input) {
|
|
|
360
814
|
...divergence
|
|
361
815
|
};
|
|
362
816
|
}).sort((left, right) => left.branchName.localeCompare(right.branchName));
|
|
363
|
-
|
|
364
|
-
|
|
817
|
+
if (snapshot) persistReconciledProjectAndConsumeSnapshot(snapshot);
|
|
818
|
+
return states;
|
|
819
|
+
} catch (error) {
|
|
820
|
+
if (!snapshot) throw error;
|
|
821
|
+
try {
|
|
822
|
+
rollbackProjectWorktreeSnapshot(snapshot);
|
|
823
|
+
} catch (recoveryError) {
|
|
824
|
+
throw new Error(
|
|
825
|
+
`Project worktree reconciliation failed and its snapshot could not be fully restored; recovery state is retained at ${snapshot.root}`,
|
|
826
|
+
{ cause: new AggregateError([error, recoveryError]) }
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
throw error;
|
|
365
830
|
}
|
|
366
831
|
}
|
|
367
832
|
function projectWorktreeOperationInProgress(checkoutPath) {
|
|
@@ -379,6 +844,8 @@ function createLinkedProjectBranch(input) {
|
|
|
379
844
|
if (input.sourceBranchName === input.branchName) throw new Error("Source and target branch names must differ");
|
|
380
845
|
const sourcePath = branchPath(input.projectRoot, input.sourceBranchName);
|
|
381
846
|
const targetPath = branchPath(input.projectRoot, input.branchName);
|
|
847
|
+
assertNoSymlinkBranchPathComponents(input.projectRoot, sourcePath);
|
|
848
|
+
assertNoSymlinkBranchPathComponents(input.projectRoot, targetPath);
|
|
382
849
|
if (!commonGitDirectory(sourcePath)) throw new Error(`Source worktree ${input.sourceBranchName} is unavailable`);
|
|
383
850
|
if (projectWorktreeOperationInProgress(sourcePath)) {
|
|
384
851
|
throw new Error(`Source worktree ${input.sourceBranchName} has an in-progress Git operation`);
|
|
@@ -394,25 +861,84 @@ function createLinkedProjectBranch(input) {
|
|
|
394
861
|
});
|
|
395
862
|
if (overlappingWorktree) throw new Error(`Project branch folder overlaps linked worktree ${overlappingWorktree}`);
|
|
396
863
|
const sourceHead = git(sourcePath, ["rev-parse", "HEAD"], "resolve source branch head");
|
|
397
|
-
|
|
864
|
+
let branchCreated = false;
|
|
398
865
|
try {
|
|
399
|
-
mirrorWorkingTree({ sourceRoot: sourcePath, targetRoot: snapshotRoot, sourceMode: "git", deletionMode: "all" });
|
|
400
866
|
git(sourcePath, ["branch", input.branchName, sourceHead], `create project branch ${input.branchName}`);
|
|
867
|
+
branchCreated = true;
|
|
401
868
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
869
|
+
assertNoSymlinkBranchPathComponents(input.projectRoot, targetPath);
|
|
870
|
+
git(
|
|
871
|
+
sourcePath,
|
|
872
|
+
["worktree", "add", "--force", "--no-checkout", targetPath, input.branchName],
|
|
873
|
+
`create linked worktree ${input.branchName}`
|
|
874
|
+
);
|
|
875
|
+
configureProjectBranchTracking(targetPath, input.branchName, input.branchName);
|
|
876
|
+
assertNoSymlinkBranchPathComponents(input.projectRoot, targetPath);
|
|
877
|
+
git(targetPath, ["read-tree", sourceHead], `initialize linked worktree index ${input.branchName}`);
|
|
878
|
+
mirrorWorkingTree({ sourceRoot: sourcePath, targetRoot: targetPath, sourceMode: "git", deletionMode: "git" });
|
|
411
879
|
return { branchPath: targetPath, baseCommitHash: sourceHead };
|
|
412
|
-
}
|
|
413
|
-
|
|
880
|
+
} catch (error) {
|
|
881
|
+
if (branchCreated) {
|
|
882
|
+
let removeError;
|
|
883
|
+
try {
|
|
884
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
885
|
+
} catch (cleanupError) {
|
|
886
|
+
removeError = cleanupError;
|
|
887
|
+
}
|
|
888
|
+
const prune = gitResult(sourcePath, ["worktree", "prune"]);
|
|
889
|
+
const deleteBranch = gitResult(sourcePath, ["branch", "-D", input.branchName]);
|
|
890
|
+
const cleanupFailures = [];
|
|
891
|
+
try {
|
|
892
|
+
fs.lstatSync(targetPath);
|
|
893
|
+
cleanupFailures.push(`target path remains at ${targetPath}`);
|
|
894
|
+
} catch (verificationError) {
|
|
895
|
+
const code = verificationError.code;
|
|
896
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
897
|
+
cleanupFailures.push(
|
|
898
|
+
`could not verify target removal${removeError ? ` after ${removeError instanceof Error ? removeError.message : String(removeError)}` : ""}: ${verificationError instanceof Error ? verificationError.message : String(verificationError)}`
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const worktrees = gitResult(sourcePath, ["worktree", "list", "--porcelain"]);
|
|
903
|
+
if (worktrees.exitCode !== 0) {
|
|
904
|
+
cleanupFailures.push(
|
|
905
|
+
`could not verify linked-worktree removal: ${worktrees.stderr || worktrees.stdout || `git exited ${worktrees.exitCode}`}`
|
|
906
|
+
);
|
|
907
|
+
} else {
|
|
908
|
+
const targetRegistered = worktrees.stdout.split(/\r?\n/).some((line) => line.startsWith("worktree ") && path.resolve(line.slice("worktree ".length)) === path.resolve(targetPath));
|
|
909
|
+
if (targetRegistered) {
|
|
910
|
+
cleanupFailures.push(
|
|
911
|
+
`linked worktree remains registered at ${targetPath}${prune.exitCode === 0 ? "" : ` (${prune.stderr || prune.stdout || `git exited ${prune.exitCode}`})`}`
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
const branchRef = gitResult(sourcePath, ["show-ref", "--verify", "--quiet", `refs/heads/${input.branchName}`]);
|
|
916
|
+
if (branchRef.exitCode === 0) {
|
|
917
|
+
cleanupFailures.push(
|
|
918
|
+
`branch ref remains${deleteBranch.exitCode === 0 ? "" : ` (${deleteBranch.stderr || deleteBranch.stdout || `git exited ${deleteBranch.exitCode}`})`}`
|
|
919
|
+
);
|
|
920
|
+
} else if (branchRef.exitCode !== 1) {
|
|
921
|
+
cleanupFailures.push(
|
|
922
|
+
`could not verify branch ref removal: ${branchRef.stderr || branchRef.stdout || `git exited ${branchRef.exitCode}`}`
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
if (cleanupFailures.length > 0) {
|
|
926
|
+
throw new ProjectBranchCreationRollbackIncompleteError(input.branchName, cleanupFailures, error);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
throw error;
|
|
414
930
|
}
|
|
415
931
|
}
|
|
932
|
+
function createOrRetryLinkedProjectBranch(input) {
|
|
933
|
+
if (input.pendingRetry) {
|
|
934
|
+
deleteLinkedProjectBranch({
|
|
935
|
+
projectRoot: input.projectRoot,
|
|
936
|
+
primaryBranchName: input.primaryBranchName,
|
|
937
|
+
branchName: input.branchName
|
|
938
|
+
});
|
|
939
|
+
}
|
|
940
|
+
return createLinkedProjectBranch(input);
|
|
941
|
+
}
|
|
416
942
|
function deleteLinkedProjectBranch(input) {
|
|
417
943
|
validateManagedBranchName(input.primaryBranchName);
|
|
418
944
|
validateManagedBranchName(input.branchName);
|
|
@@ -472,7 +998,13 @@ function pushProjectMirrorHeads(input) {
|
|
|
472
998
|
for (const branchName of [...input.branchNames].sort()) {
|
|
473
999
|
if (input.onlyBranches && !input.onlyBranches.has(branchName)) continue;
|
|
474
1000
|
const checkoutPath = branchPath(input.projectRoot, branchName);
|
|
475
|
-
const head = git(checkoutPath, ["rev-parse", "HEAD"], `resolve mirror head for ${branchName}`);
|
|
1001
|
+
const head = input.publicationHeads ? input.publicationHeads.get(branchName) : git(checkoutPath, ["rev-parse", "HEAD"], `resolve mirror head for ${branchName}`);
|
|
1002
|
+
if (!head || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(head)) {
|
|
1003
|
+
throw new Error(`Missing exact publication head for project branch ${branchName}`);
|
|
1004
|
+
}
|
|
1005
|
+
if (!tryGit(checkoutPath, ["cat-file", "-e", `${head}^{commit}`])) {
|
|
1006
|
+
throw new Error(`Exact publication head ${head} is unavailable for project branch ${branchName}`);
|
|
1007
|
+
}
|
|
476
1008
|
if (projectWorktreeOperationInProgress(checkoutPath)) {
|
|
477
1009
|
results.push({ branchName, head, pushed: false, reason: "git_operation_in_progress" });
|
|
478
1010
|
continue;
|
|
@@ -484,7 +1016,7 @@ function pushProjectMirrorHeads(input) {
|
|
|
484
1016
|
"push",
|
|
485
1017
|
"--no-recurse-submodules",
|
|
486
1018
|
input.mirrorUrl,
|
|
487
|
-
|
|
1019
|
+
`+${head}:refs/heads/${branchName}`
|
|
488
1020
|
],
|
|
489
1021
|
`push hidden project mirror for ${branchName}`
|
|
490
1022
|
);
|
|
@@ -492,7 +1024,13 @@ function pushProjectMirrorHeads(input) {
|
|
|
492
1024
|
}
|
|
493
1025
|
return results;
|
|
494
1026
|
}
|
|
495
|
-
function
|
|
1027
|
+
function assertProjectMirrorHeadsPushed(results) {
|
|
1028
|
+
const skipped = results.find((result) => !result.pushed);
|
|
1029
|
+
if (skipped) {
|
|
1030
|
+
throw new Error(`Hidden project mirror publication skipped ${skipped.branchName}${skipped.reason ? ` (${skipped.reason})` : ""}`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function observeProjectMirrorHeads(input) {
|
|
496
1034
|
const primaryPath = branchPath(input.projectRoot, input.primaryBranchName);
|
|
497
1035
|
const fetch = gitResult(primaryPath, [
|
|
498
1036
|
...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
|
|
@@ -509,30 +1047,102 @@ function fastForwardProjectHeadsFromMirror(input) {
|
|
|
509
1047
|
const previousHead = git(checkoutPath, ["rev-parse", "HEAD"], `resolve local project head ${branchName}`);
|
|
510
1048
|
const mirrorRef = `refs/r5d/mirror/${branchName}`;
|
|
511
1049
|
const mirrorHead = tryGit(primaryPath, ["show-ref", "--verify", "--quiet", mirrorRef]) ? git(primaryPath, ["rev-parse", mirrorRef], `resolve refreshed mirror head ${branchName}`) : null;
|
|
512
|
-
|
|
513
|
-
|
|
1050
|
+
const shouldMove = Boolean(
|
|
1051
|
+
mirrorHead && mirrorHead !== previousHead && (input.allowNonFastForward || tryGit(checkoutPath, ["merge-base", "--is-ancestor", previousHead, mirrorHead]))
|
|
1052
|
+
);
|
|
1053
|
+
return {
|
|
1054
|
+
branchName,
|
|
1055
|
+
previousHead,
|
|
1056
|
+
mirrorHead,
|
|
1057
|
+
mode: input.allowNonFastForward ? "reseed" : "fast_forward",
|
|
1058
|
+
shouldMove
|
|
1059
|
+
};
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
function applyObservedProjectMirrorHeads(input) {
|
|
1063
|
+
return [...input.observations].sort((left, right) => left.branchName.localeCompare(right.branchName)).map((observation) => {
|
|
1064
|
+
const { branchName, mirrorHead } = observation;
|
|
1065
|
+
const checkoutPath = branchPath(input.projectRoot, branchName);
|
|
1066
|
+
const currentHead = git(checkoutPath, ["rev-parse", "HEAD"], `resolve local project head ${branchName}`);
|
|
1067
|
+
if (!observation.shouldMove || !mirrorHead || currentHead === mirrorHead || currentHead !== observation.previousHead) {
|
|
1068
|
+
return { branchName, previousHead: currentHead, mirrorHead, moved: false };
|
|
1069
|
+
}
|
|
1070
|
+
if (!tryGit(checkoutPath, ["cat-file", "-e", `${mirrorHead}^{commit}`])) {
|
|
1071
|
+
throw new Error(`Observed hidden mirror head is no longer available for ${branchName}: ${mirrorHead}`);
|
|
514
1072
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1073
|
+
const mutationAllowed = () => !projectWorktreeOperationInProgress(checkoutPath) && input.branchMutationAllowed?.(branchName, checkoutPath) !== false;
|
|
1074
|
+
if (!mutationAllowed()) {
|
|
1075
|
+
return { branchName, previousHead: currentHead, mirrorHead, moved: false };
|
|
1076
|
+
}
|
|
1077
|
+
if (observation.mode === "reseed") {
|
|
1078
|
+
preserveHead(checkoutPath);
|
|
1079
|
+
if (!mutationAllowed()) return { branchName, previousHead: currentHead, mirrorHead, moved: false };
|
|
1080
|
+
}
|
|
1081
|
+
const action = observation.mode === "reseed" ? "reseed" : "fast-forward";
|
|
1082
|
+
git(checkoutPath, ["read-tree", "--reset", mirrorHead], `${action} project index ${branchName}`);
|
|
1083
|
+
git(checkoutPath, ["update-ref", `refs/heads/${branchName}`, mirrorHead, currentHead], `${action} project branch ${branchName}`);
|
|
1084
|
+
return { branchName, previousHead: currentHead, mirrorHead, moved: true };
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
function updateProjectHeadsFromMirror(input) {
|
|
1088
|
+
const observations = observeProjectMirrorHeads(input);
|
|
1089
|
+
return applyObservedProjectMirrorHeads({
|
|
1090
|
+
projectRoot: input.projectRoot,
|
|
1091
|
+
primaryBranchName: input.primaryBranchName,
|
|
1092
|
+
observations,
|
|
1093
|
+
branchMutationAllowed: input.branchMutationAllowed
|
|
518
1094
|
});
|
|
519
1095
|
}
|
|
1096
|
+
function fastForwardProjectHeadsFromMirror(input) {
|
|
1097
|
+
return updateProjectHeadsFromMirror({ ...input, allowNonFastForward: false }).map(({ moved, ...state }) => ({
|
|
1098
|
+
...state,
|
|
1099
|
+
fastForwarded: moved
|
|
1100
|
+
}));
|
|
1101
|
+
}
|
|
1102
|
+
function reseedProjectHeadsFromMirror(input) {
|
|
1103
|
+
return updateProjectHeadsFromMirror({ ...input, allowNonFastForward: true }).map(({ moved, ...state }) => ({
|
|
1104
|
+
...state,
|
|
1105
|
+
reseeded: moved
|
|
1106
|
+
}));
|
|
1107
|
+
}
|
|
520
1108
|
const projectWorktreesTestHarness = {
|
|
521
1109
|
commandArgs: gitCommandArgs,
|
|
522
|
-
configureRepository
|
|
1110
|
+
configureRepository,
|
|
1111
|
+
createPreparedSnapshot(input) {
|
|
1112
|
+
return { root: snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot).root };
|
|
1113
|
+
},
|
|
1114
|
+
consumeSnapshot(snapshotRoot) {
|
|
1115
|
+
const manifest = readSnapshotManifest(snapshotRoot);
|
|
1116
|
+
advanceSnapshotState(snapshotRoot, manifest, "consumed");
|
|
1117
|
+
},
|
|
1118
|
+
persistProjectAndConsumeSnapshot(snapshotRoot, afterProjectTreeFsync) {
|
|
1119
|
+
const manifest = readSnapshotManifest(snapshotRoot);
|
|
1120
|
+
persistReconciledProjectAndConsumeSnapshot({ root: snapshotRoot, manifest, paths: /* @__PURE__ */ new Map() }, afterProjectTreeFsync);
|
|
1121
|
+
},
|
|
1122
|
+
snapshotState(snapshotRoot) {
|
|
1123
|
+
return readSnapshotManifest(snapshotRoot).state;
|
|
1124
|
+
}
|
|
523
1125
|
};
|
|
524
1126
|
export {
|
|
525
1127
|
PROJECT_WORKTREE_SNAPSHOT_PREFIX,
|
|
526
|
-
|
|
1128
|
+
ProjectBranchCreationRollbackIncompleteError,
|
|
1129
|
+
applyObservedProjectMirrorHeads,
|
|
1130
|
+
assertProjectMirrorHeadsPushed,
|
|
527
1131
|
createLinkedProjectBranch,
|
|
1132
|
+
createOrRetryLinkedProjectBranch,
|
|
528
1133
|
deleteLinkedProjectBranch,
|
|
529
1134
|
deleteProjectMirrorBranch,
|
|
530
1135
|
ensureProjectWorktrees,
|
|
531
1136
|
fastForwardProjectHeadsFromMirror,
|
|
532
1137
|
hasLinkedProjectWorktreeLayout,
|
|
1138
|
+
observeProjectMirrorHeads,
|
|
1139
|
+
projectBranchMayExistAfterCreateFailure,
|
|
1140
|
+
projectOriginBranchName,
|
|
533
1141
|
projectWorktreeConfigurationFingerprint,
|
|
534
1142
|
projectWorktreeOperationInProgress,
|
|
535
1143
|
projectWorktreesTestHarness,
|
|
536
1144
|
pushProjectMirrorHeads,
|
|
537
|
-
|
|
1145
|
+
recoverStaleProjectWorktreeSnapshots,
|
|
1146
|
+
removeProjectWorktrees,
|
|
1147
|
+
reseedProjectHeadsFromMirror
|
|
538
1148
|
};
|