@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.
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 +641 -56
  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 +191 -34
  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 +1028 -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 +632 -55
  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 +190 -34
  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 +1023 -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 +7 -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
@@ -4,6 +4,104 @@ const BRANCH_NAME_MAX_LENGTH = 45;
4
4
  const BRANCH_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
5
5
  const PROJECT_SEGMENT_PATTERN = /^[a-z0-9._-]+$/;
6
6
  const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
7
+ const INTERNAL_PROJECT_PATH_SEGMENTS = /* @__PURE__ */ new Set([".r5d-retired-checkouts"]);
8
+ function lstatIfExists(targetPath) {
9
+ try {
10
+ return fs.lstatSync(targetPath);
11
+ } catch (error) {
12
+ if (error.code === "ENOENT") return null;
13
+ throw error;
14
+ }
15
+ }
16
+ function fsyncDirectory(directoryPath) {
17
+ const descriptor = fs.openSync(directoryPath, "r");
18
+ try {
19
+ fs.fsyncSync(descriptor);
20
+ } finally {
21
+ fs.closeSync(descriptor);
22
+ }
23
+ }
24
+ function fsyncCreatedDirectoryChain(directoryPath, preexistingAncestorPath) {
25
+ let current = path.resolve(directoryPath);
26
+ const ancestor = path.resolve(preexistingAncestorPath);
27
+ const relative = path.relative(ancestor, current);
28
+ if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
29
+ throw new Error(`Managed root escaped its durability ancestor: ${current}`);
30
+ }
31
+ while (true) {
32
+ fsyncDirectory(current);
33
+ if (current === ancestor) return;
34
+ current = path.dirname(current);
35
+ }
36
+ }
37
+ function canonicalizeManagedRoot(root) {
38
+ const resolvedRoot = path.resolve(root.rootPath);
39
+ let preexistingAncestor = resolvedRoot;
40
+ while (!lstatIfExists(preexistingAncestor)) {
41
+ const parent = path.dirname(preexistingAncestor);
42
+ if (parent === preexistingAncestor) {
43
+ throw new Error(`Could not find an existing ancestor for ${root.label} (${resolvedRoot})`);
44
+ }
45
+ preexistingAncestor = parent;
46
+ }
47
+ let canonicalPreexistingAncestor;
48
+ try {
49
+ canonicalPreexistingAncestor = fs.realpathSync(preexistingAncestor);
50
+ if (!fs.statSync(canonicalPreexistingAncestor).isDirectory()) {
51
+ throw new Error("the nearest existing ancestor is not a directory");
52
+ }
53
+ } catch (error) {
54
+ throw new Error(`Could not validate the existing ancestor of ${root.label} (${resolvedRoot})`, { cause: error });
55
+ }
56
+ try {
57
+ fs.mkdirSync(resolvedRoot, { recursive: true });
58
+ } catch (error) {
59
+ throw new Error(`Could not prepare ${root.label} (${resolvedRoot}) for overlap validation`, { cause: error });
60
+ }
61
+ const rootStats = fs.statSync(resolvedRoot, { bigint: true });
62
+ if (!rootStats.isDirectory()) {
63
+ throw new Error(`Managed root is not a directory: ${root.label} (${resolvedRoot})`);
64
+ }
65
+ const canonicalRoot = fs.realpathSync(resolvedRoot);
66
+ fsyncCreatedDirectoryChain(canonicalRoot, canonicalPreexistingAncestor);
67
+ return {
68
+ ...root,
69
+ rootPath: canonicalRoot,
70
+ identity: { device: rootStats.dev, inode: rootStats.ino }
71
+ };
72
+ }
73
+ function pathContains(root, candidate) {
74
+ const relative = path.relative(root, candidate);
75
+ return relative === "" || !relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative);
76
+ }
77
+ function identitiesMatch(left, right) {
78
+ return left.device === right.device && left.inode === right.inode;
79
+ }
80
+ function pathOrFilesystemContains(root, candidate) {
81
+ if (pathContains(root.rootPath, candidate.rootPath) || identitiesMatch(root.identity, candidate.identity)) {
82
+ return true;
83
+ }
84
+ let ancestor = path.dirname(candidate.rootPath);
85
+ while (true) {
86
+ const stats = fs.statSync(ancestor, { bigint: true });
87
+ if (identitiesMatch(root.identity, { device: stats.dev, inode: stats.ino })) return true;
88
+ const parent = path.dirname(ancestor);
89
+ if (parent === ancestor) return false;
90
+ ancestor = parent;
91
+ }
92
+ }
93
+ function assertDisjointManagedRoots(roots) {
94
+ const canonical = roots.map(canonicalizeManagedRoot);
95
+ for (let leftIndex = 0; leftIndex < canonical.length; leftIndex += 1) {
96
+ const left = canonical[leftIndex];
97
+ for (let rightIndex = leftIndex + 1; rightIndex < canonical.length; rightIndex += 1) {
98
+ const right = canonical[rightIndex];
99
+ if (pathOrFilesystemContains(left, right) || pathOrFilesystemContains(right, left)) {
100
+ throw new Error(`Managed roots overlap: ${left.label} (${left.rootPath}) and ${right.label} (${right.rootPath}) must be disjoint`);
101
+ }
102
+ }
103
+ }
104
+ }
7
105
  function validateManagedBranchName(branchName) {
8
106
  if (typeof branchName !== "string" || branchName.length === 0) {
9
107
  throw new Error("Branch name is required");
@@ -25,7 +123,7 @@ function validateCheckoutPathSegments(value) {
25
123
  }
26
124
  const segments = value;
27
125
  for (const segment of segments) {
28
- if (typeof segment !== "string" || !PROJECT_SEGMENT_PATTERN.test(segment) || segment !== segment.toLowerCase() || segment === "." || segment === ".." || segment === ".git" || segment.endsWith(".") || WINDOWS_RESERVED_PATH_SEGMENT.test(segment)) {
126
+ if (typeof segment !== "string" || !PROJECT_SEGMENT_PATTERN.test(segment) || segment !== segment.toLowerCase() || segment === "." || segment === ".." || segment === ".git" || INTERNAL_PROJECT_PATH_SEGMENTS.has(segment) || segment.endsWith(".") || WINDOWS_RESERVED_PATH_SEGMENT.test(segment)) {
29
127
  throw new Error(`Invalid checkout path segment from server: ${String(segment)}`);
30
128
  }
31
129
  }
@@ -67,6 +165,7 @@ function managedBranchPath(projectsRoot, checkoutPathSegments, branchName) {
67
165
  export {
68
166
  BRANCH_NAME_MAX_LENGTH,
69
167
  BRANCH_NAME_PATTERN,
168
+ assertDisjointManagedRoots,
70
169
  assertPathInside,
71
170
  managedBranchPath,
72
171
  managedProjectRoot,
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "@ricsam/r5d-worker",
3
- "version": "0.0.78",
3
+ "version": "0.0.79",
4
4
  "type": "module"
5
5
  }
@@ -11,10 +11,39 @@ const PROJECT_WORKSPACE_STATE_FILE = "project-workspace-state.json";
11
11
  const PROJECT_WORKSPACE_STATE_VERSION = 1;
12
12
  const PROJECT_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13
13
  const GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
14
+ class ProjectWorkspacePendingBranchPathChangeError extends Error {
15
+ projectId;
16
+ branchName;
17
+ currentCheckoutPathSegments;
18
+ incomingCheckoutPathSegments;
19
+ constructor(input) {
20
+ super(`Cannot move project ${input.projectId} while creator-local branch ${input.branchName} is pending publication`);
21
+ this.name = "ProjectWorkspacePendingBranchPathChangeError";
22
+ this.projectId = input.projectId;
23
+ this.branchName = input.branchName;
24
+ this.currentCheckoutPathSegments = [...input.currentCheckoutPathSegments];
25
+ this.incomingCheckoutPathSegments = [...input.incomingCheckoutPathSegments];
26
+ }
27
+ }
28
+ class ProjectWorkspaceCheckoutPathCollisionError extends Error {
29
+ projectId;
30
+ occupyingProjectId;
31
+ checkoutPathSegments;
32
+ constructor(input) {
33
+ super(
34
+ `Cannot assign checkout path ${input.checkoutPathSegments.join("/")} to project ${input.projectId} while project ${input.occupyingProjectId} still owns a durable checkout or deletion tombstone there`
35
+ );
36
+ this.name = "ProjectWorkspaceCheckoutPathCollisionError";
37
+ this.projectId = input.projectId;
38
+ this.occupyingProjectId = input.occupyingProjectId;
39
+ this.checkoutPathSegments = [...input.checkoutPathSegments];
40
+ }
41
+ }
14
42
  const EMPTY_STATE = {
15
43
  version: PROJECT_WORKSPACE_STATE_VERSION,
16
44
  desiredProjects: [],
17
45
  locallyPendingCreatedBranches: [],
46
+ enabledRepositoryTransitions: [],
18
47
  tombstones: []
19
48
  };
20
49
  function isRecord(value) {
@@ -85,12 +114,36 @@ function normalizePendingCreatedBranches(value) {
85
114
  }
86
115
  const projectId = validateProjectId(entry.projectId);
87
116
  validateManagedBranchName(entry.branchName);
88
- return { projectId, branchName: entry.branchName };
117
+ if (entry.branchId !== void 0 && (typeof entry.branchId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(entry.branchId))) {
118
+ throw new Error("Invalid durable locally pending project branch id");
119
+ }
120
+ return { ...entry.branchId ? { branchId: entry.branchId } : {}, projectId, branchName: entry.branchName };
89
121
  });
90
122
  const keys = pending.map(({ projectId, branchName }) => `${projectId}\0${branchName}`);
91
123
  if (new Set(keys).size !== keys.length) throw new Error("Duplicate durable locally pending project branch");
92
124
  return pending.sort((left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName));
93
125
  }
126
+ function normalizePreserveOnlyBranches(value) {
127
+ const normalized = normalizePendingCreatedBranches(value);
128
+ if (normalized.some(({ branchId }) => !branchId)) throw new Error("Preserve-only branch is missing its durable branch id");
129
+ return normalized;
130
+ }
131
+ function normalizeEnabledRepositoryTransitions(value) {
132
+ if (value === void 0) return [];
133
+ if (!Array.isArray(value)) throw new Error("Invalid durable enabled repository transitions");
134
+ const transitions = value.map((entry) => {
135
+ if (!isRecord(entry)) throw new Error("Invalid durable enabled repository transition");
136
+ const projectId = validateProjectId(entry.projectId);
137
+ if (entry.transitionId !== null && (typeof entry.transitionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(entry.transitionId))) {
138
+ throw new Error("Invalid durable enabled repository transition id");
139
+ }
140
+ return { projectId, transitionId: entry.transitionId };
141
+ });
142
+ if (new Set(transitions.map(({ projectId }) => projectId)).size !== transitions.length) {
143
+ throw new Error("Duplicate durable enabled repository transition project");
144
+ }
145
+ return transitions.sort((left, right) => left.projectId.localeCompare(right.projectId));
146
+ }
94
147
  function tombstoneBranchNames(tombstone) {
95
148
  return tombstone.kind === "branch" ? [tombstone.branchName] : tombstone.branchNames;
96
149
  }
@@ -164,6 +217,7 @@ function normalizeState(value) {
164
217
  }
165
218
  const desiredProjects = normalizeDesiredProjects(value.desiredProjects);
166
219
  const locallyPendingCreatedBranches = normalizePendingCreatedBranches(value.locallyPendingCreatedBranches);
220
+ const enabledRepositoryTransitions = normalizeEnabledRepositoryTransitions(value.enabledRepositoryTransitions);
167
221
  const desiredById = new Map(desiredProjects.map((project) => [project.projectId, project]));
168
222
  for (const pending of locallyPendingCreatedBranches) {
169
223
  const project = desiredById.get(pending.projectId);
@@ -179,6 +233,7 @@ function normalizeState(value) {
179
233
  version: PROJECT_WORKSPACE_STATE_VERSION,
180
234
  desiredProjects,
181
235
  locallyPendingCreatedBranches,
236
+ enabledRepositoryTransitions,
182
237
  tombstones: tombstones.sort((left, right) => left.id.localeCompare(right.id))
183
238
  };
184
239
  }
@@ -217,8 +272,41 @@ function fsyncDirectory(directory) {
217
272
  if (descriptor !== void 0) fs.closeSync(descriptor);
218
273
  }
219
274
  }
220
- function writeStateAtomically(stateRoot, statePath, state) {
221
- fs.mkdirSync(stateRoot, { recursive: true, mode: 448 });
275
+ function lstatIfExists(targetPath) {
276
+ try {
277
+ return fs.lstatSync(targetPath);
278
+ } catch (error) {
279
+ if (error.code === "ENOENT") return null;
280
+ throw error;
281
+ }
282
+ }
283
+ function ensureDurableStateRoot(stateRoot) {
284
+ const resolvedStateRoot = path.resolve(stateRoot);
285
+ let existingAncestor = resolvedStateRoot;
286
+ while (!lstatIfExists(existingAncestor)) {
287
+ const parent = path.dirname(existingAncestor);
288
+ if (parent === existingAncestor) throw new Error(`Project workspace state has no existing ancestor: ${resolvedStateRoot}`);
289
+ existingAncestor = parent;
290
+ }
291
+ const ancestorStat = lstatIfExists(existingAncestor);
292
+ if (!ancestorStat || ancestorStat.isSymbolicLink() || !ancestorStat.isDirectory()) {
293
+ throw new Error(`Project workspace state must descend from a real directory: ${resolvedStateRoot}`);
294
+ }
295
+ fs.mkdirSync(resolvedStateRoot, { recursive: true, mode: 448 });
296
+ let current = resolvedStateRoot;
297
+ while (true) {
298
+ const stat = lstatIfExists(current);
299
+ if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
300
+ throw new Error(`Project workspace state path must be a real directory: ${current}`);
301
+ }
302
+ fsyncDirectory(current);
303
+ if (current === existingAncestor) break;
304
+ current = path.dirname(current);
305
+ }
306
+ }
307
+ function writeStateAtomically(stateRoot, statePath, state, afterStateRootFsync) {
308
+ ensureDurableStateRoot(stateRoot);
309
+ afterStateRootFsync?.();
222
310
  const temporaryPath = path.join(stateRoot, `.${PROJECT_WORKSPACE_STATE_FILE}.${process.pid}.${randomUUID()}.tmp`);
223
311
  let descriptor;
224
312
  try {
@@ -326,7 +414,12 @@ function mergePendingCreatedBranches(...groups) {
326
414
  const byKey = /* @__PURE__ */ new Map();
327
415
  for (const pending of groups.flat()) {
328
416
  const normalized = normalizePendingCreatedBranches([pending])[0];
329
- byKey.set(pendingCreatedBranchKey(normalized), normalized);
417
+ const key = pendingCreatedBranchKey(normalized);
418
+ const existing = byKey.get(key);
419
+ if (existing?.branchId && normalized.branchId && existing.branchId !== normalized.branchId) {
420
+ throw new Error(`Pending local branch ${normalized.projectId}/${normalized.branchName} changed durable branch id`);
421
+ }
422
+ byKey.set(key, normalized.branchId ? normalized : existing ?? normalized);
330
423
  }
331
424
  return [...byKey.values()].sort(
332
425
  (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
@@ -431,6 +524,7 @@ function stateView(projectsRoot, state) {
431
524
  desiredProjects: cloneDesiredProjects(state.desiredProjects),
432
525
  effectiveDesiredProjects: effectiveProjects,
433
526
  locallyPendingCreatedBranches: state.locallyPendingCreatedBranches.map((pending) => ({ ...pending })),
527
+ enabledRepositoryTransitions: state.enabledRepositoryTransitions.map((transition) => ({ ...transition })),
434
528
  tombstones: cloneTombstones(state.tombstones),
435
529
  pendingTreeDeletions,
436
530
  pendingMirrorRefDeletions
@@ -605,14 +699,51 @@ class ProjectWorkspaceStateStore {
605
699
  reconcile(input) {
606
700
  const previous = readState(this.statePath);
607
701
  const authoritativeDesiredProjects = normalizeDesiredProjects(input.desiredProjects);
702
+ for (const project of authoritativeDesiredProjects) {
703
+ const occupyingProjectId = previous.desiredProjects.find(
704
+ (candidate) => candidate.projectId !== project.projectId && samePathSegments(candidate.checkoutPathSegments, project.checkoutPathSegments)
705
+ )?.projectId ?? previous.tombstones.find(
706
+ (candidate) => candidate.projectId !== project.projectId && samePathSegments(candidate.checkoutPathSegments, project.checkoutPathSegments)
707
+ )?.projectId;
708
+ if (occupyingProjectId) {
709
+ throw new ProjectWorkspaceCheckoutPathCollisionError({
710
+ projectId: project.projectId,
711
+ occupyingProjectId,
712
+ checkoutPathSegments: project.checkoutPathSegments
713
+ });
714
+ }
715
+ }
608
716
  const authoritativeProjectIds = new Set(authoritativeDesiredProjects.map(({ projectId }) => projectId));
717
+ const preserveOnlyBranches = normalizePreserveOnlyBranches(input.preserveOnlyBranches ?? []);
718
+ for (const preserved of preserveOnlyBranches) {
719
+ const project = authoritativeDesiredProjects.find(({ projectId }) => projectId === preserved.projectId);
720
+ if (!project) {
721
+ throw new Error(`Preserve-only branch ${preserved.projectId}/${preserved.branchName} has no desired project`);
722
+ }
723
+ if (project.branches.some(({ branchName }) => branchName === preserved.branchName)) {
724
+ throw new Error(`Preserve-only branch ${preserved.projectId}/${preserved.branchName} is already active`);
725
+ }
726
+ }
609
727
  const locallyPendingCreatedBranches = mergePendingCreatedBranches(
610
728
  previous.locallyPendingCreatedBranches,
611
729
  input.locallyPendingCreatedBranches ?? []
612
730
  ).filter(
613
731
  (pending) => authoritativeProjectIds.has(pending.projectId) && !desiredProjectHasBranch(authoritativeDesiredProjects, pending)
614
732
  );
733
+ for (const pending of locallyPendingCreatedBranches) {
734
+ const currentProject = previous.desiredProjects.find(({ projectId }) => projectId === pending.projectId);
735
+ const incomingProject = authoritativeDesiredProjects.find(({ projectId }) => projectId === pending.projectId);
736
+ if (currentProject && incomingProject && !samePathSegments(currentProject.checkoutPathSegments, incomingProject.checkoutPathSegments)) {
737
+ throw new ProjectWorkspacePendingBranchPathChangeError({
738
+ projectId: pending.projectId,
739
+ branchName: pending.branchName,
740
+ currentCheckoutPathSegments: currentProject.checkoutPathSegments,
741
+ incomingCheckoutPathSegments: incomingProject.checkoutPathSegments
742
+ });
743
+ }
744
+ }
615
745
  const desiredProjects = cloneDesiredProjects(authoritativeDesiredProjects);
746
+ applyPendingCreatedBranches(desiredProjects, preserveOnlyBranches);
616
747
  applyPendingCreatedBranches(desiredProjects, locallyPendingCreatedBranches);
617
748
  const desiredById = new Map(desiredProjects.map((project) => [project.projectId, project]));
618
749
  const tombstones = cloneTombstones(previous.tombstones);
@@ -638,16 +769,36 @@ class ProjectWorkspaceStateStore {
638
769
  version: PROJECT_WORKSPACE_STATE_VERSION,
639
770
  desiredProjects,
640
771
  locallyPendingCreatedBranches,
772
+ enabledRepositoryTransitions: previous.enabledRepositoryTransitions.filter(({ projectId }) => authoritativeProjectIds.has(projectId)),
641
773
  tombstones
642
774
  });
643
775
  writeStateAtomically(this.stateRoot, this.statePath, nextState);
644
776
  return stateView(this.projectsRoot, nextState);
645
777
  }
778
+ /** Record a successful enabled reseed without letting disabled config consume it. */
779
+ recordEnabledRepositoryTransition(input) {
780
+ const [transition] = normalizeEnabledRepositoryTransitions([input]);
781
+ const state = readState(this.statePath);
782
+ if (!state.desiredProjects.some(({ projectId }) => projectId === transition.projectId)) {
783
+ throw new Error(`Cannot record an enabled repository transition for missing project ${transition.projectId}`);
784
+ }
785
+ state.enabledRepositoryTransitions = state.enabledRepositoryTransitions.filter(({ projectId }) => projectId !== transition.projectId);
786
+ state.enabledRepositoryTransitions.push(transition);
787
+ state.enabledRepositoryTransitions.sort((left, right) => left.projectId.localeCompare(right.projectId));
788
+ writeStateAtomically(this.stateRoot, this.statePath, state);
789
+ return stateView(this.projectsRoot, state);
790
+ }
646
791
  /** Persist the create intent before invoking Git so a crash cannot orphan an untracked worktree. */
647
792
  recordPendingCreatedBranch(input) {
648
793
  const [pending] = normalizePendingCreatedBranches([input]);
649
794
  const state = readState(this.statePath);
650
- if (state.locallyPendingCreatedBranches.some((candidate) => pendingCreatedBranchKey(candidate) === pendingCreatedBranchKey(pending))) {
795
+ const existingPending = state.locallyPendingCreatedBranches.find(
796
+ (candidate) => pendingCreatedBranchKey(candidate) === pendingCreatedBranchKey(pending)
797
+ );
798
+ if (existingPending) {
799
+ if (existingPending.branchId !== pending.branchId) {
800
+ throw new Error(`Pending project branch ${pending.branchName} belongs to another durable branch incarnation`);
801
+ }
651
802
  return stateView(this.projectsRoot, state);
652
803
  }
653
804
  const project = state.desiredProjects.find(({ projectId }) => projectId === pending.projectId);
@@ -664,6 +815,23 @@ class ProjectWorkspaceStateStore {
664
815
  writeStateAtomically(this.stateRoot, this.statePath, state);
665
816
  return stateView(this.projectsRoot, state);
666
817
  }
818
+ /** Roll back an intent only when Git failed before creating its branch. */
819
+ rollbackPendingCreatedBranch(input) {
820
+ const [pending] = normalizePendingCreatedBranches([input]);
821
+ const state = readState(this.statePath);
822
+ const key = pendingCreatedBranchKey(pending);
823
+ const existing = state.locallyPendingCreatedBranches.find((candidate) => pendingCreatedBranchKey(candidate) === key);
824
+ if (!existing || pending.branchId && existing.branchId !== pending.branchId) {
825
+ return stateView(this.projectsRoot, state);
826
+ }
827
+ state.locallyPendingCreatedBranches = state.locallyPendingCreatedBranches.filter(
828
+ (candidate) => pendingCreatedBranchKey(candidate) !== key
829
+ );
830
+ const project = state.desiredProjects.find(({ projectId }) => projectId === pending.projectId);
831
+ if (project) project.branches = project.branches.filter(({ branchName }) => branchName !== pending.branchName);
832
+ writeStateAtomically(this.stateRoot, this.statePath, state);
833
+ return stateView(this.projectsRoot, state);
834
+ }
667
835
  /** Clear a create intent after an authoritative desired config contains it. */
668
836
  clearPendingCreatedBranch(input) {
669
837
  const [pending] = normalizePendingCreatedBranches([input]);
@@ -737,9 +905,17 @@ class ProjectWorkspaceStateStore {
737
905
  return stateView(this.projectsRoot, state);
738
906
  }
739
907
  }
908
+ const projectWorkspaceStateTestHarness = {
909
+ writeEmptyStateAtDurableRootBoundary(stateRoot, afterStateRootFsync) {
910
+ writeStateAtomically(stateRoot, path.join(stateRoot, PROJECT_WORKSPACE_STATE_FILE), EMPTY_STATE, afterStateRootFsync);
911
+ }
912
+ };
740
913
  export {
741
914
  PROJECT_WORKSPACE_STATE_FILE,
915
+ ProjectWorkspaceCheckoutPathCollisionError,
916
+ ProjectWorkspacePendingBranchPathChangeError,
742
917
  ProjectWorkspaceStateStore,
743
918
  discoverStaleOuterProjectWorkspaceEntries,
919
+ projectWorkspaceStateTestHarness,
744
920
  pruneAuthoritativelyDesiredBranchDeletions
745
921
  };