@aefree/pi-plastic 0.7.6 → 0.8.0
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 +37 -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 +116 -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 +149 -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,42 @@
|
|
|
1
|
+
import { win32 } from "node:path";
|
|
2
|
+
import { parseSwitchLoadedBranch, parseSwitchTarget, safeSwitchValue } from "./switch-contract";
|
|
3
|
+
|
|
4
|
+
/** Observed Windows CM copied-add profile only. No generic pending/parser migration. */
|
|
5
|
+
export const copiedPendingArgv = ["status", "--machinereadable", "--includeRevId", "--fieldseparator=\x1f"];
|
|
6
|
+
const positive = /^[1-9][0-9]{0,19}$/;
|
|
7
|
+
const qualifiedPath = (p: string) => /^(?:[A-Za-z]:[\\/]|\\\\[^\\/]+[\\/][^\\/]+[\\/])/.test(p);
|
|
8
|
+
export const copiedPathKey = (path: string) => win32.normalize(path).toLowerCase();
|
|
9
|
+
export type CopiedPending = { changeset: string; repository: string; server: string; mergeChangeset: string; paths: string[] };
|
|
10
|
+
export function parseCopiedPending(text: string, cwd: string): CopiedPending | null {
|
|
11
|
+
const lines = text.split(/\r?\n/); if (lines.at(-1) === "") lines.pop();
|
|
12
|
+
if (lines.length < 2 || lines.length > 129 || Buffer.byteLength(text, "utf8") > 65536 || !qualifiedPath(cwd)) return null;
|
|
13
|
+
const h = lines[0].split("\x1f");
|
|
14
|
+
if (h.length !== 4 || h[0] !== "STATUS" || !/^(0|[1-9][0-9]{0,19})$/.test(h[1]) || !h.slice(2).every(v => safeSwitchValue(v) && !/[?\uFFFD]/u.test(v))) return null;
|
|
15
|
+
const paths: string[] = []; let mergeChangeset = "";
|
|
16
|
+
for (const line of lines.slice(1)) {
|
|
17
|
+
const f = line.split("\x1f"), m = /^Merge from ([1-9][0-9]{0,19})$/.exec(f[4] ?? "");
|
|
18
|
+
if (f.length !== 5 || f[0] !== "CP" || !safeSwitchValue(f[1]) || /[?\uFFFD]/u.test(f[1]) || !qualifiedPath(f[1]) || f[2] !== "False" || !positive.test(f[3]) || !m) return null;
|
|
19
|
+
const relative = win32.relative(cwd, f[1]);
|
|
20
|
+
if (!relative || relative === ".." || relative.startsWith("..\\") || win32.isAbsolute(relative) || paths.some(p => copiedPathKey(p) === copiedPathKey(f[1]))) return null;
|
|
21
|
+
if (mergeChangeset && mergeChangeset !== m[1]) return null;
|
|
22
|
+
mergeChangeset = m[1]; paths.push(f[1]);
|
|
23
|
+
}
|
|
24
|
+
return { changeset: h[1], repository: h[2], server: h[3], mergeChangeset, paths };
|
|
25
|
+
}
|
|
26
|
+
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
27
|
+
/** Full status is checked for *only* the sourced link/Added/copied rows. Volatile
|
|
28
|
+
* size/time columns are opaque display, never facts or identities. Unknown sections
|
|
29
|
+
* (including in-progress/conflict hints), duplicate paths/links and mixed copies fail. */
|
|
30
|
+
export function admitCopiedStandardStatus(text: string, pending: CopiedPending, source: string, cwd: string): boolean {
|
|
31
|
+
const lines = text.split(/\r?\n/).filter(l => l.trim() !== ""), branch = parseSwitchLoadedBranch(text), requested = parseSwitchTarget(source);
|
|
32
|
+
if (!branch || !requested || branch.repository !== pending.repository || branch.server !== pending.server || !lines[0].endsWith(`(cs:${pending.changeset} - head)`) || lines.length !== pending.paths.length + 5 || lines[1] !== "Pending merge links" || lines[3] !== "Added" || !/^ Status +Size +Last Modified +Path *$/.test(lines[4])) return false;
|
|
33
|
+
const link = /^ Merge from cs:([1-9][0-9]{0,19}) at (.+)$/.exec(lines[2]), linked = link && parseSwitchTarget(link[2]);
|
|
34
|
+
if (!link || link[1] !== pending.mergeChangeset || !linked || linked.branch !== requested.branch || linked.repository !== pending.repository || linked.server !== pending.server || requested.repository !== null && (requested.repository !== linked.repository || requested.server !== linked.server)) return false;
|
|
35
|
+
const remaining = new Set(pending.paths.map(p => win32.relative(cwd, p)));
|
|
36
|
+
for (const row of lines.slice(5)) {
|
|
37
|
+
const candidates = [...remaining].filter(path => new RegExp(`^ Copied \\(new\\) \\(Merge from ${pending.mergeChangeset}\\) {2,}[0-9]+ bytes {2,}[^\\r\\n\\x00-\\x1f\\x7f]{1,128}? {2,}${escape(path)} *$`).test(row));
|
|
38
|
+
if (candidates.length !== 1) return false;
|
|
39
|
+
remaining.delete(candidates[0]);
|
|
40
|
+
}
|
|
41
|
+
return remaining.size === 0;
|
|
42
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { DOMParser, type Element } from "@xmldom/xmldom";
|
|
2
|
+
|
|
3
|
+
export class DiffBaseXmlError extends Error { constructor() { super("Loaded-file XML did not match the diff source contract."); } }
|
|
4
|
+
function invalid(): never { throw new DiffBaseXmlError(); }
|
|
5
|
+
export function parseDiffXml(bytes: Uint8Array, rootName: string): Element {
|
|
6
|
+
if (bytes.byteLength > 1048576) invalid();
|
|
7
|
+
let text: string;
|
|
8
|
+
try { text = new TextDecoder("utf-8", { fatal:true }).decode(bytes); } catch { return invalid(); }
|
|
9
|
+
if (!/^<\?xml\s+version=["']1\.0["']\s+encoding=["']utf-8["']\s*\?>/i.test(text) || /<!DOCTYPE|<!ENTITY/i.test(text)) invalid();
|
|
10
|
+
let document;
|
|
11
|
+
try { document = new DOMParser({ locator:false, onError:invalid }).parseFromString(text,"application/xml"); } catch { return invalid(); }
|
|
12
|
+
const root = document.documentElement;
|
|
13
|
+
if (!root || root.tagName !== rootName || document.doctype) invalid();
|
|
14
|
+
const stack: Array<[Element,number]> = [[root,1]];
|
|
15
|
+
let count=0;
|
|
16
|
+
while(stack.length) {
|
|
17
|
+
const [e,depth]=stack.pop()!;
|
|
18
|
+
if (++count>20000 || depth>32 || e.attributes.length || e.namespaceURI) invalid();
|
|
19
|
+
let length=0;
|
|
20
|
+
for(let n=e.firstChild;n;n=n.nextSibling) {
|
|
21
|
+
if(n.nodeType===1) stack.push([n as Element,depth+1]);
|
|
22
|
+
else if(n.nodeType===3 || n.nodeType===4) length+=(n.nodeValue??"").length;
|
|
23
|
+
else if(n.nodeType!==8) invalid();
|
|
24
|
+
}
|
|
25
|
+
if(length>8192) invalid();
|
|
26
|
+
}
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
export function diffXmlChildren(e:Element, allowed:ReadonlySet<string>):Map<string,Element> {
|
|
30
|
+
const result=new Map<string,Element>();
|
|
31
|
+
for(let n=e.firstChild;n;n=n.nextSibling) {
|
|
32
|
+
if(n.nodeType===1) { const child=n as Element; if(!allowed.has(child.tagName)||result.has(child.tagName)) invalid();result.set(child.tagName,child); }
|
|
33
|
+
else if(n.nodeType!==8 && (n.nodeValue??"").trim()) invalid();
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
export function diffXmlLeaf(e:Element|undefined):string { if(!e||e.getElementsByTagName("*").length)invalid();return e.textContent??""; }
|
|
38
|
+
const decimal=(s:string)=>{if(!/^(?:0|[1-9][0-9]{0,19})$/.test(s))invalid();return s;};
|
|
39
|
+
const safe=(s:string,empty=false)=>{if((!empty&&!s)||s.length>4096||/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s))invalid();return s;};
|
|
40
|
+
export type LoadedFileInfo={clientPath:string;serverPath:string;changeset:string;hash:string;repSpec:string;status:string;type:string};
|
|
41
|
+
export function parseLoadedFileInfo(bytes:Uint8Array):LoadedFileInfo {
|
|
42
|
+
const root=parseDiffXml(bytes,"FileInfos");
|
|
43
|
+
const row=diffXmlChildren(root,new Set(["FileInfo"])).get("FileInfo");if(!row)invalid();
|
|
44
|
+
const fields=diffXmlChildren(row,new Set(["ClientPath","ServerPath","RevisionChangeset","Hash","RepSpec","Status","Type"]));
|
|
45
|
+
const get=(name:string)=>diffXmlLeaf(fields.get(name));
|
|
46
|
+
const result={clientPath:safe(get("ClientPath")),serverPath:safe(get("ServerPath")),changeset:decimal(get("RevisionChangeset")),hash:safe(get("Hash")),repSpec:safe(get("RepSpec"),true),status:safe(get("Status")),type:safe(get("Type"))};
|
|
47
|
+
if(!result.serverPath.startsWith("/") || !/^[A-Za-z0-9+/]+={0,2}$/.test(result.hash))invalid();
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
export type LoadedLsItem={path:string;revisionId:string;changeset:string;hash:string;algorithm:"md5"|"sha1";repository:string;server:string;type:string};
|
|
51
|
+
export function parseLoadedLs(bytes:Uint8Array):LoadedLsItem {
|
|
52
|
+
const root=parseDiffXml(bytes,"LsResults");
|
|
53
|
+
const container=diffXmlChildren(root,new Set(["LsItems"])).get("LsItems");if(!container)invalid();
|
|
54
|
+
const row=diffXmlChildren(container,new Set(["LsItem"])).get("LsItem");if(!row)invalid();
|
|
55
|
+
const allowed=new Set(["Status","Name","CurrentPath","WkPath","Size","FormattedSize","Type","Changeset","Repository","Owner","Checkout","BrId","RevId","ParentRevId","ItemId","RepId","Server","SymlinkTarget","Hash","HashAlgorithm","Chmod","Branch","Guid","ItemGuid","Date"]);
|
|
56
|
+
const fields=diffXmlChildren(row,allowed);for(const e of fields.values())diffXmlLeaf(e);
|
|
57
|
+
const get=(name:string)=>diffXmlLeaf(fields.get(name));
|
|
58
|
+
const server=safe(get("Server")),spec=safe(get("Repository"));
|
|
59
|
+
if(!spec.startsWith("rep:") || !spec.endsWith("@"+server))invalid();
|
|
60
|
+
const repository=spec.slice(4,-server.length-1);safe(repository);if(repository.includes("@"))invalid();
|
|
61
|
+
const algorithm=get("HashAlgorithm").toLowerCase();if(algorithm!=="md5"&&algorithm!=="sha1")invalid();
|
|
62
|
+
const result:LoadedLsItem={path:safe(get("CurrentPath")),revisionId:decimal(get("RevId")),changeset:decimal(get("Changeset")),hash:safe(get("Hash")),algorithm,repository,server,type:safe(get("Type"))};
|
|
63
|
+
if(!/^[A-Za-z0-9+/]+={0,2}$/.test(result.hash))invalid();
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Element } from "@xmldom/xmldom";
|
|
2
|
+
import { parseDiffXml, diffXmlChildren, diffXmlLeaf, DiffBaseXmlError } from "./diff-base-xml";
|
|
3
|
+
export type DiffPendingKind="changed"|"added"|"deleted"|"moved"|"private"|"checkedout"|"copied"|"replaced";
|
|
4
|
+
export type DiffPendingItem={path:string;sourcePath:string|null;kind:DiffPendingKind;code:string;directory:boolean};
|
|
5
|
+
export type DiffPendingSnapshot={items:DiffPendingItem[];repository:string;server:string;changeset:string};
|
|
6
|
+
const kinds={CH:"changed",AD:"added",DE:"deleted",LD:"deleted",MV:"moved",LM:"moved",PR:"private",CO:"checkedout","CO+CH":"changed",CP:"copied",RP:"replaced"} as const;
|
|
7
|
+
function invalid():never {throw new DiffBaseXmlError();}
|
|
8
|
+
const text=(e:Element|undefined)=>diffXmlLeaf(e);
|
|
9
|
+
const path=(s:string)=>{if(!s||s.length>4096||!(/^[A-Za-z]:[\\/]/.test(s)||s.startsWith("/"))||/[\u0000-\u001f\u007f-\u009f]/.test(s)||(process.platform==="win32"&&s.includes("?")))invalid();return s;};
|
|
10
|
+
export function parseDiffPending(bytes:Uint8Array):DiffPendingSnapshot {
|
|
11
|
+
const root=diffXmlChildren(parseDiffXml(bytes,"StatusOutput"),new Set(["WorkspaceStatus","WkConfigType","WkConfigName","Changes"]));
|
|
12
|
+
if(!text(root.get("WkConfigType"))||!text(root.get("WkConfigName")))invalid();
|
|
13
|
+
const workspace=root.get("WorkspaceStatus");if(!workspace)invalid();
|
|
14
|
+
const status=diffXmlChildren(workspace,new Set(["Status"])).get("Status");if(!status)invalid();
|
|
15
|
+
const context=diffXmlChildren(status,new Set(["RepSpec","Changeset"]));
|
|
16
|
+
const rep=context.get("RepSpec");if(!rep)invalid();
|
|
17
|
+
const identity=diffXmlChildren(rep,new Set(["Server","Name"]));
|
|
18
|
+
const repository=text(identity.get("Name")),server=text(identity.get("Server")),changeset=text(context.get("Changeset"));
|
|
19
|
+
if(!repository||!server||! /^(?:0|[1-9][0-9]{0,19})$/.test(changeset)||/[;\u0000-\u001f\u007f-\u009f]/.test(repository+server)||repository.includes("@"))invalid();
|
|
20
|
+
const items:DiffPendingItem[]=[],seen=new Set<string>();
|
|
21
|
+
const changes=root.get("Changes");
|
|
22
|
+
const allowed=new Set(["Type","TypeVerbose","Path","OldPath","PrintableMovedPath","MergesInfo","SimilarityPerUnit","Similarity","Size","PrintableSize","PrintableLastModified","RevisionType","LastModified"]);
|
|
23
|
+
if(changes)for(let n=changes.firstChild;n;n=n.nextSibling) {
|
|
24
|
+
if(n.nodeType!==1){if(n.nodeType!==8&&(n.nodeValue??"").trim())invalid();continue;}
|
|
25
|
+
const e=n as Element;if(e.tagName!=="Change"||items.length>=20000)invalid();
|
|
26
|
+
const fields=diffXmlChildren(e,allowed);for(const e of fields.values())text(e);
|
|
27
|
+
const code=text(fields.get("Type"));if(!Object.hasOwn(kinds,code))invalid();
|
|
28
|
+
const kind=kinds[code as keyof typeof kinds],p=path(text(fields.get("Path"))),old=text(fields.get("OldPath")),revisionType=text(fields.get("RevisionType"));
|
|
29
|
+
if((kind==="moved")!==Boolean(old)||old&&path(old)===p||seen.has(p))invalid();seen.add(p);
|
|
30
|
+
if(revisionType!=="enDirectory"&&revisionType!=="enTextFile"&&revisionType!=="enBinaryFile")invalid();
|
|
31
|
+
items.push({path:p,sourcePath:old||null,kind,code,directory:revisionType==="enDirectory"});
|
|
32
|
+
}
|
|
33
|
+
return {items,repository,server,changeset};
|
|
34
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { normalizeFindOutputLines } from "../execution/cm";
|
|
2
|
+
import { splitServerBranch, type ServerChangeset } from "./server-merge-contract";
|
|
3
|
+
|
|
4
|
+
export type MergeStatusSummary = {
|
|
5
|
+
hasPendingMergeLinks: boolean;
|
|
6
|
+
pendingMergeLinks: string[];
|
|
7
|
+
mergeInProgressHints: string[];
|
|
8
|
+
hasMergeInProgress: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const analyzeMergeStatusOutput = (output: string): MergeStatusSummary =>
|
|
12
|
+
{
|
|
13
|
+
const lines = normalizeFindOutputLines(output);
|
|
14
|
+
const pendingMergeLinks: string[] = [];
|
|
15
|
+
const mergeInProgressHints: string[] = [];
|
|
16
|
+
let inPendingMergeLinksSection = false;
|
|
17
|
+
|
|
18
|
+
for (const line of lines)
|
|
19
|
+
{
|
|
20
|
+
const trimmed = line.trim();
|
|
21
|
+
const normalized = trimmed.toLowerCase();
|
|
22
|
+
|
|
23
|
+
if (normalized === "pending merge links")
|
|
24
|
+
{
|
|
25
|
+
inPendingMergeLinksSection = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (inPendingMergeLinksSection)
|
|
30
|
+
{
|
|
31
|
+
if (trimmed.length === 0)
|
|
32
|
+
{
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (/^(changed|added|deleted|moved|private|controlled changes|items changed|local changes)\b/i.test(trimmed))
|
|
37
|
+
{
|
|
38
|
+
inPendingMergeLinksSection = false;
|
|
39
|
+
}
|
|
40
|
+
else if (/^merge\s+from\b/i.test(trimmed))
|
|
41
|
+
{
|
|
42
|
+
pendingMergeLinks.push(trimmed);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (/merge\s+in\s+progress|finish\s+it\s+before\s+checkin|unresolved\s+merge|pending\s+conflicts/i.test(trimmed))
|
|
48
|
+
{
|
|
49
|
+
mergeInProgressHints.push(trimmed);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
hasPendingMergeLinks: pendingMergeLinks.length > 0,
|
|
55
|
+
pendingMergeLinks,
|
|
56
|
+
mergeInProgressHints,
|
|
57
|
+
hasMergeInProgress: mergeInProgressHints.length > 0,
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export type MergeMachineReadableRecord = {
|
|
62
|
+
raw: string;
|
|
63
|
+
fields: string[];
|
|
64
|
+
operation: string;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type MergeOutputSummary = {
|
|
68
|
+
recordCount: number;
|
|
69
|
+
conflictSignals: string[];
|
|
70
|
+
unresolvedSignals: string[];
|
|
71
|
+
warningSignals: string[];
|
|
72
|
+
fileConflictSignals: string[];
|
|
73
|
+
fileConflictPaths: string[];
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const MERGE_START_LINE_SEPARATOR = "__OC_MR_START__";
|
|
77
|
+
export const MERGE_END_LINE_SEPARATOR = "__OC_MR_END__";
|
|
78
|
+
export const MERGE_FIELD_SEPARATOR = "__OC_MR_FIELD__";
|
|
79
|
+
|
|
80
|
+
export const mergeConflictSignalPattern = /\b(conflict|eviltwin|changedelete|deletechange|movedelete|deletemove|loadedtwice|addmove|moveadd|divergentmove|cyclemove|movedeviltwin)\b/i;
|
|
81
|
+
export const mergeUnresolvedSignalPattern = /\b(unresolved|cannot\s+resolve|can't\s+resolve|manual\s+conflict|not\s+solved|failed\s+to\s+resolve)\b/i;
|
|
82
|
+
export const mergeWarningSignalPattern = /\bwarn(?:ing)?\b/i;
|
|
83
|
+
export const mergeFileConflictOperation = "FILE_CONFLICT";
|
|
84
|
+
|
|
85
|
+
export const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
86
|
+
|
|
87
|
+
export const normalizeErrorMessage = (error: unknown): string =>
|
|
88
|
+
{
|
|
89
|
+
if (error instanceof Error)
|
|
90
|
+
{
|
|
91
|
+
return error.message;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return String(error ?? "Unknown error");
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
export const parseMergeMachineReadableRecords = (output: string): MergeMachineReadableRecord[] =>
|
|
98
|
+
{
|
|
99
|
+
const pattern = new RegExp(`${escapeRegex(MERGE_START_LINE_SEPARATOR)}([\\s\\S]*?)${escapeRegex(MERGE_END_LINE_SEPARATOR)}`, "g");
|
|
100
|
+
const records: MergeMachineReadableRecord[] = [];
|
|
101
|
+
|
|
102
|
+
for (const match of output.matchAll(pattern))
|
|
103
|
+
{
|
|
104
|
+
const raw = String(match[1] ?? "").trim();
|
|
105
|
+
if (!raw)
|
|
106
|
+
{
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const fields = raw
|
|
111
|
+
.split(MERGE_FIELD_SEPARATOR)
|
|
112
|
+
.map((field) => field.trim())
|
|
113
|
+
.filter((field) => field.length > 0);
|
|
114
|
+
const operation = fields[0] ?? "";
|
|
115
|
+
records.push({
|
|
116
|
+
raw,
|
|
117
|
+
fields,
|
|
118
|
+
operation,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return records;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export const summarizeMergeOutput = (output: string): MergeOutputSummary =>
|
|
126
|
+
{
|
|
127
|
+
const records = parseMergeMachineReadableRecords(output);
|
|
128
|
+
const textLines = normalizeFindOutputLines(output);
|
|
129
|
+
const conflictSignals = new Set<string>();
|
|
130
|
+
const unresolvedSignals = new Set<string>();
|
|
131
|
+
const warningSignals = new Set<string>();
|
|
132
|
+
const fileConflictSignals = new Set<string>();
|
|
133
|
+
const fileConflictPaths = new Set<string>();
|
|
134
|
+
|
|
135
|
+
for (const record of records)
|
|
136
|
+
{
|
|
137
|
+
const mergedText = record.fields.join(" ");
|
|
138
|
+
const isFileConflict = record.operation.toUpperCase() === mergeFileConflictOperation;
|
|
139
|
+
|
|
140
|
+
if (isFileConflict)
|
|
141
|
+
{
|
|
142
|
+
conflictSignals.add(record.raw);
|
|
143
|
+
unresolvedSignals.add(record.raw);
|
|
144
|
+
fileConflictSignals.add(record.raw);
|
|
145
|
+
if (record.fields[1])
|
|
146
|
+
{
|
|
147
|
+
fileConflictPaths.add(record.fields[1]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (mergeConflictSignalPattern.test(mergedText))
|
|
151
|
+
{
|
|
152
|
+
conflictSignals.add(record.raw);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (mergeUnresolvedSignalPattern.test(mergedText))
|
|
156
|
+
{
|
|
157
|
+
unresolvedSignals.add(record.raw);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (record.operation.toUpperCase().includes("WARN") || mergeWarningSignalPattern.test(mergedText))
|
|
161
|
+
{
|
|
162
|
+
warningSignals.add(record.raw);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
for (const line of textLines)
|
|
167
|
+
{
|
|
168
|
+
if (/\bFILE_CONFLICT\b/i.test(line))
|
|
169
|
+
{
|
|
170
|
+
conflictSignals.add(line);
|
|
171
|
+
unresolvedSignals.add(line);
|
|
172
|
+
fileConflictSignals.add(line);
|
|
173
|
+
}
|
|
174
|
+
else if (mergeConflictSignalPattern.test(line))
|
|
175
|
+
{
|
|
176
|
+
conflictSignals.add(line);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (mergeUnresolvedSignalPattern.test(line))
|
|
180
|
+
{
|
|
181
|
+
unresolvedSignals.add(line);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (/DIS_OP_WARN|\bwarn(?:ing)?\b/i.test(line))
|
|
185
|
+
{
|
|
186
|
+
warningSignals.add(line);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
recordCount: records.length,
|
|
192
|
+
conflictSignals: Array.from(conflictSignals),
|
|
193
|
+
unresolvedSignals: Array.from(unresolvedSignals),
|
|
194
|
+
warningSignals: Array.from(warningSignals),
|
|
195
|
+
fileConflictSignals: Array.from(fileConflictSignals),
|
|
196
|
+
fileConflictPaths: Array.from(fileConflictPaths),
|
|
197
|
+
};
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export type ServerMergeRecord = {
|
|
201
|
+
operation: string;
|
|
202
|
+
fields: string[];
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
export type ServerMergeParse = {
|
|
206
|
+
records: ServerMergeRecord[];
|
|
207
|
+
malformed: boolean;
|
|
208
|
+
unknownOperations: string[];
|
|
209
|
+
changesets: ServerChangeset[];
|
|
210
|
+
isAlreadyConnected: boolean;
|
|
211
|
+
hasConflict: boolean;
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
export const parseServerMergeOutput = (output: string, separators: { start: string; end: string; field: string }): ServerMergeParse =>
|
|
215
|
+
{
|
|
216
|
+
const records: ServerMergeRecord[] = [];
|
|
217
|
+
const unknownOperations = new Set<string>();
|
|
218
|
+
let malformed = false;
|
|
219
|
+
let cursor = 0;
|
|
220
|
+
|
|
221
|
+
while (true)
|
|
222
|
+
{
|
|
223
|
+
const start = output.indexOf(separators.start, cursor);
|
|
224
|
+
if (start < 0)
|
|
225
|
+
{
|
|
226
|
+
if (output.slice(cursor).trim() !== "")
|
|
227
|
+
{
|
|
228
|
+
malformed = true;
|
|
229
|
+
}
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
const prefix = output.slice(cursor, start);
|
|
233
|
+
if (prefix.trim() !== "")
|
|
234
|
+
{
|
|
235
|
+
malformed = true;
|
|
236
|
+
}
|
|
237
|
+
const payloadStart = start + separators.start.length;
|
|
238
|
+
const end = output.indexOf(separators.end, payloadStart);
|
|
239
|
+
if (end < 0)
|
|
240
|
+
{
|
|
241
|
+
malformed = true;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
const payload = output.slice(payloadStart, end);
|
|
245
|
+
if (!payload || payload.includes(separators.start) || payload.includes(separators.end))
|
|
246
|
+
{
|
|
247
|
+
malformed = true;
|
|
248
|
+
}
|
|
249
|
+
else
|
|
250
|
+
{
|
|
251
|
+
const fields = payload.split(separators.field);
|
|
252
|
+
if (records.length >= 500) { malformed = true; break; }
|
|
253
|
+
const operation = fields[0] ?? "";
|
|
254
|
+
if (!operation || fields.some((field) => field.length === 0))
|
|
255
|
+
{
|
|
256
|
+
malformed = true;
|
|
257
|
+
}
|
|
258
|
+
else
|
|
259
|
+
{
|
|
260
|
+
records.push({ operation, fields });
|
|
261
|
+
if (!["FILE_SRC", "CHANGESET", "STATUS", "FILE_CONFLICT"].includes(operation))
|
|
262
|
+
{
|
|
263
|
+
unknownOperations.add(operation);
|
|
264
|
+
malformed = true;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
cursor = end + separators.end.length;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const changesets: ServerChangeset[] = [];
|
|
272
|
+
const decimal = (s: string) => /^(0|[1-9][0-9]{0,19})$/.test(s);
|
|
273
|
+
const safe = (s: string) => s.length <= 4096 && !/[\u0000-\u001f\u007f-\u009f]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(s);
|
|
274
|
+
let isAlreadyConnected = false;
|
|
275
|
+
let hasConflict = false;
|
|
276
|
+
for (const record of records)
|
|
277
|
+
{
|
|
278
|
+
switch (record.operation)
|
|
279
|
+
{
|
|
280
|
+
case "FILE_SRC":
|
|
281
|
+
if (record.fields.length !== 5 || !record.fields[1]?.startsWith("/") || !record.fields.slice(2).every(decimal) || !safe(record.fields[1]!))
|
|
282
|
+
{
|
|
283
|
+
malformed = true;
|
|
284
|
+
}
|
|
285
|
+
break;
|
|
286
|
+
case "CHANGESET":
|
|
287
|
+
{
|
|
288
|
+
if (record.fields.length !== 2)
|
|
289
|
+
{
|
|
290
|
+
malformed = true;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
const match = record.fields[1]?.match(/^cs:([^@]+)@(.+) \(mount:'([^']+)'\)$/);
|
|
294
|
+
const identity = match ? splitServerBranch("br:" + match[2]) : null;
|
|
295
|
+
if (!match || !identity || !decimal(match[1]!) || !safe(record.fields[1]!) || !safe(match[3]!))
|
|
296
|
+
{
|
|
297
|
+
malformed = true;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
changesets.push({
|
|
301
|
+
id: match[1]!,
|
|
302
|
+
...identity,
|
|
303
|
+
mount: match[3]!,
|
|
304
|
+
});
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
case "STATUS":
|
|
308
|
+
if (record.fields.length !== 3 || record.fields[1] !== "ALREADY_CONNECTED" || record.fields[2] !== "No merges detected" || isAlreadyConnected)
|
|
309
|
+
{
|
|
310
|
+
malformed = true;
|
|
311
|
+
}
|
|
312
|
+
isAlreadyConnected = true;
|
|
313
|
+
break;
|
|
314
|
+
case "FILE_CONFLICT":
|
|
315
|
+
if (record.fields.length !== 6 || !record.fields[1]?.startsWith("/") || !record.fields.slice(2).every(decimal) || !safe(record.fields[1]!))
|
|
316
|
+
{
|
|
317
|
+
malformed = true;
|
|
318
|
+
}
|
|
319
|
+
hasConflict = true;
|
|
320
|
+
break;
|
|
321
|
+
default:
|
|
322
|
+
// Unknown operations were recorded above and make the response uncertain.
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return { records, malformed, unknownOperations: [...unknownOperations], changesets, isAlreadyConnected, hasConflict };
|
|
327
|
+
};
|