@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.
Files changed (58) hide show
  1. package/README.md +6 -0
  2. package/dist/cjs/main.cjs +1550 -335
  3. package/dist/cjs/managed-paths.cjs +101 -1
  4. package/dist/cjs/package.json +1 -1
  5. package/dist/cjs/project-workspace-state.cjs +184 -5
  6. package/dist/cjs/project-worktrees.cjs +676 -58
  7. package/dist/cjs/registry-auth.cjs +310 -0
  8. package/dist/cjs/repository-transition-policy.cjs +49 -0
  9. package/dist/cjs/supervisor.cjs +62 -11
  10. package/dist/cjs/working-tree-mirror.cjs +196 -36
  11. package/dist/cjs/workspace-automatic-sync-policy.cjs +69 -0
  12. package/dist/cjs/workspace-branch-incarnation-policy.cjs +37 -0
  13. package/dist/cjs/workspace-command-sync-policy.cjs +18 -0
  14. package/dist/cjs/workspace-git-sync.cjs +1037 -54
  15. package/dist/cjs/workspace-mount-boundary.cjs +71 -0
  16. package/dist/cjs/workspace-path-move.cjs +195 -0
  17. package/dist/cjs/workspace-preserve-only-policy.cjs +36 -0
  18. package/dist/cjs/workspace-project-config-policy.cjs +46 -0
  19. package/dist/cjs/workspace-publication-evidence.cjs +46 -0
  20. package/dist/mjs/main.mjs +1584 -341
  21. package/dist/mjs/managed-paths.mjs +100 -1
  22. package/dist/mjs/package.json +1 -1
  23. package/dist/mjs/project-workspace-state.mjs +181 -5
  24. package/dist/mjs/project-worktrees.mjs +667 -57
  25. package/dist/mjs/registry-auth.mjs +269 -0
  26. package/dist/mjs/repository-transition-policy.mjs +22 -0
  27. package/dist/mjs/supervisor.mjs +61 -11
  28. package/dist/mjs/working-tree-mirror.mjs +195 -36
  29. package/dist/mjs/workspace-automatic-sync-policy.mjs +40 -0
  30. package/dist/mjs/workspace-branch-incarnation-policy.mjs +13 -0
  31. package/dist/mjs/workspace-command-sync-policy.mjs +17 -0
  32. package/dist/mjs/workspace-git-sync.mjs +1032 -54
  33. package/dist/mjs/workspace-mount-boundary.mjs +37 -0
  34. package/dist/mjs/workspace-path-move.mjs +160 -0
  35. package/dist/mjs/workspace-preserve-only-policy.mjs +11 -0
  36. package/dist/mjs/workspace-project-config-policy.mjs +21 -0
  37. package/dist/mjs/workspace-publication-evidence.mjs +21 -0
  38. package/dist/types/credential-authority-lock-fixture.d.ts +1 -0
  39. package/dist/types/main.d.ts +175 -1
  40. package/dist/types/managed-paths.d.ts +11 -0
  41. package/dist/types/project-workspace-state.d.ts +45 -1
  42. package/dist/types/project-worktrees.d.ts +119 -2
  43. package/dist/types/registry-auth.d.ts +47 -0
  44. package/dist/types/repository-transition-policy.d.ts +26 -0
  45. package/dist/types/supervisor-daemonized-fixture.d.ts +1 -0
  46. package/dist/types/supervisor-signal-fixture.d.ts +1 -0
  47. package/dist/types/supervisor.d.ts +6 -4
  48. package/dist/types/working-tree-mirror.d.ts +14 -0
  49. package/dist/types/workspace-automatic-sync-policy.d.ts +37 -0
  50. package/dist/types/workspace-branch-incarnation-policy.d.ts +14 -0
  51. package/dist/types/workspace-command-sync-policy.d.ts +9 -0
  52. package/dist/types/workspace-git-sync.d.ts +33 -3
  53. package/dist/types/workspace-mount-boundary.d.ts +10 -0
  54. package/dist/types/workspace-path-move.d.ts +21 -0
  55. package/dist/types/workspace-preserve-only-policy.d.ts +15 -0
  56. package/dist/types/workspace-project-config-policy.d.ts +34 -0
  57. package/dist/types/workspace-publication-evidence.d.ts +17 -0
  58. 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
