@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.
- 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 +676 -58
- 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 +196 -36
- 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 +1037 -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 +667 -57
- 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 +195 -36
- 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 +1032 -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 +14 -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
|
@@ -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,
|
package/dist/mjs/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|