@ricsam/r5d-worker 0.0.78 → 0.0.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/dist/cjs/main.cjs +1550 -335
- package/dist/cjs/managed-paths.cjs +101 -1
- package/dist/cjs/package.json +1 -1
- package/dist/cjs/project-workspace-state.cjs +184 -5
- package/dist/cjs/project-worktrees.cjs +641 -56
- package/dist/cjs/registry-auth.cjs +310 -0
- package/dist/cjs/repository-transition-policy.cjs +49 -0
- package/dist/cjs/supervisor.cjs +62 -11
- package/dist/cjs/working-tree-mirror.cjs +191 -34
- package/dist/cjs/workspace-automatic-sync-policy.cjs +69 -0
- package/dist/cjs/workspace-branch-incarnation-policy.cjs +37 -0
- package/dist/cjs/workspace-command-sync-policy.cjs +18 -0
- package/dist/cjs/workspace-git-sync.cjs +1028 -54
- package/dist/cjs/workspace-mount-boundary.cjs +71 -0
- package/dist/cjs/workspace-path-move.cjs +195 -0
- package/dist/cjs/workspace-preserve-only-policy.cjs +36 -0
- package/dist/cjs/workspace-project-config-policy.cjs +46 -0
- package/dist/cjs/workspace-publication-evidence.cjs +46 -0
- package/dist/mjs/main.mjs +1584 -341
- package/dist/mjs/managed-paths.mjs +100 -1
- package/dist/mjs/package.json +1 -1
- package/dist/mjs/project-workspace-state.mjs +181 -5
- package/dist/mjs/project-worktrees.mjs +632 -55
- package/dist/mjs/registry-auth.mjs +269 -0
- package/dist/mjs/repository-transition-policy.mjs +22 -0
- package/dist/mjs/supervisor.mjs +61 -11
- package/dist/mjs/working-tree-mirror.mjs +190 -34
- package/dist/mjs/workspace-automatic-sync-policy.mjs +40 -0
- package/dist/mjs/workspace-branch-incarnation-policy.mjs +13 -0
- package/dist/mjs/workspace-command-sync-policy.mjs +17 -0
- package/dist/mjs/workspace-git-sync.mjs +1023 -54
- package/dist/mjs/workspace-mount-boundary.mjs +37 -0
- package/dist/mjs/workspace-path-move.mjs +160 -0
- package/dist/mjs/workspace-preserve-only-policy.mjs +11 -0
- package/dist/mjs/workspace-project-config-policy.mjs +21 -0
- package/dist/mjs/workspace-publication-evidence.mjs +21 -0
- package/dist/types/credential-authority-lock-fixture.d.ts +1 -0
- package/dist/types/main.d.ts +175 -1
- package/dist/types/managed-paths.d.ts +11 -0
- package/dist/types/project-workspace-state.d.ts +45 -1
- package/dist/types/project-worktrees.d.ts +119 -2
- package/dist/types/registry-auth.d.ts +47 -0
- package/dist/types/repository-transition-policy.d.ts +26 -0
- package/dist/types/supervisor-daemonized-fixture.d.ts +1 -0
- package/dist/types/supervisor-signal-fixture.d.ts +1 -0
- package/dist/types/supervisor.d.ts +6 -4
- package/dist/types/working-tree-mirror.d.ts +7 -0
- package/dist/types/workspace-automatic-sync-policy.d.ts +37 -0
- package/dist/types/workspace-branch-incarnation-policy.d.ts +14 -0
- package/dist/types/workspace-command-sync-policy.d.ts +9 -0
- package/dist/types/workspace-git-sync.d.ts +33 -3
- package/dist/types/workspace-mount-boundary.d.ts +10 -0
- package/dist/types/workspace-path-move.d.ts +21 -0
- package/dist/types/workspace-preserve-only-policy.d.ts +15 -0
- package/dist/types/workspace-project-config-policy.d.ts +34 -0
- package/dist/types/workspace-publication-evidence.d.ts +17 -0
- package/package.json +1 -1
|
@@ -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,
|
package/dist/cjs/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|