@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,94 @@
|
|
|
1
|
+
import { cmWhereEquals, getBranchLeafName, normalizeBranchSpecForComparison } from "../domain/branches";
|
|
2
|
+
import { runStatusCommand, StatusCommandError } from "../execution/status-command";
|
|
3
|
+
import { toStructuredResult, type OutputFormat } from "../presentation/results";
|
|
4
|
+
|
|
5
|
+
export class BranchReadError extends Error {
|
|
6
|
+
constructor(readonly code: "command_failed" | "aborted" | "capture_incomplete" | "invalid_identity" | "malformed_output") {
|
|
7
|
+
super(`Branch read failed (${code}); no verified identity or existence result is available.`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export const validBranchIdentity = (value: string): boolean => value.length > 0 && value.length <= 4096
|
|
11
|
+
&& value === value.trim() && !/[\u0000-\u001f\u007f-\u009f\uFFFD]/u.test(value)
|
|
12
|
+
&& !(process.platform === "win32" && value.includes("?"))
|
|
13
|
+
&& !/[\uD800-\uDFFF]/u.test(value);
|
|
14
|
+
const assertIdentity = (value: string): string => {
|
|
15
|
+
if (!validBranchIdentity(value)) throw new BranchReadError("invalid_identity");
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
const pathIdentity = (value: string): string => {
|
|
19
|
+
assertIdentity(value);
|
|
20
|
+
if (!/^\/(?:[^/@\\]+)(?:\/[^/@\\]+)*$/u.test(value)) throw new BranchReadError("malformed_output");
|
|
21
|
+
return value;
|
|
22
|
+
};
|
|
23
|
+
const read = async (args: string[], workdir?: string): Promise<string> => {
|
|
24
|
+
try {
|
|
25
|
+
const result = await runStatusCommand(args, workdir, true);
|
|
26
|
+
if (result.capture !== "complete") throw new BranchReadError("capture_incomplete");
|
|
27
|
+
return result.output;
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof StatusCommandError) throw new BranchReadError(error.code);
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
// Inspect every recognized changeset token, including empty/malformed ones.
|
|
34
|
+
// Delimiters accommodate the live cs:N@rep:... and (cs:N - head) forms;
|
|
35
|
+
// decimal/junk suffixes are part of the token, never a reusable numeric prefix.
|
|
36
|
+
const statusChangesetIds = (output: string): string[] => {
|
|
37
|
+
const tokens = [...output.matchAll(/\bcs:([^\s@)]*)/giu)].map(match => match[1]);
|
|
38
|
+
if (tokens.length > 1 || tokens.some(token => !/^[0-9]+$/u.test(token))) throw new BranchReadError("malformed_output");
|
|
39
|
+
return tokens;
|
|
40
|
+
};
|
|
41
|
+
// Only explicit, delimited observations are reusable. Never take a whitespace
|
|
42
|
+
// prefix of a br: selector or normalize an observed path into a different one.
|
|
43
|
+
const statusIdentity = (output: string): string | undefined => {
|
|
44
|
+
const candidates: string[] = [];
|
|
45
|
+
for (const line of output.split(/\r?\n/).filter(line => line.trim())) {
|
|
46
|
+
// At most the explicit leading source prefix may carry a br: marker.
|
|
47
|
+
// A second selector in either path or repository text is ambiguous, not
|
|
48
|
+
// part of a fabricated spaced branch or ignorable trailing metadata.
|
|
49
|
+
const body = line.replace(/^(?:(?:branch\s*[:=]\s*|Branch\s+)(?:br:)?|br:)/iu, "");
|
|
50
|
+
if (/\bbr:|\bbranch\s*[:=]\s*(?:br:)?\//iu.test(body)) throw new BranchReadError("malformed_output");
|
|
51
|
+
if (/\bbr:.*[()]|^branch.*\(cs:/iu.test(line)) throw new BranchReadError("malformed_output");
|
|
52
|
+
const match = line.match(/^(\/[^@]+)@.+$/u) ?? line.match(/^(?:(?:branch\s*[:=]\s*|Branch\s+)(?:br:)?|br:)(\/[^@]+)(?:@.+)?$/iu);
|
|
53
|
+
if (match) candidates.push(pathIdentity(match[1]));
|
|
54
|
+
else if (/\bbr:|^branch\b|^\//iu.test(line)) throw new BranchReadError("malformed_output");
|
|
55
|
+
}
|
|
56
|
+
if (new Set(candidates).size > 1) throw new BranchReadError("malformed_output");
|
|
57
|
+
// Pending merge-link changesets describe sources, not the loaded identity.
|
|
58
|
+
// Exclude only their standard-status lines once an explicit header exists;
|
|
59
|
+
// changeset-only fallback still validates every token for ambiguity.
|
|
60
|
+
const identityContext = candidates.length > 0
|
|
61
|
+
? output.split(/\r?\n/).filter(line => !/^ Merge from cs:[0-9]+ at /u.test(line)).join("\n")
|
|
62
|
+
: output;
|
|
63
|
+
statusChangesetIds(identityContext);
|
|
64
|
+
return candidates[0];
|
|
65
|
+
};
|
|
66
|
+
export type CurrentBranchObservation = { action: "current-branch"; branch: string; basis: "status" | "compact_status" | "changeset_lookup"; scope: "workspace" };
|
|
67
|
+
export type BranchExistsObservation = { action: "branch-exists"; requestedBranch: string; comparisonBranch: string; scope: "workspace_repository"; qualifierVerified?: false; exists: boolean };
|
|
68
|
+
export type BranchReadObservation = CurrentBranchObservation | BranchExistsObservation;
|
|
69
|
+
export async function assembleCurrentBranchObservation(args: { workdir?: string }): Promise<CurrentBranchObservation> {
|
|
70
|
+
const normal = await read(["status"], args.workdir);
|
|
71
|
+
const branch = statusIdentity(normal);
|
|
72
|
+
if (branch !== undefined) return { action: "current-branch", branch, basis: "status", scope: "workspace" };
|
|
73
|
+
const compact = await read(["status", "--compact"], args.workdir);
|
|
74
|
+
const compactBranch = statusIdentity(compact);
|
|
75
|
+
if (compactBranch !== undefined) return { action: "current-branch", branch: compactBranch, basis: "compact_status", scope: "workspace" };
|
|
76
|
+
const changesets = statusChangesetIds(compact);
|
|
77
|
+
if (changesets.length !== 1) throw new BranchReadError("malformed_output");
|
|
78
|
+
const output = await read(["find", "changeset", `where changesetid=${changesets[0]}`, "--format={branch}", "--nototal"], args.workdir);
|
|
79
|
+
const rows = output.split(/\r?\n/).filter(line => line.length > 0);
|
|
80
|
+
if (rows.length !== 1) throw new BranchReadError("malformed_output");
|
|
81
|
+
return { action: "current-branch", branch: pathIdentity(rows[0]), basis: "changeset_lookup", scope: "workspace" };
|
|
82
|
+
}
|
|
83
|
+
export async function assembleBranchExistsObservation(args: { branch: string; workdir?: string }): Promise<BranchExistsObservation> {
|
|
84
|
+
assertIdentity(args.branch);
|
|
85
|
+
if (args.branch.includes("@") && args.branch.split("@").some(segment => segment.length === 0)) throw new BranchReadError("invalid_identity");
|
|
86
|
+
const comparisonBranch = assertIdentity(normalizeBranchSpecForComparison(args.branch));
|
|
87
|
+
if (comparisonBranch.includes("//") || comparisonBranch.endsWith("/") || comparisonBranch === "/" || /[|]/u.test(comparisonBranch)) throw new BranchReadError("invalid_identity");
|
|
88
|
+
const output = await read(["find", "branch", `where ${cmWhereEquals("name", getBranchLeafName(args.branch))}`, "--format={name}", "--nototal"], args.workdir);
|
|
89
|
+
const rows = output.split(/\r?\n/).filter(line => line.length > 0).map(pathIdentity);
|
|
90
|
+
if (new Set(rows).size !== rows.length || rows.some(row => getBranchLeafName(row) !== getBranchLeafName(comparisonBranch))) throw new BranchReadError("malformed_output");
|
|
91
|
+
return { action: "branch-exists", requestedBranch: args.branch, comparisonBranch, scope: "workspace_repository", ...(args.branch.includes("@") ? { qualifierVerified: false as const } : {}), exists: rows.some(row => row === comparisonBranch) };
|
|
92
|
+
}
|
|
93
|
+
export const presentCurrentBranchObservation = (observation: CurrentBranchObservation, args: { format?: OutputFormat; workdir?: string }) => toStructuredResult("current-branch", args.format ?? "text", observation.branch, { branch: observation.branch }, args.workdir);
|
|
94
|
+
export const presentBranchExistsObservation = (observation: BranchExistsObservation): string => observation.exists ? "true" : "false";
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { assembleBranchCreateReceipt } from "./branch-create";
|
|
2
|
+
import { presentBranchCreateReceipt } from "../presentation/branch-create";
|
|
3
|
+
import { assembleBranchListObservation, presentBranchListObservation, branchListPayload } from "./branch-list";
|
|
4
|
+
import { assembleCurrentBranchObservation, assembleBranchExistsObservation, presentCurrentBranchObservation, presentBranchExistsObservation } from "./branch-reads";
|
|
5
|
+
import { getBranchLeafName, resolveBranchCreationTarget } from "../domain/branches";
|
|
6
|
+
import { parsePlasticStatusBranch } from "../plastic-workspace";
|
|
7
|
+
import { tool } from "../tool-definition";
|
|
8
|
+
import { workdirArg } from "./arguments";
|
|
9
|
+
import { assembleBranchDeleteReceipt, presentBranchDeleteReceipt } from "./branch-delete-receipt";
|
|
10
|
+
import { outputFormatArg } from "../presentation/results";
|
|
11
|
+
|
|
12
|
+
export const __plasticBranchInternals = {
|
|
13
|
+
getBranchLeafName,
|
|
14
|
+
parsePlasticStatusBranch,
|
|
15
|
+
resolveBranchCreationTarget,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Capture enough subprocess output for diagnostics while keeping the default
|
|
19
|
+
// agent-facing response intentionally small. Callers may opt into a larger
|
|
20
|
+
// focused response, but never the full capture bound.
|
|
21
|
+
|
|
22
|
+
export const branchCreate = tool({
|
|
23
|
+
description: "Create a hierarchical Plastic SCM branch, guarding rare top-level branch creation (cm branch create).",
|
|
24
|
+
args: {
|
|
25
|
+
branch: tool.schema.string().min(1).describe("Relative new-branch name or full hierarchical branch path. A relative name uses parent, or the current branch when parent is omitted."),
|
|
26
|
+
parent: tool.schema.string().optional().describe("Parent branch for a relative branch name. May differ from the workspace branch; Plastic uses the parent's latest changeset by default."),
|
|
27
|
+
changeset: tool.schema.string().optional().describe("Changeset used as the starting point instead of the parent branch's latest changeset."),
|
|
28
|
+
label: tool.schema.string().optional().describe("Label used as the starting point."),
|
|
29
|
+
comment: tool.schema.string().optional().describe("Optional branch comment."),
|
|
30
|
+
commentsFile: tool.schema.string().optional().describe("File path containing the branch comment."),
|
|
31
|
+
allowRootBranch: tool.schema.boolean().optional().describe("Allow intentional top-level branch creation such as /<new-branch>. Defaults to false."),
|
|
32
|
+
workdir: workdirArg,
|
|
33
|
+
},
|
|
34
|
+
async execute(args)
|
|
35
|
+
{
|
|
36
|
+
const dto = await assembleBranchCreateReceipt(args);
|
|
37
|
+
if (!dto.ok && dto.error.stage === "input") throw new Error(dto.error.message);
|
|
38
|
+
return presentBranchCreateReceipt(dto);
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const currentBranch = tool({
|
|
43
|
+
description: "Get the current Plastic SCM branch from workspace status output.",
|
|
44
|
+
args: {
|
|
45
|
+
format: outputFormatArg,
|
|
46
|
+
workdir: workdirArg,
|
|
47
|
+
},
|
|
48
|
+
async execute(args)
|
|
49
|
+
{
|
|
50
|
+
return presentCurrentBranchObservation(await assembleCurrentBranchObservation(args), args);
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
export const branchList = tool({
|
|
55
|
+
description: "List Plastic SCM branches using cm find branch with optional filters.",
|
|
56
|
+
args: {
|
|
57
|
+
source: tool.schema.enum(["native", "names"]).optional().describe("Observation source. Defaults to native table; names selects strict UTF-8 reusable branch rows."),
|
|
58
|
+
format: outputFormatArg,
|
|
59
|
+
maxItems: tool.schema.number().int().min(1).max(500).optional().describe("Canonical projection rows (default 100); independent of the CLI query limit. Native rows are unavailable."),
|
|
60
|
+
nameLike: tool.schema.string().optional().describe("Filter by branch leaf-name pattern (supports % wildcard); bounded to 4096 code units."),
|
|
61
|
+
parent: tool.schema.string().optional().describe("Requested parent branch filter, bounded to 4096 code units; repository qualifiers are not verified."),
|
|
62
|
+
owner: tool.schema.string().optional().describe("Filter by branch owner; bounded to 4096 code units."),
|
|
63
|
+
includeHidden: tool.schema.boolean().optional().describe("Legacy hidden query policy. True is unsupported for names and rejected before execution."),
|
|
64
|
+
limit: tool.schema.number().int().min(1).max(Number.MAX_SAFE_INTEGER).optional().describe("CLI query row limit; not a repository total or projection limit."),
|
|
65
|
+
orderBy: tool.schema.enum(["date", "branchname"]).optional().describe("Sort field for branch queries."),
|
|
66
|
+
descending: tool.schema.boolean().optional().describe("Sort descending when true."),
|
|
67
|
+
workdir: workdirArg,
|
|
68
|
+
},
|
|
69
|
+
async execute(args)
|
|
70
|
+
{
|
|
71
|
+
const observation = await assembleBranchListObservation(args);
|
|
72
|
+
// Enforce compact projection bounds for canonical and JSON core calls;
|
|
73
|
+
// default native text remains the legacy table/string route.
|
|
74
|
+
if (args.source === "names" || args.format === "json") {
|
|
75
|
+
const dto = branchListPayload(observation);
|
|
76
|
+
if (args.format === "json") return JSON.stringify(dto);
|
|
77
|
+
}
|
|
78
|
+
return presentBranchListObservation(observation);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const branchExists = tool({
|
|
83
|
+
description: "Check whether a branch exists using cm find branch.",
|
|
84
|
+
args: {
|
|
85
|
+
branch: tool.schema.string().min(1).describe("Branch name to check."),
|
|
86
|
+
workdir: workdirArg,
|
|
87
|
+
},
|
|
88
|
+
async execute(args)
|
|
89
|
+
{
|
|
90
|
+
return presentBranchExistsObservation(await assembleBranchExistsObservation(args));
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
export const branchDelete = tool({
|
|
95
|
+
description: "Delete a Plastic SCM branch (cm branch delete).",
|
|
96
|
+
args: {
|
|
97
|
+
branch: tool.schema.string().min(1).describe("Branch spec to delete."),
|
|
98
|
+
deleteChangesets: tool.schema.boolean().optional().describe("Delete changesets inside the branch when required; completion does not prove history deletion."),
|
|
99
|
+
preflight: tool.schema.boolean().optional().describe("Command-only preview, no CLI or branch/readiness analysis."),
|
|
100
|
+
format: outputFormatArg,
|
|
101
|
+
workdir: workdirArg,
|
|
102
|
+
},
|
|
103
|
+
async execute(args)
|
|
104
|
+
{
|
|
105
|
+
const request = Object.freeze({ ...args });
|
|
106
|
+
const dto = await assembleBranchDeleteReceipt(request);
|
|
107
|
+
const result = presentBranchDeleteReceipt(dto);
|
|
108
|
+
if (!dto.ok && request.preflight !== true) throw new Error(result);
|
|
109
|
+
return request.format === "json" ? JSON.stringify(dto) : result;
|
|
110
|
+
},
|
|
111
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { buildFallbackScopePaths } from "../domain/paths";
|
|
3
|
+
import { summarizePendingItems, resolveCheckinPaths, filterPendingItemsByScope, selectPrivatePathsForAutoAdd, type PendingItem } from "../domain/pending";
|
|
4
|
+
import { captureCheckinCommand, type CheckinCommandObservation } from "../execution/checkin-command";
|
|
5
|
+
import { checkinSafeComment, checkinSafeValue, parseCheckinEvidence, parseCheckinPending, type CheckinData, type CheckinReceipt, type CheckinRequest, type CheckinStep, type CheckinSummary } from "../domain/checkin-contract";
|
|
6
|
+
const pendingArgv = ["status", "--machinereadable", "--includeRevId", "--fieldseparator=\x1f"];
|
|
7
|
+
const summary = (items: PendingItem[], cwd: string): CheckinSummary => { const { privatePaths: _, ...s } = summarizePendingItems(items, cwd); return s; };
|
|
8
|
+
const normal = (o: CheckinCommandObservation) => o.capture.complete && !o.failed && o.attempt.state === "started" && o.attempt.terminal === "observed" && o.attempt.exitCode !== null && !o.attempt.aborted && !o.attempt.timedOut;
|
|
9
|
+
const success = (o: CheckinCommandObservation) => normal(o) && o.attempt.exitCode === 0 && o.capture.stderrBytes === 0;
|
|
10
|
+
function validateRequest(input: unknown): asserts input is CheckinRequest {
|
|
11
|
+
if (!input || typeof input !== "object" || Array.isArray(input)) throw Error("Invalid bounded checkin input.");
|
|
12
|
+
const r = input as Record<string, unknown>;
|
|
13
|
+
if (!checkinSafeComment(r.message) || !r.message.trim() || r.workdir !== undefined && !checkinSafeValue(r.workdir) || r.format !== undefined && !["text", "json"].includes(r.format as string)) throw Error("Invalid bounded checkin input.");
|
|
14
|
+
for (const k of ["applyChanged", "includePrivate", "includeAll", "updateAfter", "preflight"]) if (r[k] !== undefined && typeof r[k] !== "boolean") throw Error("Invalid checkin option.");
|
|
15
|
+
if (r.paths !== undefined && (!Array.isArray(r.paths) || r.paths.length > 100 || !r.paths.every(p => checkinSafeValue(p) && p.trim() && p !== "-" && !p.startsWith("-")))) throw Error("Invalid bounded checkin paths.");
|
|
16
|
+
const values = [r.message, r.workdir ?? "", ...(r.paths as string[] ?? [])] as string[];
|
|
17
|
+
if (values.reduce((n, s) => n + s.length, 0) > 16384 || Buffer.byteLength(JSON.stringify(values), "utf8") > 32768) throw Error("Checkin input exceeds aggregate bound.");
|
|
18
|
+
}
|
|
19
|
+
export async function assembleCheckinReceipt(input: unknown): Promise<CheckinReceipt> {
|
|
20
|
+
const data: CheckinData = { sourceAdmission: "windows-cm11.0.16.10371-observed", requestedPaths: [], includedPaths: [], fallbackPaths: [], excludedPaths: [], privateAddPaths: [], blockedPrivatePaths: [], itemEvents: [], observedChangesets: [], command: null, steps: [], autoEnabledApplyChanged: false, usedFallbackRetry: false, usedPrivateAutoAddRecovery: false, pendingBefore: null, pendingAfter: null, wouldRun: false, omittedReferences: 0, scopeExhaustion: "unverified", branchHead: "unverified", serverAliasEquivalence: "unverified", xlinkEffects: "unverified", mergeLinkIdentity: "unverified" };
|
|
21
|
+
let created: CheckinReceipt["data"]["createdChangeset"] = null;
|
|
22
|
+
const finish = (outcome: CheckinReceipt["outcome"], code = "uncertain", stage: "input" | "pending" | "checkin" | "producer" = "checkin", message = "Checkin evidence is uncertain; inspect workspace/repository state before deciding whether to retry."): CheckinReceipt => {
|
|
23
|
+
const effects = data.steps.filter(s => s.operation !== "status").map(s => s.effect);
|
|
24
|
+
const effect = created ? "changeset-created" : effects.includes("uncertain") ? "uncertain" : effects.includes("command-completed") ? "command-completed" : "not-attempted";
|
|
25
|
+
// Full evidence classification is complete before any whole-reference projection.
|
|
26
|
+
let remaining = 100;
|
|
27
|
+
const project = <T>(a: T[]): T[] => { const n = Math.min(a.length, remaining); remaining -= n; data.omittedReferences += a.length - n; return a.slice(0, n); };
|
|
28
|
+
data.requestedPaths = project(data.requestedPaths); data.includedPaths = project(data.includedPaths); data.fallbackPaths = project(data.fallbackPaths); data.excludedPaths = project(data.excludedPaths); data.privateAddPaths = project(data.privateAddPaths); data.blockedPrivatePaths = project(data.blockedPrivatePaths); data.itemEvents = project(data.itemEvents); data.observedChangesets = project(data.observedChangesets);
|
|
29
|
+
if (data.command) { if (data.command.length > remaining) { data.omittedReferences += data.command.length; data.command = null; } else remaining -= data.command.length; }
|
|
30
|
+
const ok = outcome === "preflight" || outcome === "completed";
|
|
31
|
+
const dto = { schemaVersion: 1, action: "checkin", provenance: { source: "plastic", producer: "@aefree/pi-plastic", contentTrust: "external" }, ok, outcome, completeness: { capture: data.steps.length ? data.steps.every(s => s.capture.complete) ? "complete" : "incomplete" : "unknown", projection: data.omittedReferences === 0 }, data: { ...data, effect, createdChangeset: created }, ...(!ok ? { error: { code, stage, message } } : {}) } as CheckinReceipt;
|
|
32
|
+
if (Buffer.byteLength(JSON.stringify(dto), "utf8") > 131072) {
|
|
33
|
+
const d = dto.data;
|
|
34
|
+
for (const key of ["requestedPaths", "includedPaths", "fallbackPaths", "excludedPaths", "privateAddPaths", "blockedPrivatePaths", "itemEvents", "observedChangesets"] as const) { d.omittedReferences += d[key].length; d[key] = [] as any; }
|
|
35
|
+
if (d.command) { d.omittedReferences += d.command.length; d.command = null; } dto.completeness.projection = false;
|
|
36
|
+
}
|
|
37
|
+
return dto;
|
|
38
|
+
};
|
|
39
|
+
let stage: "input" | "pending" | "checkin" | "producer" = "input";
|
|
40
|
+
let dispatchEntered = false;
|
|
41
|
+
try {
|
|
42
|
+
validateRequest(input);
|
|
43
|
+
if (input.updateAfter) return finish("failed", "UNATTENDED_UPDATE_AFTER_BLOCKED", "input", "updateAfter is disabled for unattended safety. Run plastic_update() then plastic_merge(...) with an explicit conflict policy.");
|
|
44
|
+
const cwd = input.workdir ?? process.cwd();
|
|
45
|
+
if (!checkinSafeValue(cwd)) return finish("failed", "invalid_request", "input", "Invalid bounded workdir.");
|
|
46
|
+
if (process.platform !== "win32") return finish("unsupported", "source_platform_unadmitted", "pending", "Checkin sources are admitted only on the observed Windows client.");
|
|
47
|
+
data.requestedPaths = input.paths ?? [];
|
|
48
|
+
const record = async (name: CheckinStep["name"], argv: string[]) => {
|
|
49
|
+
if (data.steps.length >= 8) throw Error("Step bound exceeded.");
|
|
50
|
+
dispatchEntered = true;
|
|
51
|
+
const mutation = argv[0] !== "status";
|
|
52
|
+
const s: CheckinStep = { name, operation: argv[0] as CheckinStep["operation"], attempt: { state: "unknown", terminal: "not-observed", exitCode: null, aborted: false, timedOut: false }, capture: { stdoutBytes: 0, stderrBytes: 0, stdoutRetainedBytes: 0, stderrRetainedBytes: 0, truncated: false, complete: false, validUtf8: false }, effect: mutation ? "uncertain" : "not-attempted", evidence: "unadmitted", records: 0, summary: null, changeset: null };
|
|
53
|
+
data.steps.push(s);
|
|
54
|
+
const o = await captureCheckinCommand(argv, cwd);
|
|
55
|
+
s.attempt = o.attempt; s.capture = o.capture;
|
|
56
|
+
if (mutation && ["not-started", "not-attempted"].includes(o.attempt.state)) s.effect = "not-attempted";
|
|
57
|
+
return { o, s };
|
|
58
|
+
};
|
|
59
|
+
const read = async (name: "pending-before" | "pending-recovery" | "pending-after") => {
|
|
60
|
+
const { o, s } = await record(name, pendingArgv), p = parseCheckinPending(o.stdout ?? "", cwd);
|
|
61
|
+
s.records = p.records;
|
|
62
|
+
if (success(o) && p.admitted) { s.evidence = "admitted"; s.effect = "read-observed"; s.summary = summary(p.items, cwd); return p; }
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
stage = "pending";
|
|
66
|
+
const before = await read("pending-before");
|
|
67
|
+
if (!before) return finish("unsupported", "pending_source_unadmitted", "pending", "Pending selection could not be admitted; no mutation was dispatched.");
|
|
68
|
+
data.pendingBefore = summary(before.items, cwd);
|
|
69
|
+
let includedAbsolute: string[] = [], scoped = before.items, apply = input.applyChanged ?? false;
|
|
70
|
+
if (input.paths?.length) {
|
|
71
|
+
const r = resolveCheckinPaths(input.paths, before.items, cwd);
|
|
72
|
+
data.includedPaths = r.includedPaths; data.fallbackPaths = r.fallbackPaths; data.excludedPaths = r.excludedPaths; includedAbsolute = r.includedAbsolutePaths;
|
|
73
|
+
data.autoEnabledApplyChanged = r.shouldApplyChanged && !apply; apply ||= r.shouldApplyChanged;
|
|
74
|
+
scoped = filterPendingItemsByScope(before.items, includedAbsolute);
|
|
75
|
+
if (!data.includedPaths.length) return finish("failed", "NO_PENDING_PATHS", "pending", "None of the provided checkin paths have admitted pending changes.");
|
|
76
|
+
}
|
|
77
|
+
const nonce = randomBytes(16).toString("hex"), seps = { start: `__PI_CI_START_${nonce}__`, end: `__PI_CI_END_${nonce}__`, field: `__PI_CI_FIELD_${nonce}__` };
|
|
78
|
+
const command = (paths: string[], force: boolean) => ["checkin", `-c=${input.message}`, ...(input.includeAll ? ["--all"] : []), ...(force ? ["--applychanged"] : []), ...(input.includePrivate ? ["--private"] : []), ...paths, "--machinereadable", `--startlineseparator=${seps.start}`, `--endlineseparator=${seps.end}`, `--fieldseparator=${seps.field}`];
|
|
79
|
+
const initial = command(data.includedPaths, apply); data.command = ["cm", ...initial]; data.wouldRun = true;
|
|
80
|
+
if (input.preflight) return finish("preflight");
|
|
81
|
+
const attempt = async (name: "initial-checkin" | "private-retry" | "fallback-checkin", argv: string[]) => {
|
|
82
|
+
const { o, s } = await record(name, argv), p = parseCheckinEvidence(o.stdout ?? "", seps); s.records = p.records;
|
|
83
|
+
const admitted = p.admitted && p.changesets.every(c => c.repository === before.repository);
|
|
84
|
+
if (admitted && normal(o)) {
|
|
85
|
+
s.evidence = "admitted";
|
|
86
|
+
data.itemEvents.push(...p.events); data.observedChangesets.push(...p.changesets);
|
|
87
|
+
}
|
|
88
|
+
if (success(o) && admitted && p.changesets.length === 1) { created = p.changesets[0]; s.changeset = created; s.effect = "changeset-created"; }
|
|
89
|
+
// Only genuine complete native failure without a created record may drive the existing recovery policy.
|
|
90
|
+
const retryEligible = normal(o) && admitted && o.attempt.exitCode !== 0 && p.changesets.length === 0;
|
|
91
|
+
return { completed: !!s.changeset, retryEligible, message: retryEligible ? o.stderr ?? "" : "" };
|
|
92
|
+
};
|
|
93
|
+
stage = "checkin";
|
|
94
|
+
let result = await attempt("initial-checkin", initial);
|
|
95
|
+
if (!result.completed && !result.retryEligible) return finish("uncertain");
|
|
96
|
+
if (!result.completed && /checkin operation cannot be started because there is a merge in progress|finish it before checkin|in progress merge/i.test(result.message)) {
|
|
97
|
+
await record("merge-diagnostic", ["status"]);
|
|
98
|
+
return finish("uncertain", "merge_in_progress", "checkin", "Checkin blocked by a merge in progress. Resolve files and use plastic_finalizeMerge(source=<original source>, strategy=destination) only when that conflict policy is intentional, then inspect before retrying.");
|
|
99
|
+
}
|
|
100
|
+
const scopedSummary = summary(scoped, cwd), noChanges = (m: string) => /no changes in the workspace/i.test(m);
|
|
101
|
+
if (!result.completed && noChanges(result.message) && !input.includePrivate && scopedSummary.private > 0 && scopedSummary.tracked === 0 && (input.includeAll || data.requestedPaths.length)) {
|
|
102
|
+
const selection = selectPrivatePathsForAutoAdd(scoped, includedAbsolute, cwd); data.blockedPrivatePaths = selection.blockedPaths;
|
|
103
|
+
if (selection.candidatePaths.length) {
|
|
104
|
+
const { o, s } = await record("private-add", ["add", ...selection.candidatePaths]);
|
|
105
|
+
data.privateAddPaths = selection.candidatePaths;
|
|
106
|
+
if (!normal(o) || o.attempt.exitCode !== 0) return finish("uncertain", "private_add_uncertain");
|
|
107
|
+
s.effect = "command-completed"; data.usedPrivateAutoAddRecovery = true;
|
|
108
|
+
result = await attempt("private-retry", initial);
|
|
109
|
+
if (!result.completed && !result.retryEligible) return finish("uncertain");
|
|
110
|
+
} else if (selection.blockedPaths.length) return finish("uncertain", "sensitive_private_paths", "checkin", "Only private items matched sensitive filters. Use plastic_add(paths=[...]) with explicit safe paths or includePrivate=true only when intentional.");
|
|
111
|
+
}
|
|
112
|
+
if (!result.completed && noChanges(result.message) && !input.includePrivate && scopedSummary.private > 0 && scopedSummary.tracked === 0 && !input.includeAll && !data.requestedPaths.length) return finish("uncertain", "private_items_ineligible", "checkin", "Only private items were pending without includeAll/includePrivate. Add expected files explicitly or use includePrivate=true only when intentional.");
|
|
113
|
+
if (!result.completed && noChanges(result.message) && scopedSummary.tracked > 0) {
|
|
114
|
+
const recovery = await read("pending-recovery");
|
|
115
|
+
if (recovery) data.pendingAfter = summary(recovery.items, cwd);
|
|
116
|
+
// Observation failure cannot mean clean; even genuinely empty pending cannot prove this checkin completed.
|
|
117
|
+
return finish("uncertain", recovery ? "no_changes_unverified" : "pending_recovery_failed");
|
|
118
|
+
}
|
|
119
|
+
if (!result.completed && input.paths?.length && /is not changed in current workspace|none of the provided checkin paths/i.test(result.message)) {
|
|
120
|
+
const fallback = data.fallbackPaths.length ? data.fallbackPaths : buildFallbackScopePaths(includedAbsolute, cwd), argv = command(fallback, true);
|
|
121
|
+
if (fallback.length && argv.join("\0") !== initial.join("\0")) {
|
|
122
|
+
data.usedFallbackRetry = true; data.fallbackPaths = fallback;
|
|
123
|
+
result = await attempt("fallback-checkin", argv);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!result.completed) return finish("uncertain");
|
|
127
|
+
const after = await read("pending-after");
|
|
128
|
+
if (after) data.pendingAfter = summary(after.items, cwd);
|
|
129
|
+
// Created identity proof is independent of a later pending-read failure.
|
|
130
|
+
return finish("completed");
|
|
131
|
+
} catch {
|
|
132
|
+
if (stage === "input") return finish("failed", "invalid_request", "input", "Invalid bounded checkin request.");
|
|
133
|
+
if (dispatchEntered && stage === "checkin") return finish("uncertain", "producer_failed", "producer");
|
|
134
|
+
return finish("failed", "pending_read_failed", "pending", "Pending read could not be completed; no mutation was dispatched.");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export { presentCheckinReceipt } from "../presentation/checkin-results";
|
|
139
|
+
export { checkinSafeValue, parseCheckinChangeset } from "../domain/checkin-contract";
|
|
140
|
+
export type { CheckinReceipt } from "../domain/checkin-contract";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { assembleCheckinReceipt } from "./checkin-receipt";
|
|
2
|
+
import { presentCheckinReceipt } from "../presentation/checkin-results";
|
|
3
|
+
import { toPathComparisonKeyFromAbsolutePath, isSameFilesystemDevice, buildFallbackScopePaths } from "../domain/paths";
|
|
4
|
+
import { inferPendingItemKind, parseMachineReadablePendingItems, summarizePendingItems, getSensitivePrivatePathReason, selectPrivatePathsForAutoAdd, filterPendingItemsByScope, resolveCheckinPaths } from "../domain/pending";
|
|
5
|
+
import { isRevisionNotFoundError, normalizeDiffFileRevisionSpec, resolveDiffFileRevision, isUnscopedDiffRevisionSpec, extractBranchSelectorFromRevision, extractBranchNameFromSelector } from "../domain/revisions";
|
|
6
|
+
import { runCmRaw } from "../execution/cm";
|
|
7
|
+
import { analyzeMergeStatusOutput } from "../domain/merge-output";
|
|
8
|
+
import { tool } from "../tool-definition";
|
|
9
|
+
import { outputFormatArg } from "../presentation/results";
|
|
10
|
+
import { workdirArg } from "./arguments";
|
|
11
|
+
|
|
12
|
+
export const shouldRetryCheckinWithFallbackScope = (errorMessage: string): boolean =>
|
|
13
|
+
{
|
|
14
|
+
const normalized = errorMessage.toLowerCase();
|
|
15
|
+
return normalized.includes("is not changed in current workspace")
|
|
16
|
+
|| normalized.includes("none of the provided checkin paths have pending changes")
|
|
17
|
+
|| normalized.includes("is not changed in current workspace.")
|
|
18
|
+
|| normalized.includes("none of the provided checkin paths");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const isNoChangesWorkspaceCheckinError = (errorMessage: string): boolean =>
|
|
22
|
+
{
|
|
23
|
+
const normalized = errorMessage.toLowerCase();
|
|
24
|
+
return normalized.includes("there are no changes in the workspace")
|
|
25
|
+
|| normalized.includes("no changes in the workspace");
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const __plasticCheckinInternals = {
|
|
29
|
+
// Optional policy injection makes path-case behavior deterministic in focused tests.
|
|
30
|
+
toPathComparisonKeyFromAbsolutePath,
|
|
31
|
+
isSameFilesystemDevice,
|
|
32
|
+
inferPendingItemKind,
|
|
33
|
+
parseMachineReadablePendingItems,
|
|
34
|
+
summarizePendingItems,
|
|
35
|
+
getSensitivePrivatePathReason,
|
|
36
|
+
selectPrivatePathsForAutoAdd,
|
|
37
|
+
filterPendingItemsByScope,
|
|
38
|
+
resolveCheckinPaths,
|
|
39
|
+
buildFallbackScopePaths,
|
|
40
|
+
isNoChangesWorkspaceCheckinError,
|
|
41
|
+
isRevisionNotFoundError,
|
|
42
|
+
normalizeDiffFileRevisionSpec,
|
|
43
|
+
resolveDiffFileRevision,
|
|
44
|
+
isUnscopedDiffRevisionSpec,
|
|
45
|
+
extractBranchSelectorFromRevision,
|
|
46
|
+
extractBranchNameFromSelector,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export const isMergeInProgressCheckinError = (message: string): boolean =>
|
|
50
|
+
{
|
|
51
|
+
return /checkin operation cannot be started because there is a merge in progress|finish it before checkin|in progress merge/i.test(message);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const buildMergeInProgressCheckinMessage = async (originalMessage: string, workdir?: string): Promise<string> =>
|
|
55
|
+
{
|
|
56
|
+
const statusOutput = await runCmRaw(["status"], workdir).catch(() => "");
|
|
57
|
+
const mergeState = analyzeMergeStatusOutput(statusOutput);
|
|
58
|
+
const lines: string[] = [
|
|
59
|
+
"Checkin blocked: Plastic still has a merge in progress.",
|
|
60
|
+
originalMessage.trim(),
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
if (mergeState.pendingMergeLinks.length > 0)
|
|
64
|
+
{
|
|
65
|
+
lines.push("", "Pending merge links:");
|
|
66
|
+
for (const link of mergeState.pendingMergeLinks.slice(0, 10))
|
|
67
|
+
{
|
|
68
|
+
lines.push(`- ${link}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
lines.push(
|
|
73
|
+
"",
|
|
74
|
+
"Next steps:",
|
|
75
|
+
"- If files still need manual work, resolve and validate them first.",
|
|
76
|
+
"- If files are already resolved and validated, run plastic_finalizeMerge(source=<original source>, strategy=destination) to finalize Plastic merge metadata, then retry plastic_checkin.",
|
|
77
|
+
"- Use strategy=source/destination only when that conflict policy is intentional.",
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
return lines.join("\n");
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const checkin = tool({
|
|
84
|
+
description: "Check in pending changes with a required comment (cm checkin -c=...).",
|
|
85
|
+
args: {
|
|
86
|
+
message: tool.schema.string().min(1).describe("Changeset comment."),
|
|
87
|
+
paths: tool.schema.array(tool.schema.string()).optional().describe("Optional paths to check in."),
|
|
88
|
+
applyChanged: tool.schema.boolean().optional().describe("Include changed items not checked out."),
|
|
89
|
+
includePrivate: tool.schema.boolean().optional().describe("Include private items."),
|
|
90
|
+
includeAll: tool.schema.boolean().optional().describe("Include changed, moved, and deleted items."),
|
|
91
|
+
updateAfter: tool.schema.boolean().optional().describe("Deprecated in unattended mode. Blocked because checkin --update can trigger interactive update-merge."),
|
|
92
|
+
preflight: tool.schema.boolean().optional().describe("Preview command and included paths without executing checkin."),
|
|
93
|
+
format: outputFormatArg,
|
|
94
|
+
workdir: workdirArg,
|
|
95
|
+
},
|
|
96
|
+
async execute(args)
|
|
97
|
+
{
|
|
98
|
+
const dto = await assembleCheckinReceipt(args);
|
|
99
|
+
if (!dto.ok && !args.preflight) throw new Error(dto.error.message);
|
|
100
|
+
return presentCheckinReceipt(dto, args.format);
|
|
101
|
+
},
|
|
102
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { parseSwitchTarget, parseSwitchLoadedBranch, compareSwitchTarget, parseSwitchPending, switchPendingArgv, safeSwitchValue } from "../domain/switch-contract";
|
|
3
|
+
import { checkinSafeComment, checkinSafeValue } from "../domain/checkin-contract";
|
|
4
|
+
import { admitCleanStandardStatus } from "../domain/workspace-merge-contract";
|
|
5
|
+
import { parseCopiedPending } from "../domain/copied-merge";
|
|
6
|
+
import { cmWhereEquals } from "../domain/branches";
|
|
7
|
+
import { buildCloseoutParentArgv, CLOSEOUT_STAGE_NAMES, closeoutChildEffect, closeoutCaptures, type CloseoutData, type CloseoutReceipt, type CloseoutRequest, type CloseoutStageName, type CloseoutRead, type CloseoutStage } from "../domain/closeout-contract";
|
|
8
|
+
import { captureWorkspaceMergeCommand, emptyWorkspaceMergeAttempt } from "../execution/workspace-merge-command";
|
|
9
|
+
import { assembleSwitchReceipt } from "./switch-receipt";
|
|
10
|
+
import { assembleUpdateReceipt } from "./update-receipt";
|
|
11
|
+
import { assembleWorkspaceMergeReceipt } from "./workspace-merge-receipt";
|
|
12
|
+
import { assembleCheckinReceipt } from "./checkin-receipt";
|
|
13
|
+
export { presentCloseoutReceipt } from "../presentation/closeout-results";
|
|
14
|
+
export { CLOSEOUT_STAGE_NAMES, CLOSEOUT_DTO_BYTES, closeoutChildEffect, closeoutCaptures } from "../domain/closeout-contract";
|
|
15
|
+
export type { CloseoutReceipt, CloseoutStage, CloseoutRead } from "../domain/closeout-contract";
|
|
16
|
+
export { compareSwitchTarget, parseSwitchTarget, safeSwitchValue, switchPendingArgv } from "../domain/switch-contract";
|
|
17
|
+
export { checkinSafeComment, checkinSafeValue } from "../domain/checkin-contract";
|
|
18
|
+
export { cmWhereEquals } from "../domain/branches";
|
|
19
|
+
export { buildCloseoutParentArgv } from "../domain/closeout-contract";
|
|
20
|
+
export function emptyCloseoutData(): CloseoutData { return {requested:null,sourceBranch:null,targetBranch:null,targetSource:"unresolved",checkinMessage:null,stages:[],unattemptedStages:[...CLOSEOUT_STAGE_NAMES],unobservedChild:null,createdChangeset:null,pendingAfter:null,targetVerification:"unverified",serverAliasEquivalence:"unverified",sourceLink:"unverified",branchHead:"unverified",exclusiveScope:"unverified",rollback:"not-proven",effect:"not-attempted"}; }
|
|
21
|
+
function admitRequest(input:unknown):asserts input is CloseoutRequest { if(!input||typeof input!=="object"||Array.isArray(input))throw Error();const r=input as Record<string,unknown>;if(Object.keys(r).some(k=>!["source","target","message","cardRef","strategy","updateTarget","includePrivate","preflight","format","workdir"].includes(k)))throw Error();for(const k of ["source","target"])if(r[k]!==undefined&&(!safeSwitchValue(r[k])||!parseSwitchTarget(r[k])))throw Error();if(r.message!==undefined&&!checkinSafeComment(r.message))throw Error();if(r.cardRef!==undefined&&!checkinSafeValue(r.cardRef))throw Error();if(r.workdir!==undefined&&!safeSwitchValue(r.workdir))throw Error();if(r.strategy!==undefined&&!["auto","source","destination"].includes(r.strategy as string)||r.format!==undefined&&!["text","json"].includes(r.format as string))throw Error();for(const k of ["updateTarget","includePrivate","preflight"])if(r[k]!==undefined&&typeof r[k]!=="boolean")throw Error();if(Buffer.byteLength(JSON.stringify(r),"utf8")>32768)throw Error(); }
|
|
22
|
+
export async function assembleCloseoutReceipt(input:unknown):Promise<CloseoutReceipt> {
|
|
23
|
+
const data=emptyCloseoutData();let stage:"input"|CloseoutStageName|"producer"="input";let childEntered=false;
|
|
24
|
+
const finish=(outcome:CloseoutReceipt["outcome"],code:Extract<CloseoutReceipt,{ok:false}>["error"]["code"]="observation_failed",message="Closeout paused; inspect earlier effects before deciding whether to retry."):CloseoutReceipt=>{
|
|
25
|
+
const effects=data.stages.map(closeoutChildEffect);data.effect=data.createdChangeset?"changeset-created":effects.includes("uncertain")||childEntered?"uncertain":effects.includes("command-completed")?"command-completed":"not-attempted";
|
|
26
|
+
const captures=closeoutCaptures(data.stages),projection=data.stages.every(s=>s.kind==="read"||s.result.completeness.projection);
|
|
27
|
+
const ok=outcome==="completed"||outcome==="preflight";const actual=!ok&&data.effect!=="not-attempted"?"uncertain":outcome;
|
|
28
|
+
return {schemaVersion:1,action:"merge-to-branch",provenance:{source:"plastic",producer:"@aefree/pi-plastic",contentTrust:"external"},completeness:{capture:!captures.length?"unknown":!data.unobservedChild&&data.stages.every(s=>s.kind!=="read"||s.result.capture!==null)&&captures.every(c=>c.complete)?"complete":"incomplete",projection},data,ok,outcome:actual,...(!ok?{error:{code,stage,message}}:{})} as CloseoutReceipt;
|
|
29
|
+
};
|
|
30
|
+
const push=(s:CloseoutStage)=>{if(data.stages.length>=16)throw Error();data.stages.push(s);data.unattemptedStages=data.unattemptedStages.filter(n=>n!==s.name);};
|
|
31
|
+
const read=async(name:CloseoutStageName,argv:string[])=>{stage=name;const s:CloseoutRead={argv:[...argv],attempt:{...emptyWorkspaceMergeAttempt(),state:"unknown"},capture:null,admission:"unsupported",branch:null,parentPath:null,emptyParentResult:false,summary:null};push({name,kind:"read",result:s});const o=await captureWorkspaceMergeCommand([...argv],data.requested!.workdir);s.attempt=o.attempt;s.capture=o.capture;const success=o.capture.complete&&!o.failed&&o.attempt.state==="started"&&o.attempt.terminal==="observed"&&o.attempt.exitCode===0&&!o.attempt.aborted&&!o.attempt.timedOut&&o.stderr===""&&o.stdout!==null;return {s,o,success};};
|
|
32
|
+
const loaded=async(name:CloseoutStageName,verify=false)=>{const {s,o,success}=await read(name,["status"]);if(success){s.branch=parseSwitchLoadedBranch(o.stdout!);if(s.branch)s.admission="admitted";}if(!s.branch)return false;if(verify){data.targetVerification=compareSwitchTarget(s.branch,data.targetBranch!);if(data.targetVerification==="unverified")return false;}return true;};
|
|
33
|
+
const pending=async(name:"pending-before"|"pending-final")=>{const {s,o,success}=await read(name,[...switchPendingArgv]);if(success){const p=parseSwitchPending(o.stdout!,data.requested!.workdir);if(p.summary){s.admission="admitted";s.summary=p.summary;}}return s.summary;};
|
|
34
|
+
try{
|
|
35
|
+
const submitted=input&&typeof input==="object"&&!Array.isArray(input)?Object.freeze({...input}):input;admitRequest(submitted);const cwd=submitted.workdir??process.cwd();if(!safeSwitchValue(cwd)||!safeSwitchValue(resolve(cwd)))return finish("failed","invalid_request","Closeout needs a bounded workdir.");data.requested={source:submitted.source??null,target:submitted.target??null,message:submitted.message??null,cardRef:submitted.cardRef??null,strategy:submitted.strategy??"auto",updateTarget:submitted.updateTarget??true,includePrivate:submitted.includePrivate??false,preflight:submitted.preflight??false,outputFormat:submitted.format??"text",workdir:cwd};
|
|
36
|
+
if(process.platform!=="win32")return finish("failed","unsupported_source","Closeout source is admitted only on observed Windows/client configuration.");
|
|
37
|
+
if(!await loaded("loaded-before"))return finish("failed");const start=(data.stages[0] as Extract<CloseoutStage,{kind:"read"}>).result.branch!;data.sourceBranch=submitted.source??start.branch;
|
|
38
|
+
const source=parseSwitchTarget(data.sourceBranch)!;const inScope=(spec:string)=>{const t=parseSwitchTarget(spec)!;return t.repository===null||t.repository===start.repository&&t.server===start.server;};
|
|
39
|
+
if(!inScope(data.sourceBranch)||submitted.target!==undefined&&!inScope(submitted.target))return finish("failed","target_unverified","Qualified branch repository/server spelling differs from the observed workspace; alias equivalence is unverified. No target mutation permitted.");
|
|
40
|
+
if(submitted.target!==undefined){data.targetBranch=submitted.target;data.targetSource="explicit";}
|
|
41
|
+
else{
|
|
42
|
+
const {s,o,success}=await read("parent",buildCloseoutParentArgv(data.sourceBranch));if(!success)return finish("failed");
|
|
43
|
+
const lines=o.stdout!.split(/\r?\n/);if(lines.at(-1)==="")lines.pop();
|
|
44
|
+
if(!lines.length){s.admission="admitted";s.emptyParentResult=true;return finish("failed","observation_failed","Parent query returned no admitted matching branch; pass an explicit target.");}
|
|
45
|
+
if(lines.length!==1)return finish("failed","observation_failed","Parent leaf query is ambiguous or malformed; pass an explicit target.");
|
|
46
|
+
const f=lines[0].split("|"),expectedParent=source.branch.slice(0,source.branch.lastIndexOf("/"));
|
|
47
|
+
if(f.length!==2||f[0]!==source.branch||!parseSwitchTarget(f[0])||!f[1]||f[1]!==expectedParent||!parseSwitchTarget(f[1])||parseSwitchTarget(f[1])!.repository!==null)return finish("failed","observation_failed","Parent read did not admit one matching full branch and direct parent path; pass an explicit target.");
|
|
48
|
+
s.parentPath=f[1];s.admission="admitted";const target=source.repository===null?f[1]:`${f[1]}@${source.repository}@${source.server}`;
|
|
49
|
+
if(!parseSwitchTarget(target))return finish("failed","invalid_request","Derived target is outside the bounded branch admission; pass a bounded explicit target.");data.targetBranch=target;data.targetSource="parent-read";
|
|
50
|
+
}
|
|
51
|
+
if(source.branch===parseSwitchTarget(data.targetBranch!)!.branch)return finish("failed","invalid_request","Refusing a same-branch closeout within the admitted workspace scope.");
|
|
52
|
+
const cardLine=submitted.cardRef?.trim()?`\n\n${submitted.cardRef.trim()}`:"";data.checkinMessage=submitted.message?.trim()?submitted.message:`Merge ${data.sourceBranch} into ${data.targetBranch}${cardLine}`;
|
|
53
|
+
// Reuse child comment admission before any target mutation.
|
|
54
|
+
if(!checkinSafeComment(data.checkinMessage)||[data.checkinMessage,cwd].reduce((n,s)=>n+s.length,0)>16384||Buffer.byteLength(JSON.stringify([data.checkinMessage,cwd]),"utf8")>32768)return finish("failed","invalid_request","Derived checkin message is outside the existing child admission; supply an admitted explicit message before any target mutation.");
|
|
55
|
+
const before=await pending("pending-before");if(!before)return finish("failed");data.targetVerification=compareSwitchTarget(start,data.targetBranch!);
|
|
56
|
+
if(data.requested.preflight)return finish("preflight");
|
|
57
|
+
if(data.targetVerification==="unverified"&&before.totalPending>0)return finish("blocked","policy_blocked","Pending changes block switching under the unchanged cancel policy; no target mutation permitted.");
|
|
58
|
+
stage="switch";childEntered=true;const switched=await assembleSwitchReceipt({branch:data.targetBranch!,pendingChanges:"cancel",workdir:cwd});push({name:"switch",kind:"switch",result:switched});childEntered=false;if(!switched.ok)return finish("failed","child_failed");
|
|
59
|
+
if(!await loaded("loaded-after-switch",true))return finish("failed","target_unverified");
|
|
60
|
+
if(data.requested.updateTarget){stage="update";childEntered=true;const updated=await assembleUpdateReceipt({workdir:cwd});push({name:"update",kind:"update",result:updated});childEntered=false;if(!updated.ok)return finish("failed","child_failed");}
|
|
61
|
+
if(!await loaded("loaded-before-merge",true))return finish("failed","target_unverified");
|
|
62
|
+
stage="merge";childEntered=true;const merged=await assembleWorkspaceMergeReceipt("merge",{source:data.sourceBranch!,strategy:data.requested.strategy,workdir:cwd});push({name:"merge",kind:"merge",result:merged});childEntered=false;if(!merged.ok)return finish("failed","child_failed");
|
|
63
|
+
const copiedReady=merged.data.protocol==="apply-add-copied";
|
|
64
|
+
const ready=await read("readiness",copiedReady?[...switchPendingArgv]:["status"]);if(!ready.success||ready.o.stderr!==""||!(copiedReady?!!parseCopiedPending(ready.o.stdout!,cwd):admitCleanStandardStatus(ready.o.stdout!)))return finish("failed","observation_failed","Independent post-merge readiness is outside the admitted profile; no checkin permitted. Prior effects remain possible.");ready.s.admission="admitted";
|
|
65
|
+
if(!await loaded("loaded-before-checkin",true))return finish("failed","target_unverified");
|
|
66
|
+
const checkinArgs={message:data.checkinMessage,includeAll:true,includePrivate:data.requested.includePrivate,workdir:cwd};
|
|
67
|
+
stage="checkin-preflight";childEntered=true;const preview=await assembleCheckinReceipt({...checkinArgs,preflight:true});push({name:"checkin-preflight",kind:"checkin",result:preview});childEntered=false;if(!preview.ok)return finish("failed","child_failed");
|
|
68
|
+
stage="checkin";childEntered=true;const checked=await assembleCheckinReceipt(checkinArgs);push({name:"checkin",kind:"checkin",result:checked});childEntered=false;data.createdChangeset=checked.data.createdChangeset;if(!checked.ok)return finish("failed","child_failed");
|
|
69
|
+
if(!await loaded("loaded-final",true))return finish("failed","target_unverified");
|
|
70
|
+
const final=(data.stages.at(-1)! as Extract<CloseoutStage,{kind:"read"}>).result.branch!;
|
|
71
|
+
if(!data.createdChangeset||data.createdChangeset.branch!==final.branch||data.createdChangeset.repository!==final.repository||data.createdChangeset.server!==final.server)return finish("failed","target_unverified","Observed changeset retained but its branch/repository/server spelling is not the final loaded target. No alias equivalence or target checkin proof inferred.");
|
|
72
|
+
const after=await pending("pending-final");if(!after)return finish("failed");data.pendingAfter=after;return finish("completed");
|
|
73
|
+
}catch{if(childEntered){data.unobservedChild=stage as CloseoutData["unobservedChild"];data.unattemptedStages=data.unattemptedStages.filter(n=>n!==stage);}return finish("failed",stage==="input"?"invalid_request":"producer_failed",stage==="input"?"Use only declared bounded branch selectors, strict closeout controls and admitted checkin text.":"Closeout observation failed; prior effects remain possible. No compensation or automatic replay.");}
|
|
74
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { tool } from "../tool-definition";
|
|
2
|
+
import { outputFormatArg } from "../presentation/results";
|
|
3
|
+
import { workdirArg } from "./arguments";
|
|
4
|
+
import { assembleCloseoutReceipt, presentCloseoutReceipt } from "./closeout-receipt";
|
|
5
|
+
export const mergeToBranch = tool({
|
|
6
|
+
description: "Merge a source branch into a target branch with a bounded typed stage ledger; unknown target/readiness stops before checkin and earlier effects remain recorded.",
|
|
7
|
+
args: {
|
|
8
|
+
source:tool.schema.string().optional().describe("Bounded hierarchical branch selector. Defaults to the directly observed loaded branch."),
|
|
9
|
+
target:tool.schema.string().optional().describe("Bounded hierarchical branch selector. Defaults to an admitted source-parent read in the observed repository scope."),
|
|
10
|
+
cardRef:tool.schema.string().optional().describe("Optional tracker reference in the default checkin message; derived message must meet existing checkin admission."),
|
|
11
|
+
message:tool.schema.string().optional().describe("Optional checkin message. Defaults to Merge <source> into <target>. Existing child text bounds apply."),
|
|
12
|
+
strategy:tool.schema.enum(["auto","source","destination"]).optional().describe("Conflict strategy; defaults to auto."),
|
|
13
|
+
updateTarget:tool.schema.boolean().optional().describe("Update target before merge; defaults to true."),
|
|
14
|
+
includePrivate:tool.schema.boolean().optional().describe("Include private items in merge checkin; defaults to false."),
|
|
15
|
+
preflight:tool.schema.boolean().optional().describe("Read-only discovery/pending preview, not zero CLI or applying readiness proof."),
|
|
16
|
+
format:outputFormatArg,workdir:workdirArg,
|
|
17
|
+
},
|
|
18
|
+
async execute(args){const dto=await assembleCloseoutReceipt(args);if(!dto.ok&&!args.preflight)throw Error(dto.error.message);return presentCloseoutReceipt(dto,args.format);},
|
|
19
|
+
});
|