@ricsam/r5d-worker 0.0.78 → 0.0.79
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 +641 -56
- 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 +191 -34
- 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 +1028 -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 +632 -55
- 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 +190 -34
- 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 +1023 -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 +7 -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,309 @@ 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("/"));
|
|
124
327
|
}
|
|
125
|
-
function
|
|
126
|
-
const
|
|
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
|
+
preserveTimestamps: true,
|
|
340
|
+
verbatimSymlinks: true
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
function assertNoOutstandingSnapshotForProject(temporaryRoot, projectRoot) {
|
|
344
|
+
let entries;
|
|
345
|
+
try {
|
|
346
|
+
assertRealDirectory(temporaryRoot, "Project worktree snapshot storage root");
|
|
347
|
+
entries = fs.readdirSync(temporaryRoot, { withFileTypes: true });
|
|
348
|
+
} catch (error) {
|
|
349
|
+
if (error.code === "ENOENT") return;
|
|
350
|
+
throw error;
|
|
351
|
+
}
|
|
352
|
+
for (const entry of entries) {
|
|
353
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || !PROJECT_WORKTREE_SNAPSHOT_NAME.test(entry.name)) continue;
|
|
354
|
+
let manifest;
|
|
355
|
+
try {
|
|
356
|
+
manifest = readSnapshotManifest(path.join(temporaryRoot, entry.name));
|
|
357
|
+
} catch (error) {
|
|
358
|
+
throw new Error(`Project checkout snapshot state is unreadable at ${path.join(temporaryRoot, entry.name)}`, { cause: error });
|
|
359
|
+
}
|
|
360
|
+
if (manifest.projectRoot === projectRoot && manifest.state !== "consumed") {
|
|
361
|
+
throw new Error(`Project checkout has an unfinished recovery snapshot at ${path.join(temporaryRoot, entry.name)}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function snapshotBranchTrees(projectRoot, branches, temporaryRoot) {
|
|
366
|
+
const resolvedProjectRoot = path.resolve(projectRoot);
|
|
367
|
+
const resolvedTemporaryRoot = path.resolve(temporaryRoot ?? os.tmpdir());
|
|
368
|
+
assertNoOutstandingSnapshotForProject(resolvedTemporaryRoot, resolvedProjectRoot);
|
|
369
|
+
const root = createProjectWorktreeSnapshotRoot(resolvedTemporaryRoot);
|
|
127
370
|
const paths = /* @__PURE__ */ new Map();
|
|
128
371
|
try {
|
|
129
|
-
|
|
130
|
-
const checkoutPath = branchPath(
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
372
|
+
const branchSnapshots = branches.map(({ branchName }) => {
|
|
373
|
+
const checkoutPath = branchPath(resolvedProjectRoot, branchName);
|
|
374
|
+
let snapshotPresent = false;
|
|
375
|
+
try {
|
|
376
|
+
fs.lstatSync(checkoutPath);
|
|
377
|
+
snapshotPresent = true;
|
|
378
|
+
} catch (error) {
|
|
379
|
+
if (error.code !== "ENOENT") throw error;
|
|
380
|
+
}
|
|
381
|
+
return { branchName, snapshotPresent };
|
|
382
|
+
});
|
|
383
|
+
let manifest = writeSnapshotManifest(root, {
|
|
384
|
+
version: PROJECT_WORKTREE_SNAPSHOT_MANIFEST_VERSION,
|
|
385
|
+
state: "building",
|
|
386
|
+
ownerProcessId: process.pid,
|
|
387
|
+
ownerSessionId: PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID,
|
|
388
|
+
projectRoot: resolvedProjectRoot,
|
|
389
|
+
branches: branchSnapshots,
|
|
390
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
391
|
+
});
|
|
392
|
+
for (const { branchName, snapshotPresent } of branchSnapshots) {
|
|
393
|
+
if (!snapshotPresent) continue;
|
|
394
|
+
const snapshotPath = fullSnapshotPath(root, branchName);
|
|
395
|
+
copyCheckoutTree(branchPath(resolvedProjectRoot, branchName), snapshotPath);
|
|
134
396
|
paths.set(branchName, snapshotPath);
|
|
135
397
|
}
|
|
136
|
-
|
|
398
|
+
const treesRoot = path.join(root, "trees");
|
|
399
|
+
if (fs.existsSync(treesRoot)) fsyncTree(treesRoot);
|
|
400
|
+
manifest = advanceSnapshotState(root, manifest, "prepared");
|
|
401
|
+
return { root, manifest, paths };
|
|
137
402
|
} catch (error) {
|
|
138
|
-
|
|
403
|
+
removeSnapshotStorage(root);
|
|
139
404
|
throw error;
|
|
140
405
|
}
|
|
141
406
|
}
|
|
407
|
+
function restoreProjectWorktreeSnapshot(snapshotRoot, manifest) {
|
|
408
|
+
const current = readSnapshotManifest(snapshotRoot);
|
|
409
|
+
assertSameSnapshotIdentity(manifest, current);
|
|
410
|
+
if (current.state !== "prepared") throw new Error(`Project worktree snapshot is ${current.state}, not prepared`);
|
|
411
|
+
for (const { branchName, snapshotPresent } of current.branches) {
|
|
412
|
+
const targetPath = branchPath(current.projectRoot, branchName);
|
|
413
|
+
assertNoSymlinkBranchPathComponents(current.projectRoot, targetPath);
|
|
414
|
+
if (!snapshotPresent) continue;
|
|
415
|
+
const sourcePath = fullSnapshotPath(snapshotRoot, branchName);
|
|
416
|
+
const stat = fs.lstatSync(sourcePath);
|
|
417
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
418
|
+
throw new Error(`Project worktree snapshot branch must be a real directory: ${branchName}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
for (const { branchName } of [...current.branches].sort((left, right) => right.branchName.length - left.branchName.length)) {
|
|
422
|
+
const targetPath = branchPath(current.projectRoot, branchName);
|
|
423
|
+
const targetParent = path.dirname(targetPath);
|
|
424
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
425
|
+
if (fs.existsSync(targetParent)) fsyncDirectoryChain(targetParent, current.projectRoot);
|
|
426
|
+
}
|
|
427
|
+
for (const { branchName, snapshotPresent } of current.branches) {
|
|
428
|
+
if (!snapshotPresent) continue;
|
|
429
|
+
const targetPath = branchPath(current.projectRoot, branchName);
|
|
430
|
+
copyCheckoutTree(fullSnapshotPath(snapshotRoot, branchName), targetPath);
|
|
431
|
+
fsyncTree(targetPath);
|
|
432
|
+
fsyncDirectoryChain(path.dirname(targetPath), current.projectRoot);
|
|
433
|
+
}
|
|
434
|
+
fs.mkdirSync(current.projectRoot, { recursive: true });
|
|
435
|
+
fsyncManagedProjectRootEntry(current.projectRoot);
|
|
436
|
+
}
|
|
437
|
+
function consumeAndRemoveProjectWorktreeSnapshot(snapshotRoot, manifest) {
|
|
438
|
+
const consumed = advanceSnapshotState(snapshotRoot, manifest, "consumed");
|
|
439
|
+
try {
|
|
440
|
+
assertSameSnapshotIdentity(manifest, consumed);
|
|
441
|
+
removeSnapshotStorage(snapshotRoot);
|
|
442
|
+
} catch {
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
function persistReconciledProjectAndConsumeSnapshot(snapshot, afterProjectTreeFsync) {
|
|
446
|
+
fsyncTree(snapshot.manifest.projectRoot);
|
|
447
|
+
fsyncManagedProjectRootEntry(snapshot.manifest.projectRoot);
|
|
448
|
+
afterProjectTreeFsync?.();
|
|
449
|
+
consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, snapshot.manifest);
|
|
450
|
+
}
|
|
451
|
+
function rollbackProjectWorktreeSnapshot(snapshot) {
|
|
452
|
+
const current = readSnapshotManifest(snapshot.root);
|
|
453
|
+
assertSameSnapshotIdentity(snapshot.manifest, current);
|
|
454
|
+
if (current.state === "prepared") {
|
|
455
|
+
restoreProjectWorktreeSnapshot(snapshot.root, current);
|
|
456
|
+
consumeAndRemoveProjectWorktreeSnapshot(snapshot.root, current);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (current.state === "consumed") {
|
|
460
|
+
try {
|
|
461
|
+
removeSnapshotStorage(snapshot.root);
|
|
462
|
+
} catch {
|
|
463
|
+
}
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
throw new Error(`Project worktree snapshot is unexpectedly ${current.state}`);
|
|
467
|
+
}
|
|
142
468
|
function isProcessAlive(processId) {
|
|
143
469
|
try {
|
|
144
470
|
process.kill(processId, 0);
|
|
@@ -147,35 +473,105 @@ function isProcessAlive(processId) {
|
|
|
147
473
|
return error.code !== "ESRCH";
|
|
148
474
|
}
|
|
149
475
|
}
|
|
150
|
-
function
|
|
476
|
+
function isPathInside(root, candidate) {
|
|
477
|
+
const relative = path.relative(path.resolve(root), path.resolve(candidate));
|
|
478
|
+
return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
|
|
479
|
+
}
|
|
480
|
+
function assertNoSymlinkPathComponents(root, candidate) {
|
|
481
|
+
const resolvedRoot = path.resolve(root);
|
|
482
|
+
const resolvedCandidate = path.resolve(candidate);
|
|
483
|
+
if (!isPathInside(resolvedRoot, resolvedCandidate)) throw new Error("Project worktree snapshot target escapes the projects root");
|
|
484
|
+
let current = resolvedRoot;
|
|
485
|
+
for (const component of ["", ...path.relative(resolvedRoot, resolvedCandidate).split(path.sep).filter(Boolean)]) {
|
|
486
|
+
if (component) current = path.join(current, component);
|
|
487
|
+
try {
|
|
488
|
+
if (fs.lstatSync(current).isSymbolicLink()) {
|
|
489
|
+
throw new Error(`Project worktree snapshot target contains a symbolic link: ${current}`);
|
|
490
|
+
}
|
|
491
|
+
} catch (error) {
|
|
492
|
+
const code = error.code;
|
|
493
|
+
if (code === "ENOENT" || code === "ENOTDIR") return;
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
function recoverStaleProjectWorktreeSnapshots(input) {
|
|
151
499
|
const temporaryRoot = path.resolve(input.temporaryRoot ?? os.tmpdir());
|
|
500
|
+
const projectsRoot = path.resolve(input.projectsRoot);
|
|
152
501
|
const processAlive = input.processAlive ?? isProcessAlive;
|
|
502
|
+
const currentProcessId = input.currentProcessId ?? process.pid;
|
|
503
|
+
const currentOwnerSessionId = input.currentOwnerSessionId ?? PROJECT_WORKTREE_SNAPSHOT_OWNER_SESSION_ID;
|
|
504
|
+
if (!Number.isSafeInteger(currentProcessId) || currentProcessId <= 0) throw new Error("Current snapshot recovery process ID is invalid");
|
|
505
|
+
if (!UUID_PATTERN.test(currentOwnerSessionId)) throw new Error("Current snapshot recovery owner session is invalid");
|
|
506
|
+
const restored = [];
|
|
153
507
|
const removed = [];
|
|
154
508
|
const failed = [];
|
|
155
509
|
let entries;
|
|
156
510
|
try {
|
|
511
|
+
assertRealDirectory(temporaryRoot, "Project worktree snapshot storage root");
|
|
157
512
|
entries = fs.readdirSync(temporaryRoot, { withFileTypes: true });
|
|
158
513
|
} catch (error) {
|
|
159
|
-
if (error.code === "ENOENT") return { removed, failed };
|
|
160
|
-
return { removed, failed: [{ path: temporaryRoot, error: error instanceof Error ? error.message : String(error) }] };
|
|
514
|
+
if (error.code === "ENOENT") return { restored, removed, failed };
|
|
515
|
+
return { restored, removed, failed: [{ path: temporaryRoot, error: error instanceof Error ? error.message : String(error) }] };
|
|
161
516
|
}
|
|
162
517
|
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
163
518
|
const match = PROJECT_WORKTREE_SNAPSHOT_NAME.exec(entry.name);
|
|
164
519
|
if (!match) continue;
|
|
165
520
|
const ownerProcessId = Number(match[1]);
|
|
166
|
-
if (!Number.isSafeInteger(ownerProcessId)
|
|
521
|
+
if (!Number.isSafeInteger(ownerProcessId)) continue;
|
|
167
522
|
const candidate = path.join(temporaryRoot, entry.name);
|
|
168
523
|
try {
|
|
169
524
|
const stat = fs.lstatSync(candidate);
|
|
170
525
|
if (!stat.isDirectory() || stat.isSymbolicLink()) continue;
|
|
171
|
-
|
|
172
|
-
|
|
526
|
+
const manifest = readSnapshotManifest(candidate);
|
|
527
|
+
if (manifest.ownerProcessId !== ownerProcessId) {
|
|
528
|
+
throw new Error("Project worktree snapshot owner does not match its directory name");
|
|
529
|
+
}
|
|
530
|
+
if (!isPathInside(projectsRoot, manifest.projectRoot)) {
|
|
531
|
+
throw new Error("Project worktree snapshot target is outside the configured projects root");
|
|
532
|
+
}
|
|
533
|
+
assertNoSymlinkPathComponents(projectsRoot, manifest.projectRoot);
|
|
534
|
+
const ownedByThisProcessSession = manifest.ownerProcessId === currentProcessId && manifest.ownerSessionId === currentOwnerSessionId;
|
|
535
|
+
if (ownedByThisProcessSession || ownerProcessId !== currentProcessId && processAlive(ownerProcessId)) continue;
|
|
536
|
+
if (manifest.state === "prepared") {
|
|
537
|
+
restoreProjectWorktreeSnapshot(candidate, manifest);
|
|
538
|
+
const consumed = advanceSnapshotState(candidate, manifest, "consumed");
|
|
539
|
+
removeSnapshotStorage(candidate);
|
|
540
|
+
assertSameSnapshotIdentity(manifest, consumed);
|
|
541
|
+
restored.push(candidate);
|
|
542
|
+
} else {
|
|
543
|
+
removeSnapshotStorage(candidate);
|
|
544
|
+
removed.push(candidate);
|
|
545
|
+
}
|
|
173
546
|
} catch (error) {
|
|
174
|
-
if (
|
|
547
|
+
if (ownerProcessId !== currentProcessId && processAlive(ownerProcessId)) continue;
|
|
548
|
+
if (error.code === "ENOENT") {
|
|
549
|
+
try {
|
|
550
|
+
fs.lstatSync(candidate);
|
|
551
|
+
} catch (candidateError) {
|
|
552
|
+
if (candidateError.code === "ENOENT") continue;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
175
555
|
failed.push({ path: candidate, error: error instanceof Error ? error.message : String(error) });
|
|
176
556
|
}
|
|
177
557
|
}
|
|
178
|
-
return { removed, failed };
|
|
558
|
+
return { restored, removed, failed };
|
|
559
|
+
}
|
|
560
|
+
function projectOriginBranchName(input) {
|
|
561
|
+
validateManagedBranchName(input.branchName);
|
|
562
|
+
validateManagedBranchName(input.primaryBranchName);
|
|
563
|
+
validateManagedBranchName(input.defaultBranch);
|
|
564
|
+
return input.branchName === input.primaryBranchName ? input.defaultBranch : input.branchName;
|
|
565
|
+
}
|
|
566
|
+
function configureProjectBranchTracking(checkoutPath, branchName, originBranchName) {
|
|
567
|
+
validateManagedBranchName(branchName);
|
|
568
|
+
validateManagedBranchName(originBranchName);
|
|
569
|
+
git(checkoutPath, ["config", "--local", `branch.${branchName}.remote`, "origin"], `configure origin for ${branchName}`);
|
|
570
|
+
git(
|
|
571
|
+
checkoutPath,
|
|
572
|
+
["config", "--local", `branch.${branchName}.merge`, `refs/heads/${originBranchName}`],
|
|
573
|
+
`configure upstream branch for ${branchName}`
|
|
574
|
+
);
|
|
179
575
|
}
|
|
180
576
|
function configureRepository(input) {
|
|
181
577
|
if (tryGit(input.primaryPath, ["remote", "get-url", "origin"])) {
|
|
@@ -207,6 +603,17 @@ function configureRepository(input) {
|
|
|
207
603
|
git(input.primaryPath, ["config", "--local", "user.name", name], "configure project Git user name");
|
|
208
604
|
git(input.primaryPath, ["config", "--local", "user.email", email], "configure project Git user email");
|
|
209
605
|
}
|
|
606
|
+
if (input.primaryBranchName && input.defaultBranch && input.branches) {
|
|
607
|
+
git(input.primaryPath, ["config", "--local", "push.default", "upstream"], "configure project push mapping");
|
|
608
|
+
for (const { branchName } of input.branches) {
|
|
609
|
+
const originBranchName = projectOriginBranchName({
|
|
610
|
+
branchName,
|
|
611
|
+
primaryBranchName: input.primaryBranchName,
|
|
612
|
+
defaultBranch: input.defaultBranch
|
|
613
|
+
});
|
|
614
|
+
configureProjectBranchTracking(input.primaryPath, branchName, originBranchName);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
210
617
|
}
|
|
211
618
|
function fetchProjectHeads(input) {
|
|
212
619
|
const originFetch = gitResult(input.primaryPath, [
|
|
@@ -256,6 +663,9 @@ function ensureCommitAvailable(input) {
|
|
|
256
663
|
function seedForBranch(input) {
|
|
257
664
|
const mirrorRef = `refs/r5d/mirror/${input.branch.branchName}`;
|
|
258
665
|
const mirrorHead = tryGit(input.primaryPath, ["show-ref", "--verify", "--quiet", mirrorRef]) ? git(input.primaryPath, ["rev-parse", mirrorRef], "resolve project mirror head") : null;
|
|
666
|
+
if (input.requireMirrorHead && !mirrorHead) {
|
|
667
|
+
throw new Error(`Exact hidden project mirror head is missing for ${input.branch.branchName}`);
|
|
668
|
+
}
|
|
259
669
|
const seed = mirrorHead ?? input.branch.baseCommitHash;
|
|
260
670
|
ensureCommitAvailable({ ...input, commitHash: seed });
|
|
261
671
|
return { seed, mirrorHead };
|
|
@@ -277,7 +687,12 @@ function initializeLinkedLayout(input) {
|
|
|
277
687
|
const seeds = /* @__PURE__ */ new Map();
|
|
278
688
|
const mirrorHeads = /* @__PURE__ */ new Map();
|
|
279
689
|
for (const branch of input.branches) {
|
|
280
|
-
const resolved = seedForBranch({
|
|
690
|
+
const resolved = seedForBranch({
|
|
691
|
+
...input,
|
|
692
|
+
primaryPath,
|
|
693
|
+
branch,
|
|
694
|
+
requireMirrorHead: input.exactMirrorBranches?.has(branch.branchName)
|
|
695
|
+
});
|
|
281
696
|
seeds.set(branch.branchName, resolved.seed);
|
|
282
697
|
mirrorHeads.set(branch.branchName, resolved.mirrorHead);
|
|
283
698
|
}
|
|
@@ -303,7 +718,12 @@ function reconcileLinkedLayout(input) {
|
|
|
303
718
|
const mirrorHeads = /* @__PURE__ */ new Map();
|
|
304
719
|
for (const branch of input.branches) {
|
|
305
720
|
const checkoutPath = branchPath(input.projectRoot, branch.branchName);
|
|
306
|
-
const resolved = seedForBranch({
|
|
721
|
+
const resolved = seedForBranch({
|
|
722
|
+
...input,
|
|
723
|
+
primaryPath,
|
|
724
|
+
branch,
|
|
725
|
+
requireMirrorHead: input.exactMirrorBranches?.has(branch.branchName)
|
|
726
|
+
});
|
|
307
727
|
mirrorHeads.set(branch.branchName, resolved.mirrorHead);
|
|
308
728
|
if (branch.branchName === input.primaryBranchName || commonGitDirectory(checkoutPath) === commonGitDirectory(primaryPath)) {
|
|
309
729
|
preserveHead(checkoutPath);
|
|
@@ -317,8 +737,9 @@ function reconcileLinkedLayout(input) {
|
|
|
317
737
|
}
|
|
318
738
|
return mirrorHeads;
|
|
319
739
|
}
|
|
320
|
-
function aheadBehind(checkoutPath, branchName) {
|
|
321
|
-
const
|
|
740
|
+
function aheadBehind(checkoutPath, branchName, primaryBranchName, defaultBranch) {
|
|
741
|
+
const originBranchName = projectOriginBranchName({ branchName, primaryBranchName, defaultBranch });
|
|
742
|
+
const originRef = `refs/remotes/origin/${originBranchName}`;
|
|
322
743
|
if (!tryGit(checkoutPath, ["show-ref", "--verify", "--quiet", originRef])) return { ahead: null, behind: null };
|
|
323
744
|
const output = git(checkoutPath, ["rev-list", "--left-right", "--count", `${originRef}...HEAD`], "measure project origin divergence");
|
|
324
745
|
const [behind, ahead] = output.split(/\s+/).map(Number);
|
|
@@ -326,11 +747,12 @@ function aheadBehind(checkoutPath, branchName) {
|
|
|
326
747
|
}
|
|
327
748
|
function ensureProjectWorktrees(input) {
|
|
328
749
|
const projectRoot = path.resolve(input.projectRoot);
|
|
750
|
+
const defaultBranch = input.defaultBranch ?? input.primaryBranchName;
|
|
329
751
|
assertNonOverlappingBranches(input.branches);
|
|
330
752
|
if (!input.branches.some(({ branchName }) => branchName === input.primaryBranchName)) {
|
|
331
753
|
throw new Error(`Primary project branch ${input.primaryBranchName} is missing from the workspace configuration`);
|
|
332
754
|
}
|
|
333
|
-
const snapshot = snapshotBranchTrees(projectRoot, input.branches);
|
|
755
|
+
const snapshot = snapshotBranchTrees(projectRoot, input.branches, input.snapshotRoot);
|
|
334
756
|
try {
|
|
335
757
|
fs.mkdirSync(projectRoot, { recursive: true });
|
|
336
758
|
const linked = hasCompatibleLinkedProjectWorktreeLayout({
|
|
@@ -338,7 +760,7 @@ function ensureProjectWorktrees(input) {
|
|
|
338
760
|
primaryBranchName: input.primaryBranchName,
|
|
339
761
|
branches: input.branches
|
|
340
762
|
});
|
|
341
|
-
const mirrorHeads = linked ? reconcileLinkedLayout({ ...input, projectRoot }) : initializeLinkedLayout({ ...input, projectRoot });
|
|
763
|
+
const mirrorHeads = linked ? reconcileLinkedLayout({ ...input, projectRoot, defaultBranch }) : initializeLinkedLayout({ ...input, projectRoot, defaultBranch });
|
|
342
764
|
for (const branch of input.branches) {
|
|
343
765
|
const snapshotPath = snapshot.paths.get(branch.branchName);
|
|
344
766
|
if (!snapshotPath) continue;
|
|
@@ -349,9 +771,9 @@ function ensureProjectWorktrees(input) {
|
|
|
349
771
|
deletionMode: "git"
|
|
350
772
|
});
|
|
351
773
|
}
|
|
352
|
-
|
|
774
|
+
const states = input.branches.map((branch) => {
|
|
353
775
|
const checkoutPath = branchPath(projectRoot, branch.branchName);
|
|
354
|
-
const divergence = aheadBehind(checkoutPath, branch.branchName);
|
|
776
|
+
const divergence = aheadBehind(checkoutPath, branch.branchName, input.primaryBranchName, defaultBranch);
|
|
355
777
|
return {
|
|
356
778
|
branchName: branch.branchName,
|
|
357
779
|
branchPath: checkoutPath,
|
|
@@ -360,8 +782,18 @@ function ensureProjectWorktrees(input) {
|
|
|
360
782
|
...divergence
|
|
361
783
|
};
|
|
362
784
|
}).sort((left, right) => left.branchName.localeCompare(right.branchName));
|
|
363
|
-
|
|
364
|
-
|
|
785
|
+
persistReconciledProjectAndConsumeSnapshot(snapshot);
|
|
786
|
+
return states;
|
|
787
|
+
} catch (error) {
|
|
788
|
+
try {
|
|
789
|
+
rollbackProjectWorktreeSnapshot(snapshot);
|
|
790
|
+
} catch (recoveryError) {
|
|
791
|
+
throw new Error(
|
|
792
|
+
`Project worktree reconciliation failed and its snapshot could not be fully restored; recovery state is retained at ${snapshot.root}`,
|
|
793
|
+
{ cause: new AggregateError([error, recoveryError]) }
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
throw error;
|
|
365
797
|
}
|
|
366
798
|
}
|
|
367
799
|
function projectWorktreeOperationInProgress(checkoutPath) {
|
|
@@ -379,6 +811,8 @@ function createLinkedProjectBranch(input) {
|
|
|
379
811
|
if (input.sourceBranchName === input.branchName) throw new Error("Source and target branch names must differ");
|
|
380
812
|
const sourcePath = branchPath(input.projectRoot, input.sourceBranchName);
|
|
381
813
|
const targetPath = branchPath(input.projectRoot, input.branchName);
|
|
814
|
+
assertNoSymlinkBranchPathComponents(input.projectRoot, sourcePath);
|
|
815
|
+
assertNoSymlinkBranchPathComponents(input.projectRoot, targetPath);
|
|
382
816
|
if (!commonGitDirectory(sourcePath)) throw new Error(`Source worktree ${input.sourceBranchName} is unavailable`);
|
|
383
817
|
if (projectWorktreeOperationInProgress(sourcePath)) {
|
|
384
818
|
throw new Error(`Source worktree ${input.sourceBranchName} has an in-progress Git operation`);
|
|
@@ -394,25 +828,84 @@ function createLinkedProjectBranch(input) {
|
|
|
394
828
|
});
|
|
395
829
|
if (overlappingWorktree) throw new Error(`Project branch folder overlaps linked worktree ${overlappingWorktree}`);
|
|
396
830
|
const sourceHead = git(sourcePath, ["rev-parse", "HEAD"], "resolve source branch head");
|
|
397
|
-
|
|
831
|
+
let branchCreated = false;
|
|
398
832
|
try {
|
|
399
|
-
mirrorWorkingTree({ sourceRoot: sourcePath, targetRoot: snapshotRoot, sourceMode: "git", deletionMode: "all" });
|
|
400
833
|
git(sourcePath, ["branch", input.branchName, sourceHead], `create project branch ${input.branchName}`);
|
|
834
|
+
branchCreated = true;
|
|
401
835
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
}
|
|
836
|
+
assertNoSymlinkBranchPathComponents(input.projectRoot, targetPath);
|
|
837
|
+
git(
|
|
838
|
+
sourcePath,
|
|
839
|
+
["worktree", "add", "--force", "--no-checkout", targetPath, input.branchName],
|
|
840
|
+
`create linked worktree ${input.branchName}`
|
|
841
|
+
);
|
|
842
|
+
configureProjectBranchTracking(targetPath, input.branchName, input.branchName);
|
|
843
|
+
assertNoSymlinkBranchPathComponents(input.projectRoot, targetPath);
|
|
844
|
+
git(targetPath, ["read-tree", sourceHead], `initialize linked worktree index ${input.branchName}`);
|
|
845
|
+
mirrorWorkingTree({ sourceRoot: sourcePath, targetRoot: targetPath, sourceMode: "git", deletionMode: "git" });
|
|
411
846
|
return { branchPath: targetPath, baseCommitHash: sourceHead };
|
|
412
|
-
}
|
|
413
|
-
|
|
847
|
+
} catch (error) {
|
|
848
|
+
if (branchCreated) {
|
|
849
|
+
let removeError;
|
|
850
|
+
try {
|
|
851
|
+
fs.rmSync(targetPath, { recursive: true, force: true });
|
|
852
|
+
} catch (cleanupError) {
|
|
853
|
+
removeError = cleanupError;
|
|
854
|
+
}
|
|
855
|
+
const prune = gitResult(sourcePath, ["worktree", "prune"]);
|
|
856
|
+
const deleteBranch = gitResult(sourcePath, ["branch", "-D", input.branchName]);
|
|
857
|
+
const cleanupFailures = [];
|
|
858
|
+
try {
|
|
859
|
+
fs.lstatSync(targetPath);
|
|
860
|
+
cleanupFailures.push(`target path remains at ${targetPath}`);
|
|
861
|
+
} catch (verificationError) {
|
|
862
|
+
const code = verificationError.code;
|
|
863
|
+
if (code !== "ENOENT" && code !== "ENOTDIR") {
|
|
864
|
+
cleanupFailures.push(
|
|
865
|
+
`could not verify target removal${removeError ? ` after ${removeError instanceof Error ? removeError.message : String(removeError)}` : ""}: ${verificationError instanceof Error ? verificationError.message : String(verificationError)}`
|
|
866
|
+
);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
const worktrees = gitResult(sourcePath, ["worktree", "list", "--porcelain"]);
|
|
870
|
+
if (worktrees.exitCode !== 0) {
|
|
871
|
+
cleanupFailures.push(
|
|
872
|
+
`could not verify linked-worktree removal: ${worktrees.stderr || worktrees.stdout || `git exited ${worktrees.exitCode}`}`
|
|
873
|
+
);
|
|
874
|
+
} else {
|
|
875
|
+
const targetRegistered = worktrees.stdout.split(/\r?\n/).some((line) => line.startsWith("worktree ") && path.resolve(line.slice("worktree ".length)) === path.resolve(targetPath));
|
|
876
|
+
if (targetRegistered) {
|
|
877
|
+
cleanupFailures.push(
|
|
878
|
+
`linked worktree remains registered at ${targetPath}${prune.exitCode === 0 ? "" : ` (${prune.stderr || prune.stdout || `git exited ${prune.exitCode}`})`}`
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const branchRef = gitResult(sourcePath, ["show-ref", "--verify", "--quiet", `refs/heads/${input.branchName}`]);
|
|
883
|
+
if (branchRef.exitCode === 0) {
|
|
884
|
+
cleanupFailures.push(
|
|
885
|
+
`branch ref remains${deleteBranch.exitCode === 0 ? "" : ` (${deleteBranch.stderr || deleteBranch.stdout || `git exited ${deleteBranch.exitCode}`})`}`
|
|
886
|
+
);
|
|
887
|
+
} else if (branchRef.exitCode !== 1) {
|
|
888
|
+
cleanupFailures.push(
|
|
889
|
+
`could not verify branch ref removal: ${branchRef.stderr || branchRef.stdout || `git exited ${branchRef.exitCode}`}`
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
if (cleanupFailures.length > 0) {
|
|
893
|
+
throw new ProjectBranchCreationRollbackIncompleteError(input.branchName, cleanupFailures, error);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
throw error;
|
|
414
897
|
}
|
|
415
898
|
}
|
|
899
|
+
function createOrRetryLinkedProjectBranch(input) {
|
|
900
|
+
if (input.pendingRetry) {
|
|
901
|
+
deleteLinkedProjectBranch({
|
|
902
|
+
projectRoot: input.projectRoot,
|
|
903
|
+
primaryBranchName: input.primaryBranchName,
|
|
904
|
+
branchName: input.branchName
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
return createLinkedProjectBranch(input);
|
|
908
|
+
}
|
|
416
909
|
function deleteLinkedProjectBranch(input) {
|
|
417
910
|
validateManagedBranchName(input.primaryBranchName);
|
|
418
911
|
validateManagedBranchName(input.branchName);
|
|
@@ -472,7 +965,13 @@ function pushProjectMirrorHeads(input) {
|
|
|
472
965
|
for (const branchName of [...input.branchNames].sort()) {
|
|
473
966
|
if (input.onlyBranches && !input.onlyBranches.has(branchName)) continue;
|
|
474
967
|
const checkoutPath = branchPath(input.projectRoot, branchName);
|
|
475
|
-
const head = git(checkoutPath, ["rev-parse", "HEAD"], `resolve mirror head for ${branchName}`);
|
|
968
|
+
const head = input.publicationHeads ? input.publicationHeads.get(branchName) : git(checkoutPath, ["rev-parse", "HEAD"], `resolve mirror head for ${branchName}`);
|
|
969
|
+
if (!head || !/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(head)) {
|
|
970
|
+
throw new Error(`Missing exact publication head for project branch ${branchName}`);
|
|
971
|
+
}
|
|
972
|
+
if (!tryGit(checkoutPath, ["cat-file", "-e", `${head}^{commit}`])) {
|
|
973
|
+
throw new Error(`Exact publication head ${head} is unavailable for project branch ${branchName}`);
|
|
974
|
+
}
|
|
476
975
|
if (projectWorktreeOperationInProgress(checkoutPath)) {
|
|
477
976
|
results.push({ branchName, head, pushed: false, reason: "git_operation_in_progress" });
|
|
478
977
|
continue;
|
|
@@ -484,7 +983,7 @@ function pushProjectMirrorHeads(input) {
|
|
|
484
983
|
"push",
|
|
485
984
|
"--no-recurse-submodules",
|
|
486
985
|
input.mirrorUrl,
|
|
487
|
-
|
|
986
|
+
`+${head}:refs/heads/${branchName}`
|
|
488
987
|
],
|
|
489
988
|
`push hidden project mirror for ${branchName}`
|
|
490
989
|
);
|
|
@@ -492,7 +991,13 @@ function pushProjectMirrorHeads(input) {
|
|
|
492
991
|
}
|
|
493
992
|
return results;
|
|
494
993
|
}
|
|
495
|
-
function
|
|
994
|
+
function assertProjectMirrorHeadsPushed(results) {
|
|
995
|
+
const skipped = results.find((result) => !result.pushed);
|
|
996
|
+
if (skipped) {
|
|
997
|
+
throw new Error(`Hidden project mirror publication skipped ${skipped.branchName}${skipped.reason ? ` (${skipped.reason})` : ""}`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
function observeProjectMirrorHeads(input) {
|
|
496
1001
|
const primaryPath = branchPath(input.projectRoot, input.primaryBranchName);
|
|
497
1002
|
const fetch = gitResult(primaryPath, [
|
|
498
1003
|
...gitTransportSecurityArgs(input.mirrorUrl, input.credentialHelper, input.credentialUsername),
|
|
@@ -509,30 +1014,102 @@ function fastForwardProjectHeadsFromMirror(input) {
|
|
|
509
1014
|
const previousHead = git(checkoutPath, ["rev-parse", "HEAD"], `resolve local project head ${branchName}`);
|
|
510
1015
|
const mirrorRef = `refs/r5d/mirror/${branchName}`;
|
|
511
1016
|
const mirrorHead = tryGit(primaryPath, ["show-ref", "--verify", "--quiet", mirrorRef]) ? git(primaryPath, ["rev-parse", mirrorRef], `resolve refreshed mirror head ${branchName}`) : null;
|
|
512
|
-
|
|
513
|
-
|
|
1017
|
+
const shouldMove = Boolean(
|
|
1018
|
+
mirrorHead && mirrorHead !== previousHead && (input.allowNonFastForward || tryGit(checkoutPath, ["merge-base", "--is-ancestor", previousHead, mirrorHead]))
|
|
1019
|
+
);
|
|
1020
|
+
return {
|
|
1021
|
+
branchName,
|
|
1022
|
+
previousHead,
|
|
1023
|
+
mirrorHead,
|
|
1024
|
+
mode: input.allowNonFastForward ? "reseed" : "fast_forward",
|
|
1025
|
+
shouldMove
|
|
1026
|
+
};
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
function applyObservedProjectMirrorHeads(input) {
|
|
1030
|
+
return [...input.observations].sort((left, right) => left.branchName.localeCompare(right.branchName)).map((observation) => {
|
|
1031
|
+
const { branchName, mirrorHead } = observation;
|
|
1032
|
+
const checkoutPath = branchPath(input.projectRoot, branchName);
|
|
1033
|
+
const currentHead = git(checkoutPath, ["rev-parse", "HEAD"], `resolve local project head ${branchName}`);
|
|
1034
|
+
if (!observation.shouldMove || !mirrorHead || currentHead === mirrorHead || currentHead !== observation.previousHead) {
|
|
1035
|
+
return { branchName, previousHead: currentHead, mirrorHead, moved: false };
|
|
1036
|
+
}
|
|
1037
|
+
if (!tryGit(checkoutPath, ["cat-file", "-e", `${mirrorHead}^{commit}`])) {
|
|
1038
|
+
throw new Error(`Observed hidden mirror head is no longer available for ${branchName}: ${mirrorHead}`);
|
|
514
1039
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
1040
|
+
const mutationAllowed = () => !projectWorktreeOperationInProgress(checkoutPath) && input.branchMutationAllowed?.(branchName, checkoutPath) !== false;
|
|
1041
|
+
if (!mutationAllowed()) {
|
|
1042
|
+
return { branchName, previousHead: currentHead, mirrorHead, moved: false };
|
|
1043
|
+
}
|
|
1044
|
+
if (observation.mode === "reseed") {
|
|
1045
|
+
preserveHead(checkoutPath);
|
|
1046
|
+
if (!mutationAllowed()) return { branchName, previousHead: currentHead, mirrorHead, moved: false };
|
|
1047
|
+
}
|
|
1048
|
+
const action = observation.mode === "reseed" ? "reseed" : "fast-forward";
|
|
1049
|
+
git(checkoutPath, ["read-tree", "--reset", mirrorHead], `${action} project index ${branchName}`);
|
|
1050
|
+
git(checkoutPath, ["update-ref", `refs/heads/${branchName}`, mirrorHead, currentHead], `${action} project branch ${branchName}`);
|
|
1051
|
+
return { branchName, previousHead: currentHead, mirrorHead, moved: true };
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
function updateProjectHeadsFromMirror(input) {
|
|
1055
|
+
const observations = observeProjectMirrorHeads(input);
|
|
1056
|
+
return applyObservedProjectMirrorHeads({
|
|
1057
|
+
projectRoot: input.projectRoot,
|
|
1058
|
+
primaryBranchName: input.primaryBranchName,
|
|
1059
|
+
observations,
|
|
1060
|
+
branchMutationAllowed: input.branchMutationAllowed
|
|
518
1061
|
});
|
|
519
1062
|
}
|
|
1063
|
+
function fastForwardProjectHeadsFromMirror(input) {
|
|
1064
|
+
return updateProjectHeadsFromMirror({ ...input, allowNonFastForward: false }).map(({ moved, ...state }) => ({
|
|
1065
|
+
...state,
|
|
1066
|
+
fastForwarded: moved
|
|
1067
|
+
}));
|
|
1068
|
+
}
|
|
1069
|
+
function reseedProjectHeadsFromMirror(input) {
|
|
1070
|
+
return updateProjectHeadsFromMirror({ ...input, allowNonFastForward: true }).map(({ moved, ...state }) => ({
|
|
1071
|
+
...state,
|
|
1072
|
+
reseeded: moved
|
|
1073
|
+
}));
|
|
1074
|
+
}
|
|
520
1075
|
const projectWorktreesTestHarness = {
|
|
521
1076
|
commandArgs: gitCommandArgs,
|
|
522
|
-
configureRepository
|
|
1077
|
+
configureRepository,
|
|
1078
|
+
createPreparedSnapshot(input) {
|
|
1079
|
+
return { root: snapshotBranchTrees(input.projectRoot, input.branches, input.temporaryRoot).root };
|
|
1080
|
+
},
|
|
1081
|
+
consumeSnapshot(snapshotRoot) {
|
|
1082
|
+
const manifest = readSnapshotManifest(snapshotRoot);
|
|
1083
|
+
advanceSnapshotState(snapshotRoot, manifest, "consumed");
|
|
1084
|
+
},
|
|
1085
|
+
persistProjectAndConsumeSnapshot(snapshotRoot, afterProjectTreeFsync) {
|
|
1086
|
+
const manifest = readSnapshotManifest(snapshotRoot);
|
|
1087
|
+
persistReconciledProjectAndConsumeSnapshot({ root: snapshotRoot, manifest, paths: /* @__PURE__ */ new Map() }, afterProjectTreeFsync);
|
|
1088
|
+
},
|
|
1089
|
+
snapshotState(snapshotRoot) {
|
|
1090
|
+
return readSnapshotManifest(snapshotRoot).state;
|
|
1091
|
+
}
|
|
523
1092
|
};
|
|
524
1093
|
export {
|
|
525
1094
|
PROJECT_WORKTREE_SNAPSHOT_PREFIX,
|
|
526
|
-
|
|
1095
|
+
ProjectBranchCreationRollbackIncompleteError,
|
|
1096
|
+
applyObservedProjectMirrorHeads,
|
|
1097
|
+
assertProjectMirrorHeadsPushed,
|
|
527
1098
|
createLinkedProjectBranch,
|
|
1099
|
+
createOrRetryLinkedProjectBranch,
|
|
528
1100
|
deleteLinkedProjectBranch,
|
|
529
1101
|
deleteProjectMirrorBranch,
|
|
530
1102
|
ensureProjectWorktrees,
|
|
531
1103
|
fastForwardProjectHeadsFromMirror,
|
|
532
1104
|
hasLinkedProjectWorktreeLayout,
|
|
1105
|
+
observeProjectMirrorHeads,
|
|
1106
|
+
projectBranchMayExistAfterCreateFailure,
|
|
1107
|
+
projectOriginBranchName,
|
|
533
1108
|
projectWorktreeConfigurationFingerprint,
|
|
534
1109
|
projectWorktreeOperationInProgress,
|
|
535
1110
|
projectWorktreesTestHarness,
|
|
536
1111
|
pushProjectMirrorHeads,
|
|
537
|
-
|
|
1112
|
+
recoverStaleProjectWorktreeSnapshots,
|
|
1113
|
+
removeProjectWorktrees,
|
|
1114
|
+
reseedProjectHeadsFromMirror
|
|
538
1115
|
};
|