@aefree/pi-plastic 0.7.6 → 0.8.1
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/CHANGELOG.md +43 -0
- package/README.md +48 -174
- package/docs/2026-10-06-development.md +101 -0
- package/docs/2026-10-06-diffs-and-patches.md +136 -0
- package/docs/2026-10-06-discovery.md +135 -0
- package/docs/2026-10-06-integrations.md +49 -0
- package/docs/2026-10-06-merging.md +153 -0
- package/docs/2026-10-06-safety-and-receipts.md +95 -0
- package/docs/2026-10-06-setup.md +101 -0
- package/docs/2026-10-06-shelvesets-and-reviews.md +113 -0
- package/docs/2026-10-06-status.md +130 -0
- package/docs/2026-10-06-tools-and-loading.md +64 -0
- package/docs/2026-10-06-workspace-operations.md +142 -0
- package/docs/README.md +65 -0
- package/extensions/bash-cm-diff-guard.ts +1 -1
- package/index.ts +4 -687
- package/package.json +10 -4
- package/skills/using-plastic/SKILL.md +11 -5
- package/skills/using-plastic/references/branch-management.md +5 -1
- package/skills/using-plastic/references/changeset-operations.md +9 -8
- package/skills/using-plastic/references/code-review-creation.md +25 -18
- package/skills/using-plastic/references/integration.md +9 -5
- package/skills/using-plastic/references/quick-reference.md +4 -4
- package/skills/using-plastic/references/reviewing-changes.md +10 -10
- package/skills/using-plastic/references/shelving.md +26 -11
- package/skills/using-plastic/references/troubleshooting.md +4 -4
- package/src/diff/local-snapshot.ts +74 -0
- package/src/diff/text.ts +187 -0
- package/src/domain/add-contract.ts +24 -0
- package/src/domain/branch-create-contract.ts +38 -0
- package/src/domain/branch-delete-contract.ts +15 -0
- package/src/domain/branches.ts +263 -0
- package/src/domain/checkin-contract.ts +70 -0
- package/src/domain/closeout-contract.ts +30 -0
- package/src/domain/copied-merge.ts +42 -0
- package/src/domain/diff-base-xml.ts +65 -0
- package/src/domain/diff-pending-xml.ts +34 -0
- package/src/domain/merge-output.ts +327 -0
- package/src/domain/paths.ts +253 -0
- package/src/domain/pending.ts +499 -0
- package/src/domain/removal-contract.ts +25 -0
- package/src/domain/revisions.ts +301 -0
- package/src/domain/server-merge-contract.ts +45 -0
- package/src/domain/server-merge-resolutions.ts +61 -0
- package/src/domain/status-xml.ts +122 -0
- package/src/domain/switch-contract.ts +60 -0
- package/src/domain/switch-policy.ts +95 -0
- package/src/domain/undo-contract.ts +24 -0
- package/src/domain/update-contract.ts +16 -0
- package/src/domain/workspace-merge-contract.ts +41 -0
- package/src/execution/add-command.ts +118 -0
- package/src/execution/branch-create-command.ts +118 -0
- package/src/execution/branch-list-command.ts +75 -0
- package/src/execution/checkin-command.ts +116 -0
- package/src/execution/cli-version.ts +31 -0
- package/src/execution/cm.ts +59 -0
- package/src/execution/code-review-find-command.ts +75 -0
- package/src/execution/context.ts +20 -0
- package/src/execution/diff-revisions-command.ts +74 -0
- package/src/execution/process.ts +260 -0
- package/src/execution/removal-command.ts +118 -0
- package/src/execution/server-merge-command.ts +92 -0
- package/src/execution/shelveset-list-command.ts +75 -0
- package/src/execution/status-command.ts +29 -0
- package/src/execution/status-xml-command.ts +72 -0
- package/src/execution/status-xml-limits.ts +1 -0
- package/src/execution/switch-command.ts +118 -0
- package/src/execution/test-seams.ts +10 -0
- package/src/execution/undo-command.ts +118 -0
- package/src/execution/update-command.ts +118 -0
- package/src/execution/workspace-list-command.ts +75 -0
- package/src/execution/workspace-merge-command.ts +118 -0
- package/src/operations/add-receipt.ts +37 -0
- package/src/operations/arguments.ts +3 -0
- package/src/operations/branch-create.ts +74 -0
- package/src/operations/branch-delete-receipt.ts +25 -0
- package/src/operations/branch-list.ts +94 -0
- package/src/operations/branch-reads.ts +94 -0
- package/src/operations/branches.ts +111 -0
- package/src/operations/checkin-receipt.ts +140 -0
- package/src/operations/checkin.ts +102 -0
- package/src/operations/closeout-receipt.ts +74 -0
- package/src/operations/closeout.ts +19 -0
- package/src/operations/code-review-find.ts +97 -0
- package/src/operations/consolidated-diff.ts +144 -0
- package/src/operations/diff-revisions.ts +157 -0
- package/src/operations/diff.ts +53 -0
- package/src/operations/merge.ts +26 -0
- package/src/operations/object-delete-receipt.ts +21 -0
- package/src/operations/object-write-receipt.ts +105 -0
- package/src/operations/patch-receipt.ts +96 -0
- package/src/operations/patch-support.ts +375 -0
- package/src/operations/patch.ts +25 -0
- package/src/operations/removal-receipt.ts +42 -0
- package/src/operations/reviews.ts +90 -0
- package/src/operations/server-merge.ts +130 -0
- package/src/operations/shelveset-list.ts +90 -0
- package/src/operations/shelvesets.ts +90 -0
- package/src/operations/status.ts +129 -0
- package/src/operations/switch-receipt.ts +85 -0
- package/src/operations/switch.ts +25 -0
- package/src/operations/undo-receipt.ts +37 -0
- package/src/operations/update-receipt.ts +35 -0
- package/src/operations/workspace-list.ts +92 -0
- package/src/operations/workspace-merge-receipt.ts +50 -0
- package/src/operations/workspace.ts +120 -0
- package/src/pi/add-output.ts +52 -0
- package/src/pi/arguments.ts +298 -0
- package/src/pi/branch-create-output.ts +83 -0
- package/src/pi/branch-delete-output.ts +46 -0
- package/src/pi/branch-list-output.ts +59 -0
- package/src/pi/branch-output.ts +63 -0
- package/src/pi/capability-diagnostics.ts +23 -0
- package/src/pi/checkin-output.ts +68 -0
- package/src/pi/closeout-output.ts +87 -0
- package/src/pi/code-review-find-output.ts +50 -0
- package/src/pi/diff-output.ts +66 -0
- package/src/pi/diff-revisions-output.ts +55 -0
- package/src/pi/object-delete-output.ts +15 -0
- package/src/pi/object-write-output.ts +54 -0
- package/src/pi/patch-output.ts +69 -0
- package/src/pi/register.ts +188 -0
- package/src/pi/removal-output.ts +59 -0
- package/src/pi/schemas.ts +146 -0
- package/src/pi/server-merge-output.ts +83 -0
- package/src/pi/shelveset-list-output.ts +50 -0
- package/src/pi/status-output.ts +204 -0
- package/src/pi/switch-output.ts +80 -0
- package/src/pi/tool-registry.ts +72 -0
- package/src/pi/undo-output.ts +52 -0
- package/src/pi/update-output.ts +48 -0
- package/src/pi/workspace-list-output.ts +53 -0
- package/src/pi/workspace-merge-output.ts +46 -0
- package/src/plastic-core.ts +14 -5775
- package/src/plastic-renderers.ts +102 -22
- package/src/plastic-tool-loading.ts +7 -12
- package/src/presentation/add-results.ts +5 -0
- package/src/presentation/branch-create.ts +7 -0
- package/src/presentation/branch-delete-results.ts +7 -0
- package/src/presentation/checkin-results.ts +14 -0
- package/src/presentation/closeout-results.ts +2 -0
- package/src/presentation/diff-results.ts +237 -0
- package/src/presentation/removal-results.ts +7 -0
- package/src/presentation/results.ts +84 -0
- package/src/presentation/switch-results.ts +7 -0
- package/src/presentation/undo-results.ts +5 -0
- package/src/presentation/update-results.ts +5 -0
- package/src/presentation/workspace-merge-results.ts +6 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { dirname } from "path";
|
|
3
|
+
import { runCmRaw } from "../execution/cm";
|
|
4
|
+
import { discoverPlasticWorkspace } from "../plastic-workspace";
|
|
5
|
+
import { toNormalizedAbsolutePath, toFilesystemIdentityPath, toPathComparisonKeyFromAbsolutePath, isWithinPathScope } from "./paths";
|
|
6
|
+
import { PendingItem } from "./pending";
|
|
7
|
+
|
|
8
|
+
export const isRevisionNotFoundError = (message: string): boolean =>
|
|
9
|
+
{
|
|
10
|
+
const normalized = message.toLowerCase();
|
|
11
|
+
return normalized.includes("the specified revision was not found");
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const isGlobalRevisionSpec = (revision: string): boolean =>
|
|
15
|
+
{
|
|
16
|
+
const normalized = revision.toLowerCase();
|
|
17
|
+
return normalized.startsWith("rev:")
|
|
18
|
+
|| normalized.startsWith("revid:")
|
|
19
|
+
|| normalized.startsWith("itemid:")
|
|
20
|
+
|| normalized.startsWith("serverpath:");
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const isItemSelectorRevisionSpec = (revision: string): boolean =>
|
|
24
|
+
{
|
|
25
|
+
const normalized = revision.toLowerCase();
|
|
26
|
+
return /^\d+$/.test(revision)
|
|
27
|
+
|| normalized.startsWith("cs:")
|
|
28
|
+
|| normalized.startsWith("br:")
|
|
29
|
+
|| normalized.startsWith("lb:");
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type DiffFileRevisionResolution = {
|
|
33
|
+
supplied: string | null;
|
|
34
|
+
kind: "workspace-base" | "changeset" | "branch" | "label" | "global-revision" | "file-qualified";
|
|
35
|
+
resolved: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const resolveDiffFileRevision = (pathForRevision: string, revision?: string): DiffFileRevisionResolution =>
|
|
39
|
+
{
|
|
40
|
+
const normalizedPath = pathForRevision.trim();
|
|
41
|
+
const normalizedRevision = revision?.trim() ?? "";
|
|
42
|
+
if (!normalizedPath)
|
|
43
|
+
{
|
|
44
|
+
throw new Error("path must identify a workspace file.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A bare item path is the unambiguous common case: cm cat resolves the
|
|
48
|
+
// workspace's loaded/base revision without asking agents to guess a revspec.
|
|
49
|
+
if (!normalizedRevision)
|
|
50
|
+
{
|
|
51
|
+
return { supplied: null, kind: "workspace-base", resolved: normalizedPath };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lowerRevision = normalizedRevision.toLowerCase();
|
|
55
|
+
if (lowerRevision === "base" || lowerRevision === "head" || lowerRevision === "cs:head" || /^cs:br:/i.test(normalizedRevision))
|
|
56
|
+
{
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Unsupported revision '${revision}'. Omit revision for the workspace base, or use a changeset number/cs:<number>, br:/<branch>, lb:<label>, a file-qualified '<path>#<selector>', or a global revid:/rev: spec.`,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (normalizedRevision.includes("#"))
|
|
63
|
+
{
|
|
64
|
+
return { supplied: normalizedRevision, kind: "file-qualified", resolved: normalizedRevision };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isGlobalRevisionSpec(normalizedRevision))
|
|
68
|
+
{
|
|
69
|
+
return { supplied: normalizedRevision, kind: "global-revision", resolved: normalizedRevision };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (/^\d+$/.test(normalizedRevision) || /^cs:\d+$/i.test(normalizedRevision))
|
|
73
|
+
{
|
|
74
|
+
const selector = /^\d+$/.test(normalizedRevision) ? `cs:${normalizedRevision}` : normalizedRevision;
|
|
75
|
+
return { supplied: normalizedRevision, kind: "changeset", resolved: `${normalizedPath}#${selector}` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (/^br:\/.+/i.test(normalizedRevision))
|
|
79
|
+
{
|
|
80
|
+
return { supplied: normalizedRevision, kind: "branch", resolved: `${normalizedPath}#${normalizedRevision}` };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (/^lb:[^\s#]+/i.test(normalizedRevision))
|
|
84
|
+
{
|
|
85
|
+
return { supplied: normalizedRevision, kind: "label", resolved: `${normalizedPath}#${normalizedRevision}` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw new Error(
|
|
89
|
+
`Unsupported revision '${revision}'. Omit revision for the workspace base, or use a changeset number/cs:<number>, br:/<branch>, lb:<label>, a file-qualified '<path>#<selector>', or a global revid:/rev: spec.`,
|
|
90
|
+
);
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// Kept as a small compatibility seam for older focused tests and consumers.
|
|
94
|
+
export const normalizeDiffFileRevisionSpec = (pathForRevision: string, revision: string): string =>
|
|
95
|
+
resolveDiffFileRevision(pathForRevision, revision).resolved;
|
|
96
|
+
|
|
97
|
+
export const isValidGlobalRevisionSpec = (revision: string): boolean =>
|
|
98
|
+
/^(?:rev|revid|itemid|serverpath):\S+$/i.test(revision.trim());
|
|
99
|
+
|
|
100
|
+
export const isValidFileQualifiedRevisionSpec = (revision: string): boolean =>
|
|
101
|
+
{
|
|
102
|
+
const normalized = revision.trim();
|
|
103
|
+
const separator = normalized.lastIndexOf("#");
|
|
104
|
+
if (separator <= 0 || separator === normalized.length - 1)
|
|
105
|
+
{
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const selector = normalized.slice(separator + 1);
|
|
110
|
+
return /^cs:\d+$/i.test(selector)
|
|
111
|
+
|| /^br:\/.+/i.test(selector)
|
|
112
|
+
|| /^lb:[^\s#]+$/i.test(selector)
|
|
113
|
+
|| isValidGlobalRevisionSpec(selector);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const isValidDiffRevisionSpec = (revision: string): boolean =>
|
|
117
|
+
isValidGlobalRevisionSpec(revision) || isValidFileQualifiedRevisionSpec(revision);
|
|
118
|
+
|
|
119
|
+
export const isUnscopedDiffRevisionSpec = (revision: string): boolean => !isValidDiffRevisionSpec(revision);
|
|
120
|
+
|
|
121
|
+
export const extractBranchSelectorFromRevision = (revision: string): string | null =>
|
|
122
|
+
{
|
|
123
|
+
const match = revision.match(/(?:^|#)(br:[^\s#]+)/i);
|
|
124
|
+
if (!match)
|
|
125
|
+
{
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const selector = (match[1] ?? "").trim();
|
|
130
|
+
return selector.length > 0 ? selector : null;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
export const extractBranchNameFromSelector = (branchSelector: string): string | null =>
|
|
134
|
+
{
|
|
135
|
+
const normalized = branchSelector.trim().replace(/^br:/i, "");
|
|
136
|
+
if (!normalized)
|
|
137
|
+
{
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const atIndex = normalized.indexOf("@");
|
|
142
|
+
const branchName = atIndex >= 0 ? normalized.slice(0, atIndex).trim() : normalized.trim();
|
|
143
|
+
return branchName.length > 0 ? branchName : null;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export type PendingBaseIdentityResolver = {
|
|
147
|
+
resolve: (item: PendingItem) => Promise<string>;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export type RepositoryIdentity = {
|
|
151
|
+
repository: string;
|
|
152
|
+
server: string;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const parseRepositoryIdentity = (selector: string | undefined): RepositoryIdentity | null =>
|
|
156
|
+
{
|
|
157
|
+
if (!selector || /[\u0000-\u001f\u007f]/.test(selector))
|
|
158
|
+
{
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
// Repository names cannot contain @; cloud server names can (for example,
|
|
162
|
+
// an organization-qualified cloud endpoint), so split only at the first @.
|
|
163
|
+
const separator = selector.indexOf("@");
|
|
164
|
+
if (separator <= 0 || separator === selector.length - 1)
|
|
165
|
+
{
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
return { repository: selector.slice(0, separator), server: selector.slice(separator + 1) };
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const parseRepositorySelector = (output: string): RepositoryIdentity | null =>
|
|
172
|
+
{
|
|
173
|
+
const match = output.match(/^\s*(?:repository|rep)\s+(?:"([^"]+)"|(\S+))\s*$/mi);
|
|
174
|
+
return parseRepositoryIdentity(match?.[1] ?? match?.[2]);
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
export const parseXlinkRepositorySelector = (output: string): RepositoryIdentity | null =>
|
|
178
|
+
{
|
|
179
|
+
// `cm xlink --show` reports wxlink:<serverpath>@<loaded-revision>@<repository>@<server>.
|
|
180
|
+
const match = output.match(/^.+?\s+-->\s+wxlink:.+?@(?:\d+|cs:\d+)@(.+)\s*$/mi);
|
|
181
|
+
return parseRepositoryIdentity(match?.[1]?.trim());
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
export const isNotXlinkError = (error: unknown): boolean => /\bis not an xlink\.?\s*$/i.test(error instanceof Error ? error.message.trim() : String(error).trim());
|
|
185
|
+
|
|
186
|
+
export const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): PendingBaseIdentityResolver =>
|
|
187
|
+
{
|
|
188
|
+
let workspaceRoot: Promise<string> | undefined;
|
|
189
|
+
const getWorkspaceRoot = (): Promise<string> =>
|
|
190
|
+
{
|
|
191
|
+
workspaceRoot ??= discoverPlasticWorkspace(cwd).then((outcome) =>
|
|
192
|
+
{
|
|
193
|
+
if (outcome.kind !== "found")
|
|
194
|
+
{
|
|
195
|
+
throw new Error(`Plastic cannot determine the workspace root for pending base resolution: ${outcome.kind === "unavailable" ? outcome.reason : "no workspace marker found"}.`);
|
|
196
|
+
}
|
|
197
|
+
return toNormalizedAbsolutePath(outcome.value.root, cwd);
|
|
198
|
+
});
|
|
199
|
+
return workspaceRoot;
|
|
200
|
+
};
|
|
201
|
+
const xlinkLookups = new Map<string, Promise<RepositoryIdentity | null>>();
|
|
202
|
+
let workspaceRepository: Promise<RepositoryIdentity> | undefined;
|
|
203
|
+
const getWorkspaceRepository = (): Promise<RepositoryIdentity> =>
|
|
204
|
+
{
|
|
205
|
+
workspaceRepository ??= runCmRaw(["showselector"], workdir).then((output) =>
|
|
206
|
+
{
|
|
207
|
+
const repository = parseRepositorySelector(output);
|
|
208
|
+
if (!repository)
|
|
209
|
+
{
|
|
210
|
+
throw new Error("Plastic returned no repository/server identity for this workspace.");
|
|
211
|
+
}
|
|
212
|
+
return repository;
|
|
213
|
+
});
|
|
214
|
+
return workspaceRepository;
|
|
215
|
+
};
|
|
216
|
+
const getXlinkRepository = async (candidate: string): Promise<RepositoryIdentity | null> =>
|
|
217
|
+
{
|
|
218
|
+
// Use the physical identity for cache keys, but keep candidate lexical for cm.
|
|
219
|
+
const key = toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate));
|
|
220
|
+
let lookup = xlinkLookups.get(key);
|
|
221
|
+
if (!lookup)
|
|
222
|
+
{
|
|
223
|
+
lookup = runCmRaw(["xlink", "--show", candidate], workdir)
|
|
224
|
+
.then((output) =>
|
|
225
|
+
{
|
|
226
|
+
const repository = parseXlinkRepositorySelector(output);
|
|
227
|
+
if (!repository)
|
|
228
|
+
{
|
|
229
|
+
throw new Error(`Plastic returned malformed Xlink ownership for '${candidate}'.`);
|
|
230
|
+
}
|
|
231
|
+
return repository;
|
|
232
|
+
})
|
|
233
|
+
.catch((error) =>
|
|
234
|
+
{
|
|
235
|
+
if (isNotXlinkError(error))
|
|
236
|
+
{
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
throw error;
|
|
240
|
+
});
|
|
241
|
+
xlinkLookups.set(key, lookup);
|
|
242
|
+
}
|
|
243
|
+
return lookup;
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
async resolve(item)
|
|
248
|
+
{
|
|
249
|
+
if (!item.revisionId)
|
|
250
|
+
{
|
|
251
|
+
throw new Error(`Plastic status did not provide a base revision ID for '${item.workspacePath}'.`);
|
|
252
|
+
}
|
|
253
|
+
const root = await getWorkspaceRoot();
|
|
254
|
+
const rootIdentity = await toFilesystemIdentityPath(root);
|
|
255
|
+
const ownershipPath = item.kind === "moved" && item.sourceWorkspacePath
|
|
256
|
+
? toNormalizedAbsolutePath(item.sourceWorkspacePath, cwd)
|
|
257
|
+
: item.normalizedPath;
|
|
258
|
+
if (!isWithinPathScope(
|
|
259
|
+
toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(ownershipPath)),
|
|
260
|
+
toPathComparisonKeyFromAbsolutePath(rootIdentity),
|
|
261
|
+
))
|
|
262
|
+
{
|
|
263
|
+
throw new Error(`Plastic cannot resolve an owning repository for '${item.workspacePath}' outside the requested workspace.`);
|
|
264
|
+
}
|
|
265
|
+
let candidate = dirname(ownershipPath);
|
|
266
|
+
while (isWithinPathScope(
|
|
267
|
+
toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate)),
|
|
268
|
+
toPathComparisonKeyFromAbsolutePath(rootIdentity),
|
|
269
|
+
))
|
|
270
|
+
{
|
|
271
|
+
// The workspace root belongs to its selector; cm rejects Xlink queries there.
|
|
272
|
+
if (toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate))
|
|
273
|
+
=== toPathComparisonKeyFromAbsolutePath(rootIdentity))
|
|
274
|
+
{
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
// A missing ancestor can be a removed Xlink mount. Status does not
|
|
278
|
+
// retain ownership, so parent-repository fallback would be unsafe.
|
|
279
|
+
if ((item.kind === "deleted" || item.kind === "moved") && !(await fs.stat(candidate).catch(() => null)))
|
|
280
|
+
{
|
|
281
|
+
throw new Error(`Plastic cannot resolve the owning repository for '${item.workspacePath}' because an ownership ancestor is missing.`);
|
|
282
|
+
}
|
|
283
|
+
const repository = await getXlinkRepository(candidate);
|
|
284
|
+
if (repository)
|
|
285
|
+
{
|
|
286
|
+
item.baseRepository = `${repository.repository}@${repository.server}`;
|
|
287
|
+
return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
|
|
288
|
+
}
|
|
289
|
+
const parent = dirname(candidate);
|
|
290
|
+
if (parent === candidate)
|
|
291
|
+
{
|
|
292
|
+
break;
|
|
293
|
+
}
|
|
294
|
+
candidate = parent;
|
|
295
|
+
}
|
|
296
|
+
const repository = await getWorkspaceRepository();
|
|
297
|
+
item.baseRepository = `${repository.repository}@${repository.server}`;
|
|
298
|
+
return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ResolutionFileSummary } from "./server-merge-resolutions";
|
|
2
|
+
export type ServerMergeRequest = { source: string; target: string; message?: string; mode?: "apply" | "analyze"; fileConflictsResolutionsFile?: string; unlistedConflictPolicy?: "source" | "destination"; preflight?: boolean; format?: "text" | "json" };
|
|
3
|
+
export type ServerChangeset = { id: string; branch: string; repository: string; server: string; mount: string };
|
|
4
|
+
import type { MergeAttempt, MergeCapture } from "../execution/server-merge-command";
|
|
5
|
+
export { emptyMergeAttempt } from "../execution/server-merge-command";
|
|
6
|
+
export type { MergeAttempt, MergeCapture } from "../execution/server-merge-command";
|
|
7
|
+
export type MergeReceiptData = {
|
|
8
|
+
mode: "apply" | "analyze";
|
|
9
|
+
resolutionsFile: ResolutionFileSummary | null;
|
|
10
|
+
unlistedConflictPolicy: "source" | "destination" | null;
|
|
11
|
+
requestedIdentity: { source: string; target: string; branch: string; repository: string; server: string } | null;
|
|
12
|
+
command: string[] | null;
|
|
13
|
+
capability: { state: "not-checked" | "advertised" | "missing" | "unavailable"; missingTokens: string[] };
|
|
14
|
+
attempt: MergeAttempt;
|
|
15
|
+
capture: { help: MergeCapture | null; merge: MergeCapture | null };
|
|
16
|
+
parse: { records: number; malformed: boolean; unknownOperations: number; changesetsObserved: number; conflictsObserved: number } | null;
|
|
17
|
+
observedChangesets: ServerChangeset[];
|
|
18
|
+
conflictPaths: string[];
|
|
19
|
+
counts: { changesetsReturned: number; changesetsOmitted: number; conflictsReturned: number; conflictsOmitted: number };
|
|
20
|
+
createdChangeset: ServerChangeset | null;
|
|
21
|
+
effect: "not-attempted" | "changeset-created" | "not-proven" | "uncertain";
|
|
22
|
+
serverAliasEquivalence: "unverified";
|
|
23
|
+
mergeLinkIdentity: "unverified";
|
|
24
|
+
xlinkEffects: "unverified";
|
|
25
|
+
wouldRun: boolean;
|
|
26
|
+
remoteAnalysis: "not-performed" | "performed";
|
|
27
|
+
};
|
|
28
|
+
type Base = { schemaVersion: 1; action: "merge-branches"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; completeness: { capture: "complete" | "incomplete" | "unknown"; projection: boolean } };
|
|
29
|
+
export type MergeReceiptError = { code: string; stage: "input" | "help" | "merge" | "producer"; message: string };
|
|
30
|
+
export type MergeReceipt = Base & (
|
|
31
|
+
{ ok: true; outcome: "completed"; data: MergeReceiptData & { createdChangeset: ServerChangeset; effect: "changeset-created" } } |
|
|
32
|
+
{ ok: true; outcome: "preflight"; data: MergeReceiptData & { createdChangeset: null; effect: "not-attempted" } } |
|
|
33
|
+
{ ok: true; outcome: "analyzed"; data: MergeReceiptData & { createdChangeset: null; effect: "not-attempted" } } |
|
|
34
|
+
{ ok: true; outcome: "no-op"; data: MergeReceiptData & { createdChangeset: null; effect: "not-proven" } } |
|
|
35
|
+
{ ok: false; outcome: "unsupported" | "failed"; data: MergeReceiptData & { createdChangeset: null; effect: "not-attempted" }; error: MergeReceiptError } |
|
|
36
|
+
{ ok: false; outcome: "conflict" | "uncertain"; data: MergeReceiptData & { createdChangeset: null; effect: "uncertain" }; error: MergeReceiptError }
|
|
37
|
+
);
|
|
38
|
+
/** Unambiguous supported syntax: branch/repository contain no @, full server tail is retained. */
|
|
39
|
+
export function splitServerBranch(raw: string): { branch: string; repository: string; server: string } | null {
|
|
40
|
+
const match = /^br:(\/[^@]+)@([^@]+)@([^@]+(?:@(cloud|unity))?)$/.exec(raw);
|
|
41
|
+
if (!match) return null;
|
|
42
|
+
const [, branch, repository, server] = match;
|
|
43
|
+
if (!branch || !repository || !server || branch.endsWith("/") || [branch, repository, server].some(s => s !== s.trim())) return null;
|
|
44
|
+
return { branch, repository, server };
|
|
45
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { open, stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
export const RESOLUTIONS_MAX_BYTES = 1024 * 1024;
|
|
6
|
+
export const RESOLUTIONS_MAX_ENTRIES = 500;
|
|
7
|
+
const unsafe = /[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
8
|
+
export type ResolutionFileSummary = { path: string; sha256: string; entries: number; resultFiles: number; keepSource: number; keepDestination: number };
|
|
9
|
+
function object(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === "object" && !Array.isArray(value); }
|
|
10
|
+
function safePath(value: unknown): value is string { return typeof value === "string" && value.length > 0 && value.length <= 4096 && !unsafe.test(value); }
|
|
11
|
+
export function assertAbsoluteResolutionPath(value: unknown): asserts value is string {
|
|
12
|
+
if (!safePath(value) || !isAbsolute(value)) throw new Error("Resolution input paths must be bounded absolute local paths without control/malformed characters.");
|
|
13
|
+
}
|
|
14
|
+
/** Read-only local validation. CLI remains responsible for matching entries to current conflicts. */
|
|
15
|
+
export async function inspectResolutionFile(path: string): Promise<ResolutionFileSummary> {
|
|
16
|
+
assertAbsoluteResolutionPath(path);
|
|
17
|
+
let bytes: Buffer;
|
|
18
|
+
if (!(await stat(path)).isFile()) throw new Error("Resolutions JSON must be a regular file.");
|
|
19
|
+
const file = await open(path, "r");
|
|
20
|
+
try {
|
|
21
|
+
const stat = await file.stat();
|
|
22
|
+
if (!stat.isFile() || stat.size > RESOLUTIONS_MAX_BYTES) throw new Error("Resolutions JSON must be a regular file of at most 1 MiB.");
|
|
23
|
+
// Bounded even if another process grows the file after stat.
|
|
24
|
+
const buffer = Buffer.alloc(RESOLUTIONS_MAX_BYTES + 1);
|
|
25
|
+
let length = 0;
|
|
26
|
+
while (length < buffer.length) {
|
|
27
|
+
const { bytesRead } = await file.read(buffer, length, buffer.length - length, null);
|
|
28
|
+
if (!bytesRead) break;
|
|
29
|
+
length += bytesRead;
|
|
30
|
+
}
|
|
31
|
+
if (length > RESOLUTIONS_MAX_BYTES) throw new Error("Resolutions JSON exceeds 1 MiB.");
|
|
32
|
+
bytes = buffer.subarray(0, length);
|
|
33
|
+
} finally { await file.close(); }
|
|
34
|
+
let value: unknown;
|
|
35
|
+
try { value = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); }
|
|
36
|
+
catch { throw new Error("Resolutions file must contain valid UTF-8 JSON."); }
|
|
37
|
+
if (!object(value) || Object.keys(value).some(k => k !== "resolutions") || !Array.isArray(value.resolutions) || value.resolutions.length > RESOLUTIONS_MAX_ENTRIES) throw new Error("Resolutions JSON requires only a resolutions array with at most 500 entries.");
|
|
38
|
+
const summary: ResolutionFileSummary = { path, sha256: createHash("sha256").update(bytes).digest("hex"), entries: value.resolutions.length, resultFiles: 0, keepSource: 0, keepDestination: 0 };
|
|
39
|
+
const paths = new Set<string>();
|
|
40
|
+
for (let index = 0; index < value.resolutions.length; index++) {
|
|
41
|
+
const entry = value.resolutions[index];
|
|
42
|
+
const bad = () => new Error(`Invalid resolution entry ${index + 1}: require a unique destination conflict path and exactly one of keep (source/destination) or an absolute readable resultFile.`);
|
|
43
|
+
if (!object(entry) || Object.keys(entry).some(k => !["path", "keep", "resultFile"].includes(k)) || !safePath(entry.path) || !entry.path.startsWith("/") || paths.has(entry.path) || Object.hasOwn(entry, "keep") === Object.hasOwn(entry, "resultFile")) throw bad();
|
|
44
|
+
paths.add(entry.path);
|
|
45
|
+
if (Object.hasOwn(entry, "keep")) {
|
|
46
|
+
if (entry.keep === "source") summary.keepSource++;
|
|
47
|
+
else if (entry.keep === "destination") summary.keepDestination++;
|
|
48
|
+
else throw bad();
|
|
49
|
+
} else {
|
|
50
|
+
try {
|
|
51
|
+
assertAbsoluteResolutionPath(entry.resultFile);
|
|
52
|
+
if (!(await stat(entry.resultFile)).isFile()) throw bad();
|
|
53
|
+
const result = await open(entry.resultFile, "r");
|
|
54
|
+
try { if (!(await result.stat()).isFile()) throw bad(); }
|
|
55
|
+
finally { await result.close(); }
|
|
56
|
+
} catch { throw bad(); }
|
|
57
|
+
summary.resultFiles++;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return summary;
|
|
61
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { DOMParser, type Element } from "@xmldom/xmldom";
|
|
2
|
+
import type { MachineReadableStatusItem, MachineReadableStatusSummary } from "./pending";
|
|
3
|
+
|
|
4
|
+
import { STATUS_XML_LIMITS } from "../execution/status-xml-limits";
|
|
5
|
+
export { STATUS_XML_LIMITS } from "../execution/status-xml-limits";
|
|
6
|
+
export type XmlStatusItem = Omit<MachineReadableStatusItem, "revisionId" | "statusCode" | "kind"> & { statusCode: keyof typeof kinds; kind: typeof kinds[keyof typeof kinds] };
|
|
7
|
+
export class StatusXmlError extends Error {
|
|
8
|
+
constructor(readonly code: "unsupported_source" | "malformed_output" | "output_overflow" = "malformed_output") {
|
|
9
|
+
super(code === "unsupported_source" ? "Status XML contains an unsupported record profile."
|
|
10
|
+
: code === "output_overflow" ? "Status XML exceeded the bounded observation limits."
|
|
11
|
+
: "Status XML did not match the supported observation contract.");
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function invalid(): never { throw new StatusXmlError(); }
|
|
15
|
+
function limit(): never { throw new StatusXmlError("output_overflow"); }
|
|
16
|
+
const kinds = { CH: "changed", AD: "added", DE: "deleted", LD: "deleted", MV: "moved", PR: "private" } as const;
|
|
17
|
+
const fields = new Set(["Type", "TypeVerbose", "Path", "OldPath", "PrintableMovedPath", "MergesInfo", "SimilarityPerUnit", "Similarity", "Size", "PrintableSize", "PrintableLastModified", "RevisionType", "LastModified"]);
|
|
18
|
+
const isAbsolute = (path: string): boolean => path.startsWith("/") || /^[A-Za-z]:\\/.test(path) || /^\\\\[^\\]+\\[^\\]+\\/.test(path);
|
|
19
|
+
const identity = (value: string): string => {
|
|
20
|
+
if (!value || value.length > STATUS_XML_LIMITS.path || !isAbsolute(value) || /[\u0000-\u001f\uFFFE\uFFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(value) || (process.platform === "win32" && value.includes("?"))) invalid();
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// Fatal byte decoding distinguishes literal U+FFFD from decoder substitution.
|
|
25
|
+
export function decodeStatusXml(bytes: Uint8Array): string {
|
|
26
|
+
if (bytes.byteLength > STATUS_XML_LIMITS.stdoutBytes) limit();
|
|
27
|
+
let output: string;
|
|
28
|
+
try { output = new TextDecoder("utf-8", { fatal: true }).decode(bytes); } catch { return invalid(); }
|
|
29
|
+
// This is a declaration gate, not an XML parser. Only the documented UTF-8 document format is supported.
|
|
30
|
+
if (!/^<\?xml\s+version\s*=\s*(["'])1\.0\1\s+encoding\s*=\s*(["'])utf-8\2(?:\s+standalone\s*=\s*(["'])(?:yes|no)\3)?\s*\?>/i.test(output)) invalid();
|
|
31
|
+
return output;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function parseStatusXml(bytes: Uint8Array): { output: string; items: XmlStatusItem[]; summary: MachineReadableStatusSummary } {
|
|
35
|
+
const output = decodeStatusXml(bytes);
|
|
36
|
+
// Conservative denial also rejects these tokens in comments/CDATA; no DTD processing is allowed.
|
|
37
|
+
const upper = output.toUpperCase();
|
|
38
|
+
if (upper.includes("<!DOCTYPE") || upper.includes("<!ENTITY")) invalid();
|
|
39
|
+
let document;
|
|
40
|
+
try {
|
|
41
|
+
document = new DOMParser({ locator: false, onError: () => invalid() }).parseFromString(output, "application/xml");
|
|
42
|
+
} catch { return invalid(); }
|
|
43
|
+
if (document.doctype || !document.documentElement || document.documentElement.tagName !== "StatusOutput") invalid();
|
|
44
|
+
const stack: Array<[Element, number]> = [[document.documentElement, 1]];
|
|
45
|
+
let records = 0;
|
|
46
|
+
while (stack.length) {
|
|
47
|
+
const [element, depth] = stack.pop()!;
|
|
48
|
+
if (depth > STATUS_XML_LIMITS.depth) limit();
|
|
49
|
+
if (element.attributes.length || element.namespaceURI) invalid();
|
|
50
|
+
if (element.tagName === "Change" && ++records > STATUS_XML_LIMITS.records) limit();
|
|
51
|
+
const cap = element.tagName === "Path" || element.tagName === "OldPath" ? STATUS_XML_LIMITS.path : element.tagName === "Type" ? STATUS_XML_LIMITS.code : STATUS_XML_LIMITS.text;
|
|
52
|
+
// Accumulated text, including adjacent text/CDATA nodes, is bounded before projection.
|
|
53
|
+
let textLength = 0;
|
|
54
|
+
for (let node = element.firstChild; node; node = node.nextSibling) {
|
|
55
|
+
if (node.nodeType === 1) stack.push([node as Element, depth + 1]);
|
|
56
|
+
else if (node.nodeType === 3 || node.nodeType === 4) textLength += (node.nodeValue ?? "").length;
|
|
57
|
+
else if (node.nodeType !== 8) invalid();
|
|
58
|
+
}
|
|
59
|
+
if (textLength > cap) limit();
|
|
60
|
+
}
|
|
61
|
+
const children = (element: Element, allowed: ReadonlySet<string>): Map<string, Element> => {
|
|
62
|
+
const result = new Map<string, Element>();
|
|
63
|
+
for (let node = element.firstChild; node; node = node.nextSibling) {
|
|
64
|
+
if (node.nodeType === 1) {
|
|
65
|
+
const child = node as Element;
|
|
66
|
+
if (!allowed.has(child.tagName) || result.has(child.tagName)) invalid();
|
|
67
|
+
result.set(child.tagName, child);
|
|
68
|
+
} else if (node.nodeType !== 8 && (node.nodeValue ?? "").trim()) invalid();
|
|
69
|
+
}
|
|
70
|
+
return result;
|
|
71
|
+
};
|
|
72
|
+
const leaf = (element: Element | undefined): string => {
|
|
73
|
+
if (!element || element.getElementsByTagName("*").length) return invalid();
|
|
74
|
+
return element.textContent ?? "";
|
|
75
|
+
};
|
|
76
|
+
const root = children(document.documentElement, new Set(["WorkspaceStatus", "WkConfigType", "WkConfigName", "Changes"]));
|
|
77
|
+
// A well-formed but foreign/underspecified document must never become complete-empty status.
|
|
78
|
+
const workspace = root.get("WorkspaceStatus");
|
|
79
|
+
if (!workspace || !leaf(root.get("WkConfigType")) || !leaf(root.get("WkConfigName"))) invalid();
|
|
80
|
+
const status = children(workspace, new Set(["Status"])).get("Status");
|
|
81
|
+
if (!status) invalid();
|
|
82
|
+
const context = children(status, new Set(["RepSpec", "Changeset"]));
|
|
83
|
+
if (!/^\d+$/.test(leaf(context.get("Changeset")))) invalid();
|
|
84
|
+
const rep = context.get("RepSpec");
|
|
85
|
+
if (!rep) invalid();
|
|
86
|
+
const spec = children(rep, new Set(["Server", "Name"]));
|
|
87
|
+
if (!leaf(spec.get("Server")) || !leaf(spec.get("Name"))) invalid();
|
|
88
|
+
const items: XmlStatusItem[] = [];
|
|
89
|
+
const seen = new Set<string>();
|
|
90
|
+
let unsupported = false;
|
|
91
|
+
const changes = root.get("Changes");
|
|
92
|
+
if (changes) for (let node = changes.firstChild; node; node = node.nextSibling) {
|
|
93
|
+
if (node.nodeType !== 1) { if (node.nodeType !== 8 && (node.nodeValue ?? "").trim()) invalid(); continue; }
|
|
94
|
+
const change = node as Element;
|
|
95
|
+
if (change.tagName !== "Change") invalid();
|
|
96
|
+
const row = children(change, fields);
|
|
97
|
+
// All recognized fields are scalar in the verified configuration.
|
|
98
|
+
for (const value of row.values()) leaf(value);
|
|
99
|
+
const statusCode = leaf(row.get("Type"));
|
|
100
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(statusCode)) invalid();
|
|
101
|
+
const supportedStatus = Object.hasOwn(kinds, statusCode);
|
|
102
|
+
const kind = supportedStatus ? kinds[statusCode as keyof typeof kinds] : undefined;
|
|
103
|
+
const path = identity(leaf(row.get("Path")));
|
|
104
|
+
const oldPath = leaf(row.get("OldPath"));
|
|
105
|
+
const revisionType = leaf(row.get("RevisionType"));
|
|
106
|
+
if (!/^en[A-Za-z0-9]+$/.test(revisionType)) invalid();
|
|
107
|
+
const supportedType = revisionType === "enDirectory" || revisionType === "enTextFile";
|
|
108
|
+
if (supportedStatus && (kind === "moved") !== Boolean(oldPath)) invalid();
|
|
109
|
+
if (oldPath && identity(oldPath) === path) invalid();
|
|
110
|
+
if (seen.has(path)) invalid();
|
|
111
|
+
seen.add(path);
|
|
112
|
+
// Validate the entire document before reporting unsupported profiles: a later unsafe
|
|
113
|
+
// identity/structure must not be masked by an earlier unadmitted record.
|
|
114
|
+
if (!supportedStatus || !supportedType) { unsupported = true; continue; }
|
|
115
|
+
items.push({ statusCode: statusCode as keyof typeof kinds, kind: kind!, path, isDirectory: revisionType === "enDirectory", ...(oldPath ? { sourcePath: oldPath } : {}) });
|
|
116
|
+
}
|
|
117
|
+
if (unsupported) throw new StatusXmlError("unsupported_source");
|
|
118
|
+
const summary: MachineReadableStatusSummary = { totalPending: items.length, added: 0, changed: 0, moved: 0, deleted: 0, private: 0, other: 0, tracked: 0 };
|
|
119
|
+
for (const row of items) summary[row.kind]++;
|
|
120
|
+
summary.tracked = items.length - summary.private;
|
|
121
|
+
return { output, items, summary };
|
|
122
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { parseMachineReadablePendingItems, summarizePendingItems } from "./pending";
|
|
2
|
+
import type { SwitchAttempt, SwitchCapture } from "../execution/switch-command";
|
|
3
|
+
export type { SwitchAttempt, SwitchCapture } from "../execution/switch-command";
|
|
4
|
+
export type SwitchRequest = { branch: string; pendingChanges?: "shelve" | "bring" | "cancel"; preflight?: boolean; format?: "text" | "json"; workdir?: string };
|
|
5
|
+
export type SwitchBranchObservation = { branch: string; repository: string; server: string; basis: "standard-status"; scope: "loaded-workspace" };
|
|
6
|
+
export type SwitchSummary = { totalPending: number; tracked: number; private: number; added: number; changed: number; moved: number; deleted: number; other: number };
|
|
7
|
+
export type SwitchStepName = "branch-before" | "pending-before" | "shelve" | "pending-recovery" | "switch" | "branch-after";
|
|
8
|
+
export type SwitchStep = { name: SwitchStepName; argv: string[]; attempt: SwitchAttempt; capture: SwitchCapture; evidence: "unadmitted" | "admitted" | "terminal-only" | "eligible-no-changes"; effect: "read-observed" | "not-attempted" | "command-completed" | "uncertain"; branch: SwitchBranchObservation | null; summary: SwitchSummary | null; records: number };
|
|
9
|
+
export type SwitchStrategy = "unresolved" | "already-on-target-branch" | "silent-noinput" | "direct-switch-private-only" | "blocked-bring-tracked-pending" | "cancel-with-pending" | "shelve-then-switch-noinput";
|
|
10
|
+
export type SwitchData = { requestedTarget: string | null; workdir: string | null; pendingPolicy: "shelve" | "bring" | "cancel"; defaultedPolicy: boolean; preflight: boolean; strategy: SwitchStrategy; wouldRun: boolean; plannedMutations: Array<"shelve" | "switch">; unattemptedMutations: Array<"shelve" | "switch">; steps: SwitchStep[]; branchBefore: SwitchBranchObservation | null; branchAfter: SwitchBranchObservation | null; pendingBefore: SwitchSummary | null; pendingRecovery: SwitchSummary | null; observedShelveset: null; usedNoChangesShelveRecovery: boolean; targetVerification: "unverified" | "name-only" | "exact-qualifier-spelling"; serverAliasEquivalence: "unverified"; filePreservation: "unverified"; xlinkEffects: "unverified"; effect: "not-attempted" | "command-completed" | "uncertain" };
|
|
11
|
+
type Base = { schemaVersion: 1; action: "switch-branch"; provenance: { source: "plastic"; producer: "@aefree/pi-plastic"; contentTrust: "external" }; sourceAdmission: "windows-cm11.0.16.10371-observed"; completeness: { read: "complete" | "incomplete" | "unknown"; capture: "complete" | "incomplete" | "unknown"; projection: true }; data: SwitchData };
|
|
12
|
+
export type SwitchReceipt = Base & ({ ok: true; outcome: "preflight" | "already-loaded" | "switched" } | { ok: false; outcome: "canceled" | "blocked" | "failed" | "uncertain" | "command-completed-unverified"; error: { code: "invalid_request" | "unsupported_source" | "observation_failed" | "policy_canceled" | "policy_blocked" | "mutation_failed" | "target_unverified" | "producer_failed"; stage: "input" | SwitchStepName | "policy" | "producer"; message: string } });
|
|
13
|
+
export const safeSwitchValue = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 4096 && value === value.trim() && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(value);
|
|
14
|
+
const identity = (value: string) => safeSwitchValue(value) && !/[?\uFFFD]/u.test(value);
|
|
15
|
+
const hierarchy = (value: string) => identity(value) && /^\/(?:[^/@\\]+)(?:\/[^/@\\]+)*$/u.test(value);
|
|
16
|
+
export function parseSwitchTarget(value: string) {
|
|
17
|
+
const text = value.replace(/^br:/i, "");
|
|
18
|
+
const m = /^(\/[^@]+)(?:@([^@]+)@([^@]+(?:@(?:cloud|unity))?))?$/u.exec(text);
|
|
19
|
+
if (!safeSwitchValue(value) || !m || !hierarchy(m[1]) || m[2] !== undefined && (!identity(m[2]) || !identity(m[3]))) return null;
|
|
20
|
+
return { branch: m[1], repository: m[2] ?? null, server: m[3] ?? null };
|
|
21
|
+
}
|
|
22
|
+
export function parseSwitchLoadedBranch(text: string): SwitchBranchObservation | null {
|
|
23
|
+
const lines = text.split(/\r?\n/);
|
|
24
|
+
const grammar = /^(\/[^@]+)@([^@]+)@([^\s]+) \(cs:(?:0|[1-9][0-9]{0,19}) - head\)$/u;
|
|
25
|
+
const match = grammar.exec(lines[0] ?? "");
|
|
26
|
+
if (!match || lines.slice(1).some(line => grammar.test(line)) || !hierarchy(match[1]) || !identity(match[2]) || !identity(match[3]) || !safeSwitchValue(`${match[1]}@${match[2]}@${match[3]}`) || !parseSwitchTarget(`${match[1]}@${match[2]}@${match[3]}`)) return null;
|
|
27
|
+
return { branch: match[1], repository: match[2], server: match[3], basis: "standard-status", scope: "loaded-workspace" };
|
|
28
|
+
}
|
|
29
|
+
export function compareSwitchTarget(observed: SwitchBranchObservation, requested: string): SwitchData["targetVerification"] {
|
|
30
|
+
const target = parseSwitchTarget(requested);
|
|
31
|
+
if (!target || observed.branch !== target.branch) return "unverified";
|
|
32
|
+
if (target.repository === null) return "name-only";
|
|
33
|
+
return target.repository === observed.repository && target.server === observed.server ? "exact-qualifier-spelling" : "unverified";
|
|
34
|
+
}
|
|
35
|
+
const decimal = /^(0|[1-9][0-9]{0,19})$/;
|
|
36
|
+
const absolute = (value: string) => identity(value) && /^(?:[A-Za-z]:[\\/]|\\\\)/.test(value);
|
|
37
|
+
export const switchPendingArgv = ["status", "--machinereadable", "--includeRevId", "--fieldseparator=\x1f"];
|
|
38
|
+
/** Selected strict pending source; legacy selector and checkin's 500-record contract stay untouched. */
|
|
39
|
+
export function parseSwitchPending(text: string, cwd: string) {
|
|
40
|
+
const lines = text.split(/\r?\n/); if (lines.at(-1) === "") lines.pop();
|
|
41
|
+
const records = Math.min(lines.length, 20001), h = (lines[0] ?? "").split("\x1f");
|
|
42
|
+
let admitted = lines.length > 0 && lines.length <= 20000 && h.length === 4 && h[0] === "STATUS" && decimal.test(h[1]) && identity(h[2] ?? "") && identity(h[3] ?? "");
|
|
43
|
+
for (const line of lines.slice(1)) {
|
|
44
|
+
const f = line.split("\x1f");
|
|
45
|
+
if (f[0] === "MV") {
|
|
46
|
+
if (f.length !== 7 || f[1] !== "100%" || !absolute(f[2]) || !absolute(f[3]) || f[4] !== "False" || !decimal.test(f[5]) || f[5] === "0" || f[6] !== "NO_MERGES") admitted = false;
|
|
47
|
+
} else if (f.length !== 5 || !/^(PR|AD|CH|LD)$/.test(f[0]) || !absolute(f[1]) || !/^(True|False)$/.test(f[2]) || !(/^(PR|AD)$/.test(f[0]) ? f[3] === "-1" : decimal.test(f[3]) && f[3] !== "0") || f[4] !== "NO_MERGES") admitted = false;
|
|
48
|
+
}
|
|
49
|
+
if (!admitted) return { admitted: false, records, summary: null };
|
|
50
|
+
const items = parseMachineReadablePendingItems(text, cwd);
|
|
51
|
+
if (items.length !== lines.length - 1) return { admitted: false, records, summary: null };
|
|
52
|
+
const { privatePaths: _privatePaths, ...summary } = summarizePendingItems(items, cwd);
|
|
53
|
+
return { admitted: true, records, summary };
|
|
54
|
+
}
|
|
55
|
+
/** Genuine empty shelve source: exit1, empty stdout and exact workspace-specific error, never a loose substring. */
|
|
56
|
+
export function admittedSwitchNoChanges(stdout: string, stderr: string, cwd: string): boolean {
|
|
57
|
+
const m = /^Error: There are no changes in the workspace ([^\r\n]+)\r?\n?$/u.exec(stderr);
|
|
58
|
+
const key = (s: string) => s.replace(/\\/g, "/").replace(/\/$/, "").toLowerCase();
|
|
59
|
+
return stdout === "" && !!m && safeSwitchValue(m[1]) && key(m[1]) === key(cwd);
|
|
60
|
+
}
|