- branches: [...input.branches].sort((left, right) => left.branchName.localeCompare(right.branchName)).map(({ branchName, sourceBranchName, baseCommitHash }) => ({
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 createProjectWorktreeSnapshotRoot() {
123
- return fs.mkdtempSync(path.join(os.tmpdir(), `${PROJECT_WORKTREE_SNAPSHOT_PREFIX}${process.pid}-`));
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 root = createProjectWorktreeSnapshotRoot();
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
- for (const { branchName } of branches) {
130
- const checkoutPath = branchPath(projectRoot, branchName);
131
- if (!fs.existsSync(checkoutPath)) continue;
132
- const snapshotPath = path.join(root, ...branchName.split("/"));
133
- mirrorWorkingTree({ sourceRoot: checkoutPath, targetRoot: snapshotPath, sourceMode: "all", deletionMode: "all" });
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
- return { root, paths };
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
- fs.rmSync(root, { recursive: true, force: true });
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 cleanupStaleProjectWorktreeSnapshots(input = {}) {
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) || processAlive(ownerProcessId)) continue;
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
- fs.rmSync(candidate, { recursive: true, force: true });
172
- removed.push(candidate);
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 (error.code === "ENOENT") continue;
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({ ...input, primaryPath, branch });
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({ ...input, primaryPath, branch });
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 originRef = `refs/remotes/origin/${branchName}`;
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 snapshot = snapshotBranchTrees(projectRoot, input.branches);
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
- const mirrorHeads = linked ? reconcileLinkedLayout({ ...input, projectRoot }) : initializeLinkedLayout({ ...input, projectRoot });
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
- const snapshotPath = snapshot.paths.get(branch.branchName);
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
- return input.branches.map((branch) => {
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
- } finally {
364
- fs.rmSync(snapshot.root, { recursive: true, force: true });
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
- const snapshotRoot = createProjectWorktreeSnapshotRoot();
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
- try {
403
- git(sourcePath, ["worktree", "add", "--force", targetPath, input.branchName], `create linked worktree ${input.branchName}`);
404
- mirrorWorkingTree({ sourceRoot: snapshotRoot, targetRoot: targetPath, sourceMode: "all", deletionMode: "git" });
405
- } catch (error) {
406
- fs.rmSync(targetPath, { recursive: true, force: true });
407
- tryGit(sourcePath, ["worktree", "prune"]);
408
- tryGit(sourcePath, ["branch", "-D", input.branchName]);
409
- throw error;
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
- } finally {
413
- fs.rmSync(snapshotRoot, { recursive: true, force: true });
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
- `+HEAD:refs/heads/${branchName}`
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 fastForwardProjectHeadsFromMirror(input) {
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
- if (!mirrorHead || mirrorHead === previousHead || projectWorktreeOperationInProgress(checkoutPath) || !tryGit(checkoutPath, ["merge-base", "--is-ancestor", previousHead, mirrorHead])) {
513
- return { branchName, previousHead, mirrorHead, fastForwarded: false };
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
- git(checkoutPath, ["update-ref", `refs/heads/${branchName}`, mirrorHead, previousHead], `fast-forward project branch ${branchName}`);
516
- git(checkoutPath, ["reset", "--mixed", mirrorHead], `refresh project index ${branchName}`);
517
- return { branchName, previousHead, mirrorHead, fastForwarded: true };
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
- cleanupStaleProjectWorktreeSnapshots,
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
- removeProjectWorktrees
1145
+ recoverStaleProjectWorktreeSnapshots,
1146
+ removeProjectWorktrees,
1147
+ reseedProjectHeadsFromMirror
538
1148
  };