@aefree/pi-plastic 0.7.5 → 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 +51 -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 +12 -6
- 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,105 @@
|
|
|
1
|
+
import { captureWorkspaceMergeCommand, emptyWorkspaceMergeAttempt } from "../execution/workspace-merge-command";
|
|
2
|
+
|
|
3
|
+
export const OBJECT_WRITE_ACTIONS = ["shelveset-create", "shelveset-apply", "code-review-create", "code-review-update"] as const;
|
|
4
|
+
export type ObjectWriteAction = typeof OBJECT_WRITE_ACTIONS[number];
|
|
5
|
+
type Common = { workdir: string; preflight: boolean; output: "text"|"json" };
|
|
6
|
+
export type ObjectWriteRequests = {
|
|
7
|
+
"shelveset-create": Common & { paths: string[]|null; comment: string|null; commentsFile: string|null; all: boolean; dependencies: boolean; summaryFormat: boolean };
|
|
8
|
+
"shelveset-apply": Common & { shelveset: string; changePaths: string[]|null; preview: boolean; dontCheckout: boolean; comparisonMethod: string|null };
|
|
9
|
+
"code-review-create": Common & { target: string; title: string; status: string|null; assignee: string|null; repository: string|null; format: string|null };
|
|
10
|
+
"code-review-update": Common & { id: string; status: string|null; assignee: string|null; repository: string|null };
|
|
11
|
+
};
|
|
12
|
+
export type ObjectWriteRequest = ObjectWriteRequests[ObjectWriteAction];
|
|
13
|
+
export type EmittedObjectIdentity = { kind: "shelveset"|"code-review"; value: string; basis: "default-scalar"|"summary-scalar" };
|
|
14
|
+
export type ObjectWriteData = {
|
|
15
|
+
requested: ObjectWriteRequest|null; intendedArgv: string[];
|
|
16
|
+
attempt: ReturnType<typeof emptyWorkspaceMergeAttempt>;
|
|
17
|
+
capture: Awaited<ReturnType<typeof captureWorkspaceMergeCommand>>["capture"]|null;
|
|
18
|
+
commandOutput: { kind: "opaque-command-output"; text: string }|null;
|
|
19
|
+
observedCreatedIdentity: EmittedObjectIdentity|null;
|
|
20
|
+
observedAppliedItems: null; observedUpdatedState: null; observedRepositoryIdentity: null;
|
|
21
|
+
scope: "requested-only"; rollback: "not-proven";
|
|
22
|
+
effect: "not-attempted"|"not-proven"|"identity-emitted"|"uncertain";
|
|
23
|
+
};
|
|
24
|
+
export type ObjectWriteReceipt<A extends ObjectWriteAction=ObjectWriteAction> = {
|
|
25
|
+
schemaVersion:1; action:A; provenance:{source:"plastic";producer:"@aefree/pi-plastic";contentTrust:"external"};
|
|
26
|
+
completeness:{capture:"complete"|"incomplete"|"unknown";projection:true};
|
|
27
|
+
data: ObjectWriteData & {requested:ObjectWriteRequests[A]|null};
|
|
28
|
+
} & ({ok:true;outcome:"preflight"|"command-completed"}|{ok:false;outcome:"failed"|"uncertain";error:{code:"invalid_request"|"editor_configured"|"launch_failed"|"aborted"|"uncertain"|"producer_failed";message:string}});
|
|
29
|
+
const keys = {
|
|
30
|
+
"shelveset-create": ["paths","comment","commentsFile","all","dependencies","summaryFormat"],
|
|
31
|
+
"shelveset-apply": ["shelveset","changePaths","preview","dontCheckout","comparisonMethod"],
|
|
32
|
+
"code-review-create": ["target","title","status","assignee","repository","format"],
|
|
33
|
+
"code-review-update": ["id","status","assignee","repository"],
|
|
34
|
+
} as const;
|
|
35
|
+
const comparisons=["ignoreeol","ignorewhitespaces","ignoreeolandwhitespaces","recognizeall"];
|
|
36
|
+
export function validWriteText(v:unknown, multiline=false, empty=false):v is string {
|
|
37
|
+
return typeof v==="string"&&v.length<=4096&&(empty||v.trim().length>0)&&!(multiline?/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/:/[\u0000-\u001f\u007f-\u009f]/).test(v)&&!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/u.test(v);
|
|
38
|
+
}
|
|
39
|
+
export function validWriteOperand(v:unknown):v is string {return validWriteText(v)&&!v.trimStart().startsWith("-");}
|
|
40
|
+
export function validWritePaths(v:unknown):v is string[] {return Array.isArray(v)&&v.length<=256&&Array.from(v).every(validWriteOperand)&&Buffer.byteLength(JSON.stringify(v),"utf8")<=32768;}
|
|
41
|
+
export function validObjectWriteRequest(a:ObjectWriteAction,r:ObjectWriteRequest):boolean {
|
|
42
|
+
if(!validWriteText(r.workdir)||typeof r.preflight!=="boolean"||!["text","json"].includes(r.output)||Buffer.byteLength(JSON.stringify(r),"utf8")>32768)return false;
|
|
43
|
+
const v=r as unknown as Record<string,unknown>;
|
|
44
|
+
if(a==="shelveset-create")return (v.paths===null||validWritePaths(v.paths))&&(v.comment===null||validWriteText(v.comment,true))&&(v.commentsFile===null||validWriteText(v.commentsFile))&&!(v.comment!==null&&v.commentsFile!==null)&&[v.all,v.dependencies,v.summaryFormat].every(x=>typeof x==="boolean");
|
|
45
|
+
if(a==="shelveset-apply")return validWriteOperand(v.shelveset)&&(v.changePaths===null||validWritePaths(v.changePaths))&&[v.preview,v.dontCheckout].every(x=>typeof x==="boolean")&&(v.comparisonMethod===null||comparisons.includes(v.comparisonMethod as string));
|
|
46
|
+
if(![v.status,v.assignee,v.repository].every(x=>x===null||validWriteText(x,false,true)))return false;
|
|
47
|
+
return a==="code-review-create"?validWriteOperand(v.target)&&validWriteOperand(v.title)&&(v.format===null||validWriteText(v.format,true,true)):validWriteOperand(v.id);
|
|
48
|
+
}
|
|
49
|
+
export function objectWritePrefix(a:ObjectWriteAction) {return a==="shelveset-create"?["shelveset","create"]:a==="shelveset-apply"?["shelveset","apply"]:a==="code-review-create"?["codereview"]:["codereview","-e"];}
|
|
50
|
+
export function objectWriteArgv(a:ObjectWriteAction,r:ObjectWriteRequest):string[] {
|
|
51
|
+
const v=r as any,argv=objectWritePrefix(a);
|
|
52
|
+
if(a==="shelveset-create") {
|
|
53
|
+
argv.push(...(v.paths??[]));if(v.all)argv.push("--all");if(v.dependencies)argv.push("--dependencies");if(v.summaryFormat)argv.push("--summaryformat");if(v.comment!==null)argv.push(`-c=${v.comment}`);if(v.commentsFile!==null)argv.push(`-commentsfile=${v.commentsFile}`);
|
|
54
|
+
} else if(a==="shelveset-apply") {
|
|
55
|
+
argv.push(v.shelveset,...(v.changePaths??[]));if(v.preview)argv.push("--preview");if(v.dontCheckout)argv.push("--dontcheckout");if(v.comparisonMethod!==null)argv.push(`--comparisonmethod=${v.comparisonMethod}`);
|
|
56
|
+
} else {
|
|
57
|
+
argv.push(...(a==="code-review-create"?[v.target,v.title]:[v.id]));if(v.status)argv.push(`--status=${v.status}`);if(v.assignee)argv.push(`--assignee=${v.assignee}`);if(v.repository)argv.push(`--repository=${v.repository}`);if(a==="code-review-create"&&v.format)argv.push(`--format=${v.format}`);
|
|
58
|
+
}
|
|
59
|
+
return argv;
|
|
60
|
+
}
|
|
61
|
+
export function parseObjectWriteIdentity(a:ObjectWriteAction,r:ObjectWriteRequest,stdout:string):EmittedObjectIdentity|null {
|
|
62
|
+
const line=stdout.replace(/\r?\n$/,""); // One emitted scalar, not a match buried in diagnostic text.
|
|
63
|
+
if(!validWriteText(line)||line!==line.trim())return null;
|
|
64
|
+
if(a==="shelveset-create"&&/^sh:[1-9][0-9]*@[^@\r\n]+@[^@\r\n]+(?:@(cloud|unity))?$/.test(line))return {kind:"shelveset",value:line,basis:(r as ObjectWriteRequests["shelveset-create"]).summaryFormat?"summary-scalar":"default-scalar"};
|
|
65
|
+
if(a==="code-review-create"&&!(r as ObjectWriteRequests["code-review-create"]).format&&/^(?:[1-9][0-9]*|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.test(line))return {kind:"code-review",value:line,basis:"default-scalar"};
|
|
66
|
+
return null; // Custom creation formatting and apply/update progress are opaque, never identity/state.
|
|
67
|
+
}
|
|
68
|
+
export function emptyObjectWriteData(a:ObjectWriteAction):ObjectWriteData {return {requested:null,intendedArgv:objectWritePrefix(a),attempt:emptyWorkspaceMergeAttempt(),capture:null,commandOutput:null,observedCreatedIdentity:null,observedAppliedItems:null,observedUpdatedState:null,observedRepositoryIdentity:null,scope:"requested-only",rollback:"not-proven",effect:"not-attempted"};}
|
|
69
|
+
function header(a:ObjectWriteAction,d:ObjectWriteData) {return {schemaVersion:1 as const,action:a,provenance:{source:"plastic" as const,producer:"@aefree/pi-plastic" as const,contentTrust:"external" as const},completeness:{capture:d.capture?d.capture.complete?"complete" as const:"incomplete" as const:"unknown" as const,projection:true as const},data:d};}
|
|
70
|
+
export function objectWriteFailure(a:ObjectWriteAction,d:ObjectWriteData,code:Extract<ObjectWriteReceipt,{ok:false}>["error"]["code"],message:string):ObjectWriteReceipt {return {...header(a,d),ok:false,outcome:["started","unknown"].includes(d.attempt.state)?"uncertain":"failed",error:{code,message}} as ObjectWriteReceipt;}
|
|
71
|
+
export async function assembleObjectWriteReceipt(a:ObjectWriteAction,args:unknown):Promise<ObjectWriteReceipt> {
|
|
72
|
+
const d=emptyObjectWriteData(a);let entered=false;
|
|
73
|
+
try {
|
|
74
|
+
if(!args||typeof args!=="object"||Array.isArray(args)||Object.keys(args).some(k=>![...keys[a],"workdir","preflight","output"].includes(k)))return objectWriteFailure(a,d,"invalid_request","Use only declared bounded object-write controls.");
|
|
75
|
+
const i={...args} as Record<string,unknown>,r:any={workdir:i.workdir===undefined?process.cwd():i.workdir,preflight:i.preflight===undefined?false:i.preflight,output:i.output===undefined?"text":i.output};
|
|
76
|
+
for(const k of keys[a]) {
|
|
77
|
+
if(Object.hasOwn(i,k)&&i[k]===null)return objectWriteFailure(a,d,"invalid_request","Explicit null controls are unsupported; omit optional controls.");
|
|
78
|
+
const v=i[k];r[k]=v===undefined?(["all","dependencies","summaryFormat","preview","dontCheckout"].includes(k)?false:null):["paths","changePaths"].includes(k)&&Array.isArray(v)&&v.length<=256?Array.from(v):v;
|
|
79
|
+
}
|
|
80
|
+
if(!validObjectWriteRequest(a,r))return objectWriteFailure(a,d,"invalid_request","Use exact non-option operands, bounded lists/text, exclusive comments, strict booleans and declared output/comparison modes.");
|
|
81
|
+
d.requested=r;d.intendedArgv=objectWriteArgv(a,r);
|
|
82
|
+
if(r.preflight)return {...header(a,d),ok:true,outcome:"preflight"} as ObjectWriteReceipt;
|
|
83
|
+
if(a==="shelveset-create"&&r.comment===null&&r.commentsFile===null&&process.env.PLASTICEDITOR?.trim())return objectWriteFailure(a,d,"editor_configured","Comment-less creation with PLASTICEDITOR configured could launch an editor. Supply comment/commentsFile; no environment override or dispatch.");
|
|
84
|
+
entered=true;const o=await captureWorkspaceMergeCommand([...d.intendedArgv],r.workdir);d.attempt=o.attempt;d.capture=o.capture;
|
|
85
|
+
const completed=o.capture.complete&&!o.failed&&o.attempt.state==="started"&&o.attempt.exitCode===0;
|
|
86
|
+
if(completed) {
|
|
87
|
+
d.commandOutput={kind:"opaque-command-output",text:o.stdout!};d.observedCreatedIdentity=parseObjectWriteIdentity(a,r,o.stdout!);
|
|
88
|
+
d.effect=d.observedCreatedIdentity?"identity-emitted":"not-proven";
|
|
89
|
+
if(o.capture.stderrBytes===0)return {...header(a,d),ok:true,outcome:"command-completed"} as ObjectWriteReceipt;
|
|
90
|
+
if(!d.observedCreatedIdentity)d.effect="uncertain";
|
|
91
|
+
} else d.effect=["started","unknown"].includes(d.attempt.state)?"uncertain":"not-attempted";
|
|
92
|
+
return objectWriteFailure(a,d,o.attempt.aborted?"aborted":["started","unknown"].includes(d.attempt.state)?"uncertain":"launch_failed","Object-write completion/state is not admitted. Earlier emitted identities remain recorded; no automatic retry, conflict choice or rollback.");
|
|
93
|
+
} catch {
|
|
94
|
+
if(entered&&d.capture===null){d.attempt={...emptyWorkspaceMergeAttempt(),state:"unknown"};d.effect="uncertain";}
|
|
95
|
+
return objectWriteFailure(a,d,"producer_failed","Object-write observation failed. Earlier possible/emitted effects remain; no automatic recovery or retry.");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
export function presentObjectWriteReceipt(d:ObjectWriteReceipt):string {
|
|
99
|
+
if(d.data.requested?.output==="json")return JSON.stringify(d);
|
|
100
|
+
const identity=d.data.observedCreatedIdentity?` Emitted ${d.data.observedCreatedIdentity.kind}: ${d.data.observedCreatedIdentity.value}.`:"";
|
|
101
|
+
if(!d.ok)return `${d.action} ${d.outcome}: ${d.error.message} Effect: ${d.data.effect}.${identity}`;
|
|
102
|
+
if(d.outcome==="preflight")return `${d.action} command-only preview (not executed): ${JSON.stringify(d.data.intendedArgv)}. No existence/conflict/state analysis.`;
|
|
103
|
+
const opaque=d.data.commandOutput?.text?`\n${d.data.commandOutput.text}`:"";
|
|
104
|
+
return `${d.action} command completed.${identity} Applied items, requested review state, repository scope and rollback remain unverified.${opaque}`;
|
|
105
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { isUtf8 } from "node:buffer";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { captureWorkspaceMergeCommand, emptyWorkspaceMergeAttempt } from "../execution/workspace-merge-command";
|
|
7
|
+
import { boundDiffOutput } from "../diff/text";
|
|
8
|
+
import { buildPatchCommandArgs, resolvePatchToolPath, resolvePatchBranchSpecs, ensurePatchOutputDoesNotExist, readBoundedPatchFile } from "./patch-support";
|
|
9
|
+
|
|
10
|
+
export const PATCH_FILE_MAX_BYTES = 4194304;
|
|
11
|
+
export const PATCH_RECEIPT_MAX_BYTES = 524288;
|
|
12
|
+
export type PatchRequest = { source: string; destination: string|null; output: string|null; toolPath: string|null; clean: boolean; integration: boolean; workdir: string; preflight: boolean; format: "text"|"json" };
|
|
13
|
+
export type PatchArtifact = { bytes: number; sha256: string; utf8: boolean; empty: boolean; binaryLimited: boolean; totalChars: number|null; truncated: boolean; content: string|null };
|
|
14
|
+
export type PatchData = {
|
|
15
|
+
requested: PatchRequest|null;
|
|
16
|
+
resolved: {source: string; destination: string|null; toolPath: string; output: string|null}|null;
|
|
17
|
+
intendedArgv: string[];
|
|
18
|
+
attempt: ReturnType<typeof emptyWorkspaceMergeAttempt>;
|
|
19
|
+
capture: Awaited<ReturnType<typeof captureWorkspaceMergeCommand>>["capture"]|null;
|
|
20
|
+
artifact: PatchArtifact|null;
|
|
21
|
+
publication: "not-requested"|"not-published"|"published"|"unknown";
|
|
22
|
+
cleanup: "not-created"|"completed"|"failed"|"retained"|"unknown";
|
|
23
|
+
retainedStagingDirectory: string|null;
|
|
24
|
+
effect: "not-attempted"|"unverified"|"artifact-observed"|"published";
|
|
25
|
+
workspaceEffects: "unverified"; moveRepresentation: "backend-determined";
|
|
26
|
+
};
|
|
27
|
+
export type PatchReceipt = {schemaVersion:1; action:"patch"; provenance:{source:"plastic";producer:"@aefree/pi-plastic";contentTrust:"external"}; completeness:{capture:"complete"|"incomplete"|"unknown";projection:true};data:PatchData}&
|
|
28
|
+
({ok:true;outcome:"preflight"|"generated"|"empty"}|{ok:false;outcome:"failed"|"uncertain";error:{code:"invalid_request"|"setup_failed"|"command_failed"|"uncertain"|"backend_incompatible"|"artifact_failed"|"publication_failed"|"cleanup_failed"|"producer_failed";message:string}});
|
|
29
|
+
export function validPatchText(v: unknown): v is string { return typeof v==="string" && v.length<=4096 && v.trim().length>0 && !/[\u0000-\u001f\u007f-\u009f]/.test(v); }
|
|
30
|
+
export function validPatchRequest(r: PatchRequest) { return validPatchText(r.source)&&!r.source.trimStart().startsWith("-")&&
|
|
31
|
+
(r.destination===null||validPatchText(r.destination)&&!r.destination.trimStart().startsWith("-"))&&
|
|
32
|
+
[r.output,r.toolPath].every(v=>v===null||validPatchText(v))&&validPatchText(r.workdir)&&typeof r.clean==="boolean"&&typeof r.integration==="boolean"&&typeof r.preflight==="boolean"&&["text","json"].includes(r.format)&&Buffer.byteLength(JSON.stringify(r),"utf8")<=32768; }
|
|
33
|
+
export function patchReceiptArgv(r: PatchRequest, v: NonNullable<PatchData["resolved"]>) {
|
|
34
|
+
return buildPatchCommandArgs({source:v.source,...(v.destination===null?{}:{destination:v.destination}),output:"<package-owned-staging-file>",toolPath:v.toolPath,clean:r.clean,integration:r.integration});
|
|
35
|
+
}
|
|
36
|
+
export function emptyPatchData(): PatchData { return {requested:null,resolved:null,intendedArgv:["patch"],attempt:emptyWorkspaceMergeAttempt(),capture:null,artifact:null,publication:"not-requested",cleanup:"not-created",retainedStagingDirectory:null,effect:"not-attempted",workspaceEffects:"unverified",moveRepresentation:"backend-determined"}; }
|
|
37
|
+
function header(d: PatchData) {return {schemaVersion:1 as const,action:"patch" as const,provenance:{source:"plastic" as const,producer:"@aefree/pi-plastic" as const,contentTrust:"external" as const},completeness:{capture:d.capture?d.capture.complete?"complete" as const:"incomplete" as const:"unknown" as const,projection:true as const},data:d};}
|
|
38
|
+
export function patchFailure(d: PatchData, code: Extract<PatchReceipt,{ok:false}>["error"]["code"], message: string): PatchReceipt {
|
|
39
|
+
return {...header(d),ok:false,outcome:["started","unknown"].includes(d.attempt.state)&&!d.artifact?"uncertain":"failed",error:{code,message}};
|
|
40
|
+
}
|
|
41
|
+
export async function assemblePatchReceipt(args: unknown): Promise<PatchReceipt> {
|
|
42
|
+
const d=emptyPatchData();let stage: string|null=null;let entered=false;let failure: {code:Extract<PatchReceipt,{ok:false}>["error"]["code"];message:string}|null=null;
|
|
43
|
+
try {
|
|
44
|
+
if (!args||typeof args!=="object"||Array.isArray(args)||Object.keys(args).some(k=>!["source","destination","output","toolPath","clean","integration","workdir","preflight","format"].includes(k)))
|
|
45
|
+
return patchFailure(d,"invalid_request","Use declared bounded patch controls only.");
|
|
46
|
+
const i={...args} as Record<string,unknown>;
|
|
47
|
+
const r={source:i.source,destination:i.destination===undefined?null:i.destination,output:i.output===undefined?null:i.output,toolPath:i.toolPath===undefined?null:i.toolPath,clean:i.clean===undefined?false:i.clean,integration:i.integration===undefined?false:i.integration,workdir:i.workdir===undefined?process.cwd():i.workdir,preflight:i.preflight===undefined?false:i.preflight,format:i.format===undefined?"json":i.format} as PatchRequest;
|
|
48
|
+
if (!validPatchRequest(r)||["destination","output","toolPath"].some(k=>Object.hasOwn(i,k)&&i[k]===null)) return patchFailure(d,"invalid_request","Use non-option source/destination, nonblank paths, strict booleans and text/json.");
|
|
49
|
+
d.requested=r;d.publication=r.output===null?"not-requested":"not-published";
|
|
50
|
+
const toolPath=resolvePatchToolPath(r.toolPath??undefined);
|
|
51
|
+
if (!validPatchText(toolPath)) throw new Error("Invalid patch backend.");
|
|
52
|
+
const branch=await resolvePatchBranchSpecs({source:r.source,...(r.destination===null?{}:{destination:r.destination})},r.workdir);
|
|
53
|
+
const v={source:branch.source,destination:branch.destination??null,toolPath,output:r.output===null?null:isAbsolute(r.output)?r.output:resolve(r.workdir,r.output)};
|
|
54
|
+
if ([v.source,v.destination,v.toolPath,v.output].some(s=>s!==null&&(s.length>8192||/[\u0000-\u001f\u007f-\u009f]/.test(s)))) throw new Error("Resolved patch controls exceed limits.");
|
|
55
|
+
d.resolved=v;d.intendedArgv=patchReceiptArgv(r,v);
|
|
56
|
+
if (r.preflight) return {...header(d),ok:true,outcome:"preflight"};
|
|
57
|
+
if (v.output!==null) await ensurePatchOutputDoesNotExist(v.output);
|
|
58
|
+
stage=await fs.mkdtemp(join(v.output===null?tmpdir():resolve(v.output,".."),".pi-plastic-patch-"));
|
|
59
|
+
const staged=join(stage,"patch-output");
|
|
60
|
+
entered=true;
|
|
61
|
+
const o=await captureWorkspaceMergeCommand(buildPatchCommandArgs({...v,destination:v.destination??undefined,output:staged,clean:r.clean,integration:r.integration}),r.workdir);
|
|
62
|
+
d.attempt=o.attempt;d.capture=o.capture;d.effect=["started","unknown"].includes(o.attempt.state)?"unverified":"not-attempted";
|
|
63
|
+
if (!o.capture.complete||o.failed||o.attempt.state!=="started"||o.attempt.exitCode!==0||o.capture.stderrBytes>0) {
|
|
64
|
+
const incompatible=/unrecognized option [`']?--binary/i.test(o.stderr??"");
|
|
65
|
+
failure={code:incompatible?"backend_incompatible":["started","unknown"].includes(o.attempt.state)?"uncertain":"command_failed",message:incompatible?"Patch backend rejected --binary. Configure a verified GNU-compatible patch backend; no automatic substitution.":"Patch command completion is not admitted. Inspect possible effects; no automatic retry."};
|
|
66
|
+
} else {
|
|
67
|
+
try {
|
|
68
|
+
const bytes=await readBoundedPatchFile(staged,PATCH_FILE_MAX_BYTES),utf8=isUtf8(bytes),body=utf8?bytes.toString("utf8"):null,bounded=body===null?null:boundDiffOutput(body);
|
|
69
|
+
d.artifact={bytes:bytes.length,sha256:createHash("sha256").update(bytes).digest("hex"),utf8,empty:bytes.length===0,binaryLimited:!utf8||/Binary files? .* differ|cannot diff|binary (?:content|file) (?:is )?not supported/i.test((body??"")+"\n"+(o.stdout??"")),totalChars:body===null?null:body.length,truncated:!!bounded?.truncated&&r.output===null,content:r.output===null&&utf8?bounded!.output:null};
|
|
70
|
+
d.effect="artifact-observed";
|
|
71
|
+
} catch { failure={code:"artifact_failed",message:"Completed command did not yield an admitted stable regular patch file within 4MiB. No publication or retry."}; }
|
|
72
|
+
if (!failure&&v.output!==null) {
|
|
73
|
+
if (d.artifact!.empty) failure={code:"publication_failed",message:"Refusing to publish an empty patch; requested output was not published."};
|
|
74
|
+
else try { await fs.link(staged,v.output);d.publication="published";d.effect="published"; }
|
|
75
|
+
catch { failure={code:"publication_failed",message:"Atomic no-overwrite publication failed. Observed generated bytes remain recorded; no replacement or retry."}; }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
if (entered&&d.capture===null) {d.attempt={...emptyWorkspaceMergeAttempt(),state:"unknown"};d.effect="unverified";}
|
|
80
|
+
failure={code:entered?"producer_failed":"setup_failed",message:entered?"Patch observation failed; earlier observed effects remain recorded, no automatic retry.":"Patch setup unavailable. Verify exact selector repository, backend and new output parent; no command was dispatched."};
|
|
81
|
+
} finally {
|
|
82
|
+
if (stage!==null) {
|
|
83
|
+
if (entered&&(d.attempt.state==="unknown"||d.attempt.state==="started")&&(d.attempt.terminal!=="observed"||d.attempt.aborted||d.attempt.timedOut)) {d.cleanup="retained";d.retainedStagingDirectory=stage;}
|
|
84
|
+
else try {await fs.rm(stage,{recursive:true,force:true});d.cleanup="completed";}
|
|
85
|
+
catch {d.cleanup="failed";d.retainedStagingDirectory=stage;failure??={code:"cleanup_failed",message:"Owned staging cleanup failed. Earlier generated/published artifact evidence remains; no rollback or retry."};}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (failure) return patchFailure(d,failure.code,failure.message);
|
|
89
|
+
return {...header(d),ok:true,outcome:d.artifact!.empty?"empty":"generated"};
|
|
90
|
+
}
|
|
91
|
+
export function presentPatchReceipt(d: PatchReceipt) {
|
|
92
|
+
if (d.data.requested?.format!=="text") return JSON.stringify(d);
|
|
93
|
+
if (!d.ok) return `Patch ${d.outcome}: ${d.error.message} Effect: ${d.data.effect}; cleanup: ${d.data.cleanup}.`;
|
|
94
|
+
if (d.outcome==="preflight") return `Patch command-only preview: ${JSON.stringify(d.data.intendedArgv)}. No generation/backend/readiness analysis.`;
|
|
95
|
+
return `Patch ${d.outcome}: ${d.data.artifact!.bytes} observed bytes; publication: ${d.data.publication}; cleanup: ${d.data.cleanup}. Workspace effects/move encoding remain unverified.`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import { spawnAndCollect, ExecutableEnvironment, resolveExecutable } from "../execution/process";
|
|
2
|
+
import { discoverPlasticWorkspace, parsePlasticSelector } from "../plastic-workspace";
|
|
3
|
+
import { promises as fs, realpathSync } from "node:fs";
|
|
4
|
+
import { join, dirname, basename, isAbsolute, resolve } from "path";
|
|
5
|
+
import { tmpdir } from "os";
|
|
6
|
+
import { isUtf8 } from "node:buffer";
|
|
7
|
+
import { constants } from "node:fs";
|
|
8
|
+
import { boundDiffOutput } from "../diff/text";
|
|
9
|
+
|
|
10
|
+
export const PLASTIC_PATCH_EXECUTABLE_ENV = "PI_PLASTIC_PATCH_EXECUTABLE";
|
|
11
|
+
|
|
12
|
+
export type PatchExecutableProbe = (command: string) => Promise<boolean>;
|
|
13
|
+
// Plastic's server/backend determines whether a moved item is encoded as a
|
|
14
|
+
// move or as delete/add records. Do not claim either without a live fixture.
|
|
15
|
+
|
|
16
|
+
export const PATCH_MOVE_REPRESENTATION = "backend-determined" as const;
|
|
17
|
+
|
|
18
|
+
export type PatchCommandArgs = {
|
|
19
|
+
source: string;
|
|
20
|
+
destination?: string;
|
|
21
|
+
output?: string;
|
|
22
|
+
toolPath?: string;
|
|
23
|
+
clean?: boolean;
|
|
24
|
+
integration?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const assertRequiredPatchValue = (name: keyof PatchCommandArgs, value: string | undefined): void =>
|
|
28
|
+
{
|
|
29
|
+
if (value === undefined || value.trim().length === 0)
|
|
30
|
+
{
|
|
31
|
+
throw new Error(`${name} must be non-empty.`);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export const assertNonBlankPatchValue = (name: keyof PatchCommandArgs, value: string | undefined): void =>
|
|
36
|
+
{
|
|
37
|
+
if (value !== undefined && value.trim().length === 0)
|
|
38
|
+
{
|
|
39
|
+
throw new Error(`${name} must be non-empty when provided.`);
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const probePatchExecutable = async (command: string): Promise<boolean> =>
|
|
44
|
+
{
|
|
45
|
+
try
|
|
46
|
+
{
|
|
47
|
+
// This establishes only that the configured executable can launch.
|
|
48
|
+
// `cm patch` remains the authoritative verifier for its --binary contract.
|
|
49
|
+
const result = await spawnAndCollect(command, ["--version"], process.cwd(), undefined, AbortSignal.timeout(3000), {
|
|
50
|
+
abortKillDelayMs: 100,
|
|
51
|
+
outputLimitChars: 1024,
|
|
52
|
+
});
|
|
53
|
+
return !result.aborted;
|
|
54
|
+
}
|
|
55
|
+
catch
|
|
56
|
+
{
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export const getPatchBackendCapabilityWarning = async (
|
|
62
|
+
environment: ExecutableEnvironment = process.env,
|
|
63
|
+
platform: NodeJS.Platform = process.platform,
|
|
64
|
+
probe: PatchExecutableProbe = probePatchExecutable,
|
|
65
|
+
): Promise<string | undefined> =>
|
|
66
|
+
{
|
|
67
|
+
const configuredPatchExecutable = environment[PLASTIC_PATCH_EXECUTABLE_ENV]?.trim();
|
|
68
|
+
if (configuredPatchExecutable)
|
|
69
|
+
{
|
|
70
|
+
if (await probe(configuredPatchExecutable))
|
|
71
|
+
{
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return "Pi Plastic capability warning: the configured PI_PLASTIC_PATCH_EXECUTABLE could not be launched. Set it to a verified patch-capable non-GUI diff executable, or remove the override to use this platform's documented fallback. The configured path is not shown for privacy.";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (platform === "win32")
|
|
79
|
+
{
|
|
80
|
+
return "Pi Plastic capability warning: plastic_patch requires PI_PLASTIC_PATCH_EXECUTABLE on Windows. Set it to a verified patch-capable non-GUI diff executable (for example Git's diff.exe). PI_PLASTIC_DIFF_EXECUTABLE is only for text diffs.";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return undefined;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const resolvePatchToolPath = (
|
|
87
|
+
toolPath?: string,
|
|
88
|
+
environment: ExecutableEnvironment = process.env,
|
|
89
|
+
platform: NodeJS.Platform = process.platform,
|
|
90
|
+
): string =>
|
|
91
|
+
{
|
|
92
|
+
// A per-call path is deliberately the strongest override: callers may
|
|
93
|
+
// select a known patch-capable executable without changing process policy.
|
|
94
|
+
if (toolPath?.trim())
|
|
95
|
+
{
|
|
96
|
+
return toolPath.trim();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const configuredPatchExecutable = environment[PLASTIC_PATCH_EXECUTABLE_ENV]?.trim();
|
|
100
|
+
if (configuredPatchExecutable)
|
|
101
|
+
{
|
|
102
|
+
return configuredPatchExecutable;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (platform === "win32")
|
|
106
|
+
{
|
|
107
|
+
// GnuWin32 diff 2.8.7 is known to reject cm patch directory operands.
|
|
108
|
+
// Do not inherit PI_PLASTIC_DIFF_EXECUTABLE here: that setting remains
|
|
109
|
+
// for text diffs and may name the incompatible executable.
|
|
110
|
+
throw new Error(
|
|
111
|
+
"cm patch requires a patch-capable non-GUI diff executable on Windows. Set PI_PLASTIC_PATCH_EXECUTABLE to a verified tool (for example Git's diff.exe), or pass toolPath for this one call. PI_PLASTIC_DIFF_EXECUTABLE is used only for text diffs and is not a safe Windows patch default.",
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Retain the pre-existing non-Windows fallback while keeping the patch-specific
|
|
116
|
+
// environment variable authoritative. The caller must verify that the resolved
|
|
117
|
+
// executable supports the local Plastic client's patch argument contract.
|
|
118
|
+
return resolveExecutable(environment, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
export type ResolvedPatchBranchSpecs = Pick<PatchCommandArgs, "source" | "destination">;
|
|
122
|
+
|
|
123
|
+
// Repository selectors are a single cm argv value, so spaces within a repository
|
|
124
|
+
// name are not shell separators. Require non-empty @-separated components without
|
|
125
|
+
// edge whitespace, while retaining Plastic's valid internal spaces and punctuation.
|
|
126
|
+
|
|
127
|
+
export const isSafePatchRepositorySelector = (repository: string | undefined): repository is string =>
|
|
128
|
+
{
|
|
129
|
+
if (!repository || repository !== repository.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(repository))
|
|
130
|
+
{
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return repository.split("@").every((component) => /^[^@\s](?:[^@]*[^@\s])?$/.test(component));
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
export const qualifyPatchBranchSpec = (branchSpec: string, repository?: string): string =>
|
|
138
|
+
{
|
|
139
|
+
const normalizedBranchSpec = branchSpec.trim();
|
|
140
|
+
if (!/^br:\/.+/i.test(normalizedBranchSpec) || normalizedBranchSpec.includes("@"))
|
|
141
|
+
{
|
|
142
|
+
return normalizedBranchSpec;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!isSafePatchRepositorySelector(repository))
|
|
146
|
+
{
|
|
147
|
+
throw new Error(
|
|
148
|
+
`Cannot safely qualify unqualified branch selector '${normalizedBranchSpec}' against this workspace. Use the exact repository-qualified syntax 'br:/<branch>@<repository>@<server>'.`,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return `${normalizedBranchSpec}@${repository}`;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
export const resolvePatchBranchSpecs = async (args: PatchCommandArgs, cwd: string): Promise<ResolvedPatchBranchSpecs> =>
|
|
156
|
+
{
|
|
157
|
+
const branchSpecs = [args.source, args.destination].filter((value): value is string => Boolean(value && /^br:\/.+/i.test(value.trim())));
|
|
158
|
+
const needsQualification = branchSpecs.some((value) => !value.includes("@"));
|
|
159
|
+
if (!needsQualification)
|
|
160
|
+
{
|
|
161
|
+
return { source: args.source.trim(), ...(args.destination ? { destination: args.destination.trim() } : {}) };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const workspace = await discoverPlasticWorkspace(cwd);
|
|
165
|
+
if (workspace.kind !== "found")
|
|
166
|
+
{
|
|
167
|
+
throw new Error("Cannot safely qualify an unqualified branch selector because the current Plastic workspace repository is unavailable. Use exact repository-qualified syntax 'br:/<branch>@<repository>@<server>'.");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const selectorBytes = await readBoundedPatchFile(join(workspace.value.plasticDir, "plastic.selector"), 65536);
|
|
171
|
+
if (!isUtf8(selectorBytes)) throw new Error("Patch selector metadata must be valid UTF-8.");
|
|
172
|
+
const selectorText = selectorBytes.toString("utf8");
|
|
173
|
+
const selector = selectorText === undefined ? { kind: "not_found" as const } : parsePlasticSelector(selectorText);
|
|
174
|
+
const repository = selector.kind === "found" ? selector.value.repository : undefined;
|
|
175
|
+
return {
|
|
176
|
+
source: qualifyPatchBranchSpec(args.source, repository),
|
|
177
|
+
...(args.destination ? { destination: qualifyPatchBranchSpec(args.destination, repository) } : {}),
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export type PatchOutputStaging = {
|
|
182
|
+
requestedOutput: string;
|
|
183
|
+
stagingOutput: string;
|
|
184
|
+
cleanup: () => Promise<void>;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export const ensurePatchOutputDoesNotExist = async (requestedOutput: string): Promise<void> =>
|
|
188
|
+
{
|
|
189
|
+
const existing = await fs.lstat(requestedOutput).catch((error: unknown) =>
|
|
190
|
+
{
|
|
191
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
192
|
+
throw error;
|
|
193
|
+
});
|
|
194
|
+
if (existing)
|
|
195
|
+
{
|
|
196
|
+
throw new Error(`Refusing to overwrite existing patch output '${requestedOutput}'. Choose a new output path.`);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export const createPatchOutputStaging = async (requestedOutput: string): Promise<PatchOutputStaging> =>
|
|
201
|
+
{
|
|
202
|
+
await ensurePatchOutputDoesNotExist(requestedOutput);
|
|
203
|
+
let stagingDirectory: string;
|
|
204
|
+
try
|
|
205
|
+
{
|
|
206
|
+
stagingDirectory = await fs.mkdtemp(join(dirname(requestedOutput), ".pi-plastic-patch-"));
|
|
207
|
+
}
|
|
208
|
+
catch (error)
|
|
209
|
+
{
|
|
210
|
+
throw new Error(`Cannot create package-owned patch staging beside '${requestedOutput}'. Ensure its parent directory exists and is writable. ${error instanceof Error ? error.message : String(error)}`);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
requestedOutput,
|
|
215
|
+
stagingOutput: join(stagingDirectory, "patch-output"),
|
|
216
|
+
cleanup: async () => fs.rm(stagingDirectory, { recursive: true, force: true }),
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
export const validatePatchStagingOutput = async (stagingOutput: string, requireContent = false): Promise<void> =>
|
|
221
|
+
{
|
|
222
|
+
const patchStat = await fs.lstat(stagingOutput).catch(() => null);
|
|
223
|
+
if (!patchStat?.isFile())
|
|
224
|
+
{
|
|
225
|
+
throw new Error("Plastic reported patch generation success but did not create package-owned staging output.");
|
|
226
|
+
}
|
|
227
|
+
if (requireContent && patchStat.size === 0)
|
|
228
|
+
{
|
|
229
|
+
// An omitted output may intentionally report an empty patch. A caller
|
|
230
|
+
// who requested a file, however, must never receive a zero-byte file
|
|
231
|
+
// that is indistinguishable from a failed/truncated backend outcome.
|
|
232
|
+
throw new Error("Refusing to publish an empty patch output. Plastic produced no patch bytes; rerun without output to inspect the empty result or verify the patch backend.");
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// Creating a hard link is atomic and fails if the requested path appeared
|
|
237
|
+
// after our preflight. Unlike rename(), it can never replace an existing file.
|
|
238
|
+
|
|
239
|
+
export const publishPatchOutput = async (stagingOutput: string, requestedOutput: string): Promise<void> =>
|
|
240
|
+
{
|
|
241
|
+
await validatePatchStagingOutput(stagingOutput, true);
|
|
242
|
+
try
|
|
243
|
+
{
|
|
244
|
+
await fs.link(stagingOutput, requestedOutput);
|
|
245
|
+
}
|
|
246
|
+
catch (error)
|
|
247
|
+
{
|
|
248
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
249
|
+
if (code === "EEXIST")
|
|
250
|
+
{
|
|
251
|
+
throw new Error(`Refusing to overwrite existing patch output '${requestedOutput}'. Choose a new output path.`);
|
|
252
|
+
}
|
|
253
|
+
throw new Error(`Could not atomically publish patch output '${requestedOutput}'. ${error instanceof Error ? error.message : String(error)}`);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
export const runPatchOutputTransaction = async (requestedOutput: string, generate: (stagingOutput: string) => Promise<void>): Promise<void> =>
|
|
258
|
+
{
|
|
259
|
+
const staging = await createPatchOutputStaging(requestedOutput);
|
|
260
|
+
try
|
|
261
|
+
{
|
|
262
|
+
await generate(staging.stagingOutput);
|
|
263
|
+
await publishPatchOutput(staging.stagingOutput, staging.requestedOutput);
|
|
264
|
+
}
|
|
265
|
+
finally
|
|
266
|
+
{
|
|
267
|
+
await staging.cleanup();
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
export const redactPatchStagingOutput = (message: string, stagingOutput: string): string =>
|
|
272
|
+
{
|
|
273
|
+
const stagingPaths = new Set([stagingOutput]);
|
|
274
|
+
try
|
|
275
|
+
{
|
|
276
|
+
// macOS commonly exposes /var through the canonical /private/var path.
|
|
277
|
+
// cm may report the latter even though it received the former.
|
|
278
|
+
stagingPaths.add(join(realpathSync.native(dirname(stagingOutput)), basename(stagingOutput)));
|
|
279
|
+
}
|
|
280
|
+
catch
|
|
281
|
+
{
|
|
282
|
+
// The staging parent normally exists; retain raw-path redaction if it does not.
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return Array.from(stagingPaths)
|
|
286
|
+
.sort((left, right) => right.length - left.length)
|
|
287
|
+
.reduce((redacted, path) => redacted.split(path).join("<package-owned-staging-file>"), message);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
export const withPatchBackendContractDiagnostic = (error: unknown, toolPath: string, stagingOutput: string): Error =>
|
|
291
|
+
{
|
|
292
|
+
const message = redactPatchStagingOutput(error instanceof Error ? error.message : String(error), stagingOutput);
|
|
293
|
+
// Plastic 11 on macOS invokes its patch backend with --binary. Apple's BSD
|
|
294
|
+
// /usr/bin/diff rejects that flag, while GNU diffutils accepts it. Detect
|
|
295
|
+
// the observed contract failure instead of guessing from a path or OS.
|
|
296
|
+
if (/unrecognized option [`']?--binary/i.test(message))
|
|
297
|
+
{
|
|
298
|
+
return new Error(
|
|
299
|
+
`${message}\n\nPlastic patch requires a diff executable that accepts --binary. '${toolPath}' rejected that argument. Configure PI_PLASTIC_PATCH_EXECUTABLE (or this call's toolPath) to a verified GNU diffutils-compatible non-GUI diff executable. Text diff configuration is independent; Apple BSD diff may still be used for text-only diffs.`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
return new Error(message);
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
export const buildPatchCommandArgs = (args: PatchCommandArgs): string[] =>
|
|
306
|
+
{
|
|
307
|
+
assertRequiredPatchValue("source", args.source);
|
|
308
|
+
assertNonBlankPatchValue("destination", args.destination);
|
|
309
|
+
assertNonBlankPatchValue("output", args.output);
|
|
310
|
+
assertNonBlankPatchValue("toolPath", args.toolPath);
|
|
311
|
+
|
|
312
|
+
const cmdArgs: string[] = ["patch", args.source];
|
|
313
|
+
|
|
314
|
+
if (args.destination)
|
|
315
|
+
{
|
|
316
|
+
cmdArgs.push(args.destination);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (args.output)
|
|
320
|
+
{
|
|
321
|
+
cmdArgs.push(`--output=${args.output}`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (args.toolPath)
|
|
325
|
+
{
|
|
326
|
+
cmdArgs.push(`--tool=${args.toolPath}`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (args.clean)
|
|
330
|
+
{
|
|
331
|
+
cmdArgs.push("--clean");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (args.integration)
|
|
335
|
+
{
|
|
336
|
+
cmdArgs.push("--integration");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return cmdArgs;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
export const __plasticPatchInternals = {
|
|
343
|
+
buildPatchCommandArgs,
|
|
344
|
+
resolvePatchToolPath,
|
|
345
|
+
getPatchBackendCapabilityWarning,
|
|
346
|
+
qualifyPatchBranchSpec,
|
|
347
|
+
resolvePatchBranchSpecs,
|
|
348
|
+
createPatchOutputStaging,
|
|
349
|
+
publishPatchOutput,
|
|
350
|
+
runPatchOutputTransaction,
|
|
351
|
+
withPatchBackendContractDiagnostic,
|
|
352
|
+
PATCH_MOVE_REPRESENTATION,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
/** One bounded file observation, not an atomic filesystem snapshot. */
|
|
357
|
+
export async function readBoundedPatchFile(path: string, limit: number): Promise<Buffer> {
|
|
358
|
+
const before = await fs.lstat(path);
|
|
359
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > limit) throw new Error("Unsupported patch file or byte limit.");
|
|
360
|
+
const handle = await fs.open(path, constants.O_RDONLY | constants.O_NONBLOCK | (constants.O_NOFOLLOW ?? 0));
|
|
361
|
+
try {
|
|
362
|
+
const opened = await handle.stat();
|
|
363
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino || opened.size > limit) throw new Error("Patch file changed or exceeds byte limit.");
|
|
364
|
+
const buffer = Buffer.alloc(limit + 1);
|
|
365
|
+
let length = 0;
|
|
366
|
+
while (length < buffer.length) {
|
|
367
|
+
const read = await handle.read(buffer, length, buffer.length - length, length);
|
|
368
|
+
if (read.bytesRead === 0) break;
|
|
369
|
+
length += read.bytesRead;
|
|
370
|
+
}
|
|
371
|
+
const after = await handle.stat();
|
|
372
|
+
if (length > limit || length !== opened.size || after.size !== opened.size || after.mtimeMs !== opened.mtimeMs || after.ctimeMs !== opened.ctimeMs) throw new Error("Patch file changed or exceeds byte limit.");
|
|
373
|
+
return buffer.subarray(0, length);
|
|
374
|
+
} finally { await handle.close(); }
|
|
375
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { tool } from "../tool-definition";
|
|
2
|
+
import { workdirArg } from "./arguments";
|
|
3
|
+
import { assemblePatchReceipt, presentPatchReceipt } from "./patch-receipt";
|
|
4
|
+
export { __plasticPatchInternals, getPatchBackendCapabilityWarning, PLASTIC_PATCH_EXECUTABLE_ENV } from "./patch-support";
|
|
5
|
+
|
|
6
|
+
export const patch = tool({
|
|
7
|
+
description: "Generate a Plastic SCM review patch with a patch-specific non-GUI diff backend; branch selectors are qualified to the current workspace repository and requested outputs publish atomically without overwrite (cm patch).",
|
|
8
|
+
args: {
|
|
9
|
+
source: tool.schema.string().min(1).describe("Source changeset or branch spec. Unqualified br:/ selectors are qualified only from the exact current workspace repository."),
|
|
10
|
+
destination: tool.schema.string().optional().describe("Optional second changeset or branch spec for two-spec patch generation."),
|
|
11
|
+
output: tool.schema.string().optional().describe("Optional new output file path. The package stages then atomically publishes it and refuses any existing path. If omitted, patch content is returned."),
|
|
12
|
+
toolPath: tool.schema.string().optional().describe("Optional patch-capable non-GUI diff executable for this call (highest priority). Otherwise use PI_PLASTIC_PATCH_EXECUTABLE; non-Windows falls back to PI_PLASTIC_DIFF_EXECUTABLE/diff, which must support the local Plastic patch argument contract."),
|
|
13
|
+
clean: tool.schema.boolean().optional().describe("Exclude content that arrived via merges and include only direct checkins."),
|
|
14
|
+
integration: tool.schema.boolean().optional().describe("Show branch changes pending integration into the parent branch."),
|
|
15
|
+
preflight: tool.schema.boolean().optional().describe("Command-only preview; no staging, backend probe or generation."),
|
|
16
|
+
format: tool.schema.enum(["text", "json"]).optional().describe("Receipt presentation; defaults to JSON."),
|
|
17
|
+
workdir: workdirArg,
|
|
18
|
+
},
|
|
19
|
+
async execute(args) {
|
|
20
|
+
const dto = await assemblePatchReceipt(Object.freeze({ ...args }));
|
|
21
|
+
const text = presentPatchReceipt(dto);
|
|
22
|
+
if (!dto.ok) throw new Error(text);
|
|
23
|
+
return text;
|
|
24
|
+
},
|
|
25
|
+
});
|