@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
@@ -30,6 +30,7 @@ var managed_paths_exports = {};
30
30
  __export(managed_paths_exports, {
31
31
  BRANCH_NAME_MAX_LENGTH: () => BRANCH_NAME_MAX_LENGTH,
32
32
  BRANCH_NAME_PATTERN: () => BRANCH_NAME_PATTERN,
33
+ assertDisjointManagedRoots: () => assertDisjointManagedRoots,
33
34
  assertPathInside: () => assertPathInside,
34
35
  managedBranchPath: () => managedBranchPath,
35
36
  managedProjectRoot: () => managedProjectRoot,
@@ -43,6 +44,104 @@ const BRANCH_NAME_MAX_LENGTH = 45;
43
44
  const BRANCH_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[a-z0-9]+(?:-[a-z0-9]+)*)*$/;
44
45
  const PROJECT_SEGMENT_PATTERN = /^[a-z0-9._-]+$/;
45
46
  const WINDOWS_RESERVED_PATH_SEGMENT = /^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i;
47
+ const INTERNAL_PROJECT_PATH_SEGMENTS = /* @__PURE__ */ new Set([".r5d-retired-checkouts"]);
48
+ function lstatIfExists(targetPath) {
49
+ try {
50
+ return import_node_fs.default.lstatSync(targetPath);
51
+ } catch (error) {
52
+ if (error.code === "ENOENT") return null;
53
+ throw error;
54
+ }
55
+ }
56
+ function fsyncDirectory(directoryPath) {
57
+ const descriptor = import_node_fs.default.openSync(directoryPath, "r");
58
+ try {
59
+ import_node_fs.default.fsyncSync(descriptor);
60
+ } finally {
61
+ import_node_fs.default.closeSync(descriptor);
62
+ }
63
+ }
64
+ function fsyncCreatedDirectoryChain(directoryPath, preexistingAncestorPath) {
65
+ let current = import_node_path.default.resolve(directoryPath);
66
+ const ancestor = import_node_path.default.resolve(preexistingAncestorPath);
67
+ const relative = import_node_path.default.relative(ancestor, current);
68
+ if (relative === ".." || relative.startsWith(`..${import_node_path.default.sep}`) || import_node_path.default.isAbsolute(relative)) {
69
+ throw new Error(`Managed root escaped its durability ancestor: ${current}`);
70
+ }
71
+ while (true) {
72
+ fsyncDirectory(current);
73
+ if (current === ancestor) return;
74
+ current = import_node_path.default.dirname(current);
75
+ }
76
+ }
77
+ function canonicalizeManagedRoot(root) {
78
+ const resolvedRoot = import_node_path.default.resolve(root.rootPath);
79
+ let preexistingAncestor = resolvedRoot;
80
+ while (!lstatIfExists(preexistingAncestor)) {
81
+ const parent = import_node_path.default.dirname(preexistingAncestor);
82
+ if (parent === preexistingAncestor) {
83
+ throw new Error(`Could not find an existing ancestor for ${root.label} (${resolvedRoot})`);
84
+ }
85
+ preexistingAncestor = parent;
86
+ }
87
+ let canonicalPreexistingAncestor;
88
+ try {
89
+ canonicalPreexistingAncestor = import_node_fs.default.realpathSync(preexistingAncestor);
90
+ if (!import_node_fs.default.statSync(canonicalPreexistingAncestor).isDirectory()) {
91
+ throw new Error("the nearest existing ancestor is not a directory");
92
+ }
93
+ } catch (error) {
94
+ throw new Error(`Could not validate the existing ancestor of ${root.label} (${resolvedRoot})`, { cause: error });
95
+ }
96
+ try {
97
+ import_node_fs.default.mkdirSync(resolvedRoot, { recursive: true });
98
+ } catch (error) {
99
+ throw new Error(`Could not prepare ${root.label} (${resolvedRoot}) for overlap validation`, { cause: error });
100
+ }
101
+ const rootStats = import_node_fs.default.statSync(resolvedRoot, { bigint: true });
102
+ if (!rootStats.isDirectory()) {
103
+ throw new Error(`Managed root is not a directory: ${root.label} (${resolvedRoot})`);
104
+ }
105
+ const canonicalRoot = import_node_fs.default.realpathSync(resolvedRoot);
106
+ fsyncCreatedDirectoryChain(canonicalRoot, canonicalPreexistingAncestor);
107
+ return {
108
+ ...root,
109
+ rootPath: canonicalRoot,
110
+ identity: { device: rootStats.dev, inode: rootStats.ino }
111
+ };
112
+ }
113
+ function pathContains(root, candidate) {
114
+ const relative = import_node_path.default.relative(root, candidate);
115
+ return relative === "" || !relative.startsWith(`..${import_node_path.default.sep}`) && relative !== ".." && !import_node_path.default.isAbsolute(relative);
116
+ }
117
+ function identitiesMatch(left, right) {
118
+ return left.device === right.device && left.inode === right.inode;
119
+ }
120
+ function pathOrFilesystemContains(root, candidate) {
121
+ if (pathContains(root.rootPath, candidate.rootPath) || identitiesMatch(root.identity, candidate.identity)) {
122
+ return true;
123
+ }
124
+ let ancestor = import_node_path.default.dirname(candidate.rootPath);
125
+ while (true) {
126
+ const stats = import_node_fs.default.statSync(ancestor, { bigint: true });
127
+ if (identitiesMatch(root.identity, { device: stats.dev, inode: stats.ino })) return true;
128
+ const parent = import_node_path.default.dirname(ancestor);
129
+ if (parent === ancestor) return false;
130
+ ancestor = parent;
131
+ }
132
+ }
133
+ function assertDisjointManagedRoots(roots) {
134
+ const canonical = roots.map(canonicalizeManagedRoot);
135
+ for (let leftIndex = 0; leftIndex < canonical.length; leftIndex += 1) {
136
+ const left = canonical[leftIndex];
137
+ for (let rightIndex = leftIndex + 1; rightIndex < canonical.length; rightIndex += 1) {
138
+ const right = canonical[rightIndex];
139
+ if (pathOrFilesystemContains(left, right) || pathOrFilesystemContains(right, left)) {
140
+ throw new Error(`Managed roots overlap: ${left.label} (${left.rootPath}) and ${right.label} (${right.rootPath}) must be disjoint`);
141
+ }
142
+ }
143
+ }
144
+ }
46
145
  function validateManagedBranchName(branchName) {
47
146
  if (typeof branchName !== "string" || branchName.length === 0) {
48
147
  throw new Error("Branch name is required");
@@ -64,7 +163,7 @@ function validateCheckoutPathSegments(value) {
64
163
  }
65
164
  const segments = value;
66
165
  for (const segment of segments) {
67
- if (typeof segment !== "string" || !PROJECT_SEGMENT_PATTERN.test(segment) || segment !== segment.toLowerCase() || segment === "." || segment === ".." || segment === ".git" || segment.endsWith(".") || WINDOWS_RESERVED_PATH_SEGMENT.test(segment)) {
166
+ 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)) {
68
167
  throw new Error(`Invalid checkout path segment from server: ${String(segment)}`);
69
168
  }
70
169
  }
@@ -107,6 +206,7 @@ function managedBranchPath(projectsRoot, checkoutPathSegments, branchName) {
107
206
  0 && (module.exports = {
108
207
  BRANCH_NAME_MAX_LENGTH,
109
208
  BRANCH_NAME_PATTERN,
209
+ assertDisjointManagedRoots,
110
210
  assertPathInside,
111
211
  managedBranchPath,
112
212
  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": "commonjs"
5
5
  }
@@ -29,8 +29,11 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
29
29
  var project_workspace_state_exports = {};
30
30
  __export(project_workspace_state_exports, {
31
31
  PROJECT_WORKSPACE_STATE_FILE: () => PROJECT_WORKSPACE_STATE_FILE,
32
+ ProjectWorkspaceCheckoutPathCollisionError: () => ProjectWorkspaceCheckoutPathCollisionError,
33
+ ProjectWorkspacePendingBranchPathChangeError: () => ProjectWorkspacePendingBranchPathChangeError,
32
34
  ProjectWorkspaceStateStore: () => ProjectWorkspaceStateStore,
33
35
  discoverStaleOuterProjectWorkspaceEntries: () => discoverStaleOuterProjectWorkspaceEntries,
36
+ projectWorkspaceStateTestHarness: () => projectWorkspaceStateTestHarness,
34
37
  pruneAuthoritativelyDesiredBranchDeletions: () => pruneAuthoritativelyDesiredBranchDeletions
35
38
  });
36
39
  module.exports = __toCommonJS(project_workspace_state_exports);
@@ -42,10 +45,39 @@ const PROJECT_WORKSPACE_STATE_FILE = "project-workspace-state.json";
42
45
  const PROJECT_WORKSPACE_STATE_VERSION = 1;
43
46
  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;
44
47
  const GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
48
+ class ProjectWorkspacePendingBranchPathChangeError extends Error {
49
+ projectId;
50
+ branchName;
51
+ currentCheckoutPathSegments;
52
+ incomingCheckoutPathSegments;
53
+ constructor(input) {
54
+ super(`Cannot move project ${input.projectId} while creator-local branch ${input.branchName} is pending publication`);
55
+ this.name = "ProjectWorkspacePendingBranchPathChangeError";
56
+ this.projectId = input.projectId;
57
+ this.branchName = input.branchName;
58
+ this.currentCheckoutPathSegments = [...input.currentCheckoutPathSegments];
59
+ this.incomingCheckoutPathSegments = [...input.incomingCheckoutPathSegments];
60
+ }
61
+ }
62
+ class ProjectWorkspaceCheckoutPathCollisionError extends Error {
63
+ projectId;
64
+ occupyingProjectId;
65
+ checkoutPathSegments;
66
+ constructor(input) {
67
+ super(
68
+ `Cannot assign checkout path ${input.checkoutPathSegments.join("/")} to project ${input.projectId} while project ${input.occupyingProjectId} still owns a durable checkout or deletion tombstone there`
69
+ );
70
+ this.name = "ProjectWorkspaceCheckoutPathCollisionError";
71
+ this.projectId = input.projectId;
72
+ this.occupyingProjectId = input.occupyingProjectId;
73
+ this.checkoutPathSegments = [...input.checkoutPathSegments];
74
+ }
75
+ }
45
76
  const EMPTY_STATE = {
46
77
  version: PROJECT_WORKSPACE_STATE_VERSION,
47
78
  desiredProjects: [],
48
79
  locallyPendingCreatedBranches: [],
80
+ enabledRepositoryTransitions: [],
49
81
  tombstones: []
50
82
  };
51
83
  function isRecord(value) {
@@ -116,12 +148,36 @@ function normalizePendingCreatedBranches(value) {
116
148
  }
117
149
  const projectId = validateProjectId(entry.projectId);
118
150
  (0, import_managed_paths.validateManagedBranchName)(entry.branchName);
119
- return { projectId, branchName: entry.branchName };
151
+ if (entry.branchId !== void 0 && (typeof entry.branchId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(entry.branchId))) {
152
+ throw new Error("Invalid durable locally pending project branch id");
153
+ }
154
+ return { ...entry.branchId ? { branchId: entry.branchId } : {}, projectId, branchName: entry.branchName };
120
155
  });
121
156
  const keys = pending.map(({ projectId, branchName }) => `${projectId}\0${branchName}`);
122
157
  if (new Set(keys).size !== keys.length) throw new Error("Duplicate durable locally pending project branch");
123
158
  return pending.sort((left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName));
124
159
  }
160
+ function normalizePreserveOnlyBranches(value) {
161
+ const normalized = normalizePendingCreatedBranches(value);
162
+ if (normalized.some(({ branchId }) => !branchId)) throw new Error("Preserve-only branch is missing its durable branch id");
163
+ return normalized;
164
+ }
165
+ function normalizeEnabledRepositoryTransitions(value) {
166
+ if (value === void 0) return [];
167
+ if (!Array.isArray(value)) throw new Error("Invalid durable enabled repository transitions");
168
+ const transitions = value.map((entry) => {
169
+ if (!isRecord(entry)) throw new Error("Invalid durable enabled repository transition");
170
+ const projectId = validateProjectId(entry.projectId);
171
+ if (entry.transitionId !== null && (typeof entry.transitionId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(entry.transitionId))) {
172
+ throw new Error("Invalid durable enabled repository transition id");
173
+ }
174
+ return { projectId, transitionId: entry.transitionId };
175
+ });
176
+ if (new Set(transitions.map(({ projectId }) => projectId)).size !== transitions.length) {
177
+ throw new Error("Duplicate durable enabled repository transition project");
178
+ }
179
+ return transitions.sort((left, right) => left.projectId.localeCompare(right.projectId));
180
+ }
125
181
  function tombstoneBranchNames(tombstone) {
126
182
  return tombstone.kind === "branch" ? [tombstone.branchName] : tombstone.branchNames;
127
183
  }
@@ -195,6 +251,7 @@ function normalizeState(value) {
195
251
  }
196
252
  const desiredProjects = normalizeDesiredProjects(value.desiredProjects);
197
253
  const locallyPendingCreatedBranches = normalizePendingCreatedBranches(value.locallyPendingCreatedBranches);
254
+ const enabledRepositoryTransitions = normalizeEnabledRepositoryTransitions(value.enabledRepositoryTransitions);
198
255
  const desiredById = new Map(desiredProjects.map((project) => [project.projectId, project]));
199
256
  for (const pending of locallyPendingCreatedBranches) {
200
257
  const project = desiredById.get(pending.projectId);
@@ -210,6 +267,7 @@ function normalizeState(value) {
210
267
  version: PROJECT_WORKSPACE_STATE_VERSION,
211
268
  desiredProjects,
212
269
  locallyPendingCreatedBranches,
270
+ enabledRepositoryTransitions,
213
271
  tombstones: tombstones.sort((left, right) => left.id.localeCompare(right.id))
214
272
  };
215
273
  }
@@ -248,8 +306,41 @@ function fsyncDirectory(directory) {
248
306
  if (descriptor !== void 0) import_node_fs.default.closeSync(descriptor);
249
307
  }
250
308
  }
251
- function writeStateAtomically(stateRoot, statePath, state) {
252
- import_node_fs.default.mkdirSync(stateRoot, { recursive: true, mode: 448 });
309
+ function lstatIfExists(targetPath) {
310
+ try {
311
+ return import_node_fs.default.lstatSync(targetPath);
312
+ } catch (error) {
313
+ if (error.code === "ENOENT") return null;
314
+ throw error;
315
+ }
316
+ }
317
+ function ensureDurableStateRoot(stateRoot) {
318
+ const resolvedStateRoot = import_node_path.default.resolve(stateRoot);
319
+ let existingAncestor = resolvedStateRoot;
320
+ while (!lstatIfExists(existingAncestor)) {
321
+ const parent = import_node_path.default.dirname(existingAncestor);
322
+ if (parent === existingAncestor) throw new Error(`Project workspace state has no existing ancestor: ${resolvedStateRoot}`);
323
+ existingAncestor = parent;
324
+ }
325
+ const ancestorStat = lstatIfExists(existingAncestor);
326
+ if (!ancestorStat || ancestorStat.isSymbolicLink() || !ancestorStat.isDirectory()) {
327
+ throw new Error(`Project workspace state must descend from a real directory: ${resolvedStateRoot}`);
328
+ }
329
+ import_node_fs.default.mkdirSync(resolvedStateRoot, { recursive: true, mode: 448 });
330
+ let current = resolvedStateRoot;
331
+ while (true) {
332
+ const stat = lstatIfExists(current);
333
+ if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) {
334
+ throw new Error(`Project workspace state path must be a real directory: ${current}`);
335
+ }
336
+ fsyncDirectory(current);
337
+ if (current === existingAncestor) break;
338
+ current = import_node_path.default.dirname(current);
339
+ }
340
+ }
341
+ function writeStateAtomically(stateRoot, statePath, state, afterStateRootFsync) {
342
+ ensureDurableStateRoot(stateRoot);
343
+ afterStateRootFsync?.();
253
344
  const temporaryPath = import_node_path.default.join(stateRoot, `.${PROJECT_WORKSPACE_STATE_FILE}.${process.pid}.${(0, import_node_crypto.randomUUID)()}.tmp`);
254
345
  let descriptor;
255
346
  try {
@@ -357,7 +448,12 @@ function mergePendingCreatedBranches(...groups) {
357
448
  const byKey = /* @__PURE__ */ new Map();
358
449
  for (const pending of groups.flat()) {
359
450
  const normalized = normalizePendingCreatedBranches([pending])[0];
360
- byKey.set(pendingCreatedBranchKey(normalized), normalized);
451
+ const key = pendingCreatedBranchKey(normalized);
452
+ const existing = byKey.get(key);
453
+ if (existing?.branchId && normalized.branchId && existing.branchId !== normalized.branchId) {
454
+ throw new Error(`Pending local branch ${normalized.projectId}/${normalized.branchName} changed durable branch id`);
455
+ }
456
+ byKey.set(key, normalized.branchId ? normalized : existing ?? normalized);
361
457
  }
362
458
  return [...byKey.values()].sort(
363
459
  (left, right) => left.projectId.localeCompare(right.projectId) || left.branchName.localeCompare(right.branchName)
@@ -462,6 +558,7 @@ function stateView(projectsRoot, state) {
462
558
  desiredProjects: cloneDesiredProjects(state.desiredProjects),
463
559
  effectiveDesiredProjects: effectiveProjects,
464
560
  locallyPendingCreatedBranches: state.locallyPendingCreatedBranches.map((pending) => ({ ...pending })),
561
+ enabledRepositoryTransitions: state.enabledRepositoryTransitions.map((transition) => ({ ...transition })),
465
562
  tombstones: cloneTombstones(state.tombstones),
466
563
  pendingTreeDeletions,
467
564
  pendingMirrorRefDeletions
@@ -636,14 +733,51 @@ class ProjectWorkspaceStateStore {
636
733
  reconcile(input) {
637
734
  const previous = readState(this.statePath);
638
735
  const authoritativeDesiredProjects = normalizeDesiredProjects(input.desiredProjects);
736
+ for (const project of authoritativeDesiredProjects) {
737
+ const occupyingProjectId = previous.desiredProjects.find(
738
+ (candidate) => candidate.projectId !== project.projectId && samePathSegments(candidate.checkoutPathSegments, project.checkoutPathSegments)
739
+ )?.projectId ?? previous.tombstones.find(
740
+ (candidate) => candidate.projectId !== project.projectId && samePathSegments(candidate.checkoutPathSegments, project.checkoutPathSegments)
741
+ )?.projectId;
742
+ if (occupyingProjectId) {
743
+ throw new ProjectWorkspaceCheckoutPathCollisionError({
744
+ projectId: project.projectId,
745
+ occupyingProjectId,
746
+ checkoutPathSegments: project.checkoutPathSegments
747
+ });
748
+ }
749
+ }
639
750
  const authoritativeProjectIds = new Set(authoritativeDesiredProjects.map(({ projectId }) => projectId));
751
+ const preserveOnlyBranches = normalizePreserveOnlyBranches(input.preserveOnlyBranches ?? []);
752
+ for (const preserved of preserveOnlyBranches) {
753
+ const project = authoritativeDesiredProjects.find(({ projectId }) => projectId === preserved.projectId);
754
+ if (!project) {
755
+ throw new Error(`Preserve-only branch ${preserved.projectId}/${preserved.branchName} has no desired project`);
756
+ }
757
+ if (project.branches.some(({ branchName }) => branchName === preserved.branchName)) {
758
+ throw new Error(`Preserve-only branch ${preserved.projectId}/${preserved.branchName} is already active`);
759
+ }
760
+ }
640
761
  const locallyPendingCreatedBranches = mergePendingCreatedBranches(
641
762
  previous.locallyPendingCreatedBranches,
642
763
  input.locallyPendingCreatedBranches ?? []
643
764
  ).filter(
644
765
  (pending) => authoritativeProjectIds.has(pending.projectId) && !desiredProjectHasBranch(authoritativeDesiredProjects, pending)
645
766
  );
767
+ for (const pending of locallyPendingCreatedBranches) {
768
+ const currentProject = previous.desiredProjects.find(({ projectId }) => projectId === pending.projectId);
769
+ const incomingProject = authoritativeDesiredProjects.find(({ projectId }) => projectId === pending.projectId);
770
+ if (currentProject && incomingProject && !samePathSegments(currentProject.checkoutPathSegments, incomingProject.checkoutPathSegments)) {
771
+ throw new ProjectWorkspacePendingBranchPathChangeError({
772
+ projectId: pending.projectId,
773
+ branchName: pending.branchName,
774
+ currentCheckoutPathSegments: currentProject.checkoutPathSegments,
775
+ incomingCheckoutPathSegments: incomingProject.checkoutPathSegments
776
+ });
777
+ }
778
+ }
646
779
  const desiredProjects = cloneDesiredProjects(authoritativeDesiredProjects);
780
+ applyPendingCreatedBranches(desiredProjects, preserveOnlyBranches);
647
781
  applyPendingCreatedBranches(desiredProjects, locallyPendingCreatedBranches);
648
782
  const desiredById = new Map(desiredProjects.map((project) => [project.projectId, project]));
649
783
  const tombstones = cloneTombstones(previous.tombstones);
@@ -669,16 +803,36 @@ class ProjectWorkspaceStateStore {
669
803
  version: PROJECT_WORKSPACE_STATE_VERSION,
670
804
  desiredProjects,
671
805
  locallyPendingCreatedBranches,
806
+ enabledRepositoryTransitions: previous.enabledRepositoryTransitions.filter(({ projectId }) => authoritativeProjectIds.has(projectId)),
672
807
  tombstones
673
808
  });
674
809
  writeStateAtomically(this.stateRoot, this.statePath, nextState);
675
810
  return stateView(this.projectsRoot, nextState);
676
811
  }
812
+ /** Record a successful enabled reseed without letting disabled config consume it. */
813
+ recordEnabledRepositoryTransition(input) {
814
+ const [transition] = normalizeEnabledRepositoryTransitions([input]);
815
+ const state = readState(this.statePath);
816
+ if (!state.desiredProjects.some(({ projectId }) => projectId === transition.projectId)) {
817
+ throw new Error(`Cannot record an enabled repository transition for missing project ${transition.projectId}`);
818
+ }
819
+ state.enabledRepositoryTransitions = state.enabledRepositoryTransitions.filter(({ projectId }) => projectId !== transition.projectId);
820
+ state.enabledRepositoryTransitions.push(transition);
821
+ state.enabledRepositoryTransitions.sort((left, right) => left.projectId.localeCompare(right.projectId));
822
+ writeStateAtomically(this.stateRoot, this.statePath, state);
823
+ return stateView(this.projectsRoot, state);
824
+ }
677
825
  /** Persist the create intent before invoking Git so a crash cannot orphan an untracked worktree. */
678
826
  recordPendingCreatedBranch(input) {
679
827
  const [pending] = normalizePendingCreatedBranches([input]);
680
828
  const state = readState(this.statePath);
681
- if (state.locallyPendingCreatedBranches.some((candidate) => pendingCreatedBranchKey(candidate) === pendingCreatedBranchKey(pending))) {
829
+ const existingPending = state.locallyPendingCreatedBranches.find(
830
+ (candidate) => pendingCreatedBranchKey(candidate) === pendingCreatedBranchKey(pending)
831
+ );
832
+ if (existingPending) {
833
+ if (existingPending.branchId !== pending.branchId) {
834
+ throw new Error(`Pending project branch ${pending.branchName} belongs to another durable branch incarnation`);
835
+ }
682
836
  return stateView(this.projectsRoot, state);
683
837
  }
684
838
  const project = state.desiredProjects.find(({ projectId }) => projectId === pending.projectId);
@@ -695,6 +849,23 @@ class ProjectWorkspaceStateStore {
695
849
  writeStateAtomically(this.stateRoot, this.statePath, state);
696
850
  return stateView(this.projectsRoot, state);
697
851
  }
852
+ /** Roll back an intent only when Git failed before creating its branch. */
853
+ rollbackPendingCreatedBranch(input) {
854
+ const [pending] = normalizePendingCreatedBranches([input]);
855
+ const state = readState(this.statePath);
856
+ const key = pendingCreatedBranchKey(pending);
857
+ const existing = state.locallyPendingCreatedBranches.find((candidate) => pendingCreatedBranchKey(candidate) === key);
858
+ if (!existing || pending.branchId && existing.branchId !== pending.branchId) {
859
+ return stateView(this.projectsRoot, state);
860
+ }
861
+ state.locallyPendingCreatedBranches = state.locallyPendingCreatedBranches.filter(
862
+ (candidate) => pendingCreatedBranchKey(candidate) !== key
863
+ );
864
+ const project = state.desiredProjects.find(({ projectId }) => projectId === pending.projectId);
865
+ if (project) project.branches = project.branches.filter(({ branchName }) => branchName !== pending.branchName);
866
+ writeStateAtomically(this.stateRoot, this.statePath, state);
867
+ return stateView(this.projectsRoot, state);
868
+ }
698
869
  /** Clear a create intent after an authoritative desired config contains it. */
699
870
  clearPendingCreatedBranch(input) {
700
871
  const [pending] = normalizePendingCreatedBranches([input]);
@@ -768,10 +939,18 @@ class ProjectWorkspaceStateStore {
768
939
  return stateView(this.projectsRoot, state);
769
940
  }
770
941
  }
942
+ const projectWorkspaceStateTestHarness = {
943
+ writeEmptyStateAtDurableRootBoundary(stateRoot, afterStateRootFsync) {
944
+ writeStateAtomically(stateRoot, import_node_path.default.join(stateRoot, PROJECT_WORKSPACE_STATE_FILE), EMPTY_STATE, afterStateRootFsync);
945
+ }
946
+ };
771
947
  // Annotate the CommonJS export names for ESM import in node:
772
948
  0 && (module.exports = {
773
949
  PROJECT_WORKSPACE_STATE_FILE,
950
+ ProjectWorkspaceCheckoutPathCollisionError,
951
+ ProjectWorkspacePendingBranchPathChangeError,
774
952
  ProjectWorkspaceStateStore,
775
953
  discoverStaleOuterProjectWorkspaceEntries,
954
+ projectWorkspaceStateTestHarness,
776
955
  pruneAuthoritativelyDesiredBranchDeletions
777
956
  });