@aefree/pi-plastic 0.5.8 → 0.6.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 +17 -0
- package/README.md +5 -3
- package/index.ts +7 -1
- package/package.json +6 -5
- package/skills/using-plastic/SKILL.md +2 -0
- package/src/plastic-core.ts +588 -28
- package/src/plastic-tool-loading.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,23 @@ and this project follows semantic versioning for public package releases.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## [0.6.0] - 2026-09-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Add `plastic_mergeBranches` for one bounded, workspace-free server-side merge between explicitly same-repository/server qualified branches. The command-only preflight never contacts Plastic; completed results require one emitted root-mount target changeset and retain unverified merge-link/xlink effects and uncertain no-op/conflict effects.
|
|
15
|
+
|
|
16
|
+
### Changed
|
|
17
|
+
|
|
18
|
+
- Pin the test runner as local devDependency `tsx@4.23.5` and make all package test, live-test, and eval scripts use that local executable instead of an opportunistic `npx` download.
|
|
19
|
+
- Update README's pinned GitHub installation examples to `v0.6.0`.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Make `plastic_mergeBranches` JSON formatting side-effect free, enforce exact bounded machine-readable record framing/schema, and bound both local capability and merge process lifetimes without retrying uncertain dispatches.
|
|
24
|
+
- Stop workspace merge closeout before target-side mutation when its required switch is canceled for pending changes; the blocked result now includes typed pending context and `checkedIn: false`.
|
|
25
|
+
- Preserve qualified parent-lookup repository/server scope while matching documented local branch-name fields, and report bounded `cm find` failures instead of silently treating them as a missing parent; root and missing branches now remain distinguishable.
|
|
26
|
+
|
|
10
27
|
## [0.5.8] - 2026-09-15
|
|
11
28
|
|
|
12
29
|
### Fixed
|
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ Removing the approval layer does not relax Plastic safety guards. Command allowl
|
|
|
36
36
|
- `plastic_branchCreate`
|
|
37
37
|
- `plastic_switchBranch`
|
|
38
38
|
- `plastic_merge`
|
|
39
|
+
- `plastic_mergeBranches`
|
|
39
40
|
- `plastic_mergeToBranch`
|
|
40
41
|
- `plastic_finalizeMerge`
|
|
41
42
|
- `plastic_currentBranch`
|
|
@@ -54,7 +55,7 @@ Removing the approval layer does not relax Plastic safety guards. Command allowl
|
|
|
54
55
|
|
|
55
56
|
## Dynamic tool loading
|
|
56
57
|
|
|
57
|
-
The package exposes
|
|
58
|
+
The package exposes 30 public `plastic_*` tools. `plastic_workspaceCreate` is intentionally not registered or discoverable until the package provides a paired, safe workspace-cleanup capability. `plastic_tool_search` is a package-owned loader that searches the explicit Plastic capability catalog, reports bounded matches and safety guidance, and additively enables selected tools for the next model request.
|
|
58
59
|
|
|
59
60
|
The default **balanced** session set keeps `plastic_tool_search`, `plastic_status`, and `plastic_currentBranch` active. The other Plastic tools remain registered but inactive until selected; built-in and other-extension tools are not removed. Previous loader additions on the active session branch are restored on startup, resume, fork, and reload.
|
|
60
61
|
|
|
@@ -80,6 +81,7 @@ The Bash guards run only in Pi processes that actually load this package. A proj
|
|
|
80
81
|
- `plastic_patch` generates review patches with `cm patch`, including `clean` and `integration` filters for branch review workflows. It does not expose patch apply. Unqualified `br:/...` selectors are qualified only with the current workspace selector's exact repository; otherwise pass `br:/<branch>@<repository>@<server>`.
|
|
81
82
|
- Bash safety rails block `cm diff` and unsafe interactive `cm merge --merge` usage.
|
|
82
83
|
- Merge tooling surfaces Plastic `FILE_CONFLICT` records and merge-state metadata from `cm status`.
|
|
84
|
+
- `plastic_mergeBranches` is a separate workspace-free server-side route. It requires fully repository/server-qualified source and target branches in the same exact repository/server and a nonempty message. Its optional preflight only renders the command; it does not analyze remote conflicts. A completed result proves one emitted root-mount target changeset for this dispatch, not source merge-link identity, exclusive target-head ownership, server capability beyond the dispatch, rollback, or xlink effects. No-op and conflict records do not independently prove that no server effect occurred. The route has no workspace fallback, retries, target-head unification, xlink policy, shelve mode, or conflict-policy override; undetected server-side xlink effects remain possible.
|
|
83
85
|
- `plastic_mergeToBranch` performs the common safe closeout flow: resolve the source branch's parent as the default target, switch to the target branch, optionally update, merge a source branch non-interactively, verify merge state, and check in the merge result. It verifies the loaded target before merge/checkin and rejects final-branch mismatches; Plastic checkins stay on the branch where they were created, so switching afterward is not a Git-style integration.
|
|
84
86
|
- `plastic_finalizeMerge` supports reviewed/manual-resolution flows where Plastic still needs merge metadata finalized before checkin.
|
|
85
87
|
|
|
@@ -109,13 +111,13 @@ pi install npm:@aefree/pi-plastic
|
|
|
109
111
|
Install a pinned GitHub release over HTTPS:
|
|
110
112
|
|
|
111
113
|
```bash
|
|
112
|
-
pi install git:github.com/aefreedman/pi-plastic@v0.
|
|
114
|
+
pi install git:github.com/aefreedman/pi-plastic@v0.6.0
|
|
113
115
|
```
|
|
114
116
|
|
|
115
117
|
Equivalent SSH install:
|
|
116
118
|
|
|
117
119
|
```bash
|
|
118
|
-
pi install git:git@github.com/aefreedman/pi-plastic@v0.
|
|
120
|
+
pi install git:git@github.com/aefreedman/pi-plastic@v0.6.0
|
|
119
121
|
```
|
|
120
122
|
|
|
121
123
|
To intentionally track the moving default branch instead of a release tag:
|
package/index.ts
CHANGED
|
@@ -68,6 +68,7 @@ const PLASTIC_EXPORTS = [
|
|
|
68
68
|
"branchCreate",
|
|
69
69
|
"switchBranch",
|
|
70
70
|
"merge",
|
|
71
|
+
"mergeBranches",
|
|
71
72
|
"mergeToBranch",
|
|
72
73
|
"finalizeMerge",
|
|
73
74
|
"currentBranch",
|
|
@@ -223,6 +224,11 @@ const TOOL_CONFIG: Partial<Record<PlasticExportName, ToolConfig>> = {
|
|
|
223
224
|
return input;
|
|
224
225
|
},
|
|
225
226
|
},
|
|
227
|
+
mergeBranches: {
|
|
228
|
+
prepareArguments(args) {
|
|
229
|
+
return normalizeOutputFormatAlias(normalizeArgs(args));
|
|
230
|
+
},
|
|
231
|
+
},
|
|
226
232
|
mergeToBranch: {
|
|
227
233
|
prepareArguments(args) {
|
|
228
234
|
const input = normalizeOutputFormatAlias(normalizeArgs(args));
|
|
@@ -714,7 +720,7 @@ export default function plasticTools(pi: ExtensionAPI) {
|
|
|
714
720
|
},
|
|
715
721
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
716
722
|
const normalizedParams = normalizeArgs(params);
|
|
717
|
-
if (normalizedParams.workdir === undefined && ctx?.cwd) {
|
|
723
|
+
if (exportName !== "mergeBranches" && normalizedParams.workdir === undefined && ctx?.cwd) {
|
|
718
724
|
normalizedParams.workdir = ctx.cwd;
|
|
719
725
|
}
|
|
720
726
|
const result = await core.runWithAbortSignal(signal, async () => coreTool.execute(normalizedParams));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aefree/pi-plastic",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.19.0"
|
|
@@ -33,14 +33,15 @@
|
|
|
33
33
|
]
|
|
34
34
|
},
|
|
35
35
|
"scripts": {
|
|
36
|
-
"test": "
|
|
37
|
-
"test:live": "
|
|
38
|
-
"test:live:branch-create": "
|
|
39
|
-
"eval:tool-loading": "
|
|
36
|
+
"test": "tsx tests/release-registry-preflight.test.ts && tsx tests/plastic-tool-validation.ts && tsx tests/plastic-status-machine-readable.test.ts && tsx tests/plastic-checkin-path-resolution.ts && tsx tests/plastic-diff-workflow.test.ts && tsx tests/plastic-workspace-diff.test.ts && tsx tests/plastic-process-lifecycle.test.ts && tsx tests/plastic-patch-command.test.ts && tsx tests/plastic-capability-diagnostics.test.ts && tsx tests/plastic-extension-registration.test.ts && tsx tests/plastic-dynamic-tool-loading.test.ts && tsx tests/plastic-tool-rendering.test.ts && tsx tests/plastic-branch-query.test.ts && tsx tests/plastic-workspace.test.ts && tsx tests/plastic-mutation-execution.test.ts && tsx tests/plastic-merge-branches.test.ts && tsx tests/file-discovery-filter.test.ts && tsx tests/file-discovery-filter-registration.test.ts && tsx tests/packed-copy-owner-conflict.test.ts && tsx tests/plastic-branch-status.test.ts && tsx tests/plastic-constrained-sampling.test.ts && tsx tests/bash-cm-diff-guard-validation.ts",
|
|
37
|
+
"test:live": "tsx tests/plastic-live-smoke.test.ts",
|
|
38
|
+
"test:live:branch-create": "tsx tests/plastic-branch-create-live.test.ts",
|
|
39
|
+
"eval:tool-loading": "tsx evals/tool-loading/run-eval.ts"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@aefree/pi-file-discovery": "^0.1.1",
|
|
43
43
|
"@earendil-works/pi-coding-agent": "0.83.0",
|
|
44
|
+
"tsx": "4.23.5",
|
|
44
45
|
"typebox": "^1.3.8"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
@@ -16,6 +16,8 @@ Treat `plastic_merge` success as provisional until `plastic_status` confirms the
|
|
|
16
16
|
|
|
17
17
|
For the common closeout flow of merging a finished source branch into its parent branch, prefer `plastic_mergeToBranch` when available. When `target` is omitted, it resolves the source branch's Plastic parent branch instead of assuming `/dev`. It switches to the target, optionally updates, runs the safe merge, checks merge state, and checks in the merge result. Pass `target` only when the user explicitly wants a different integration branch.
|
|
18
18
|
|
|
19
|
+
Use `plastic_mergeBranches` only for the separate workspace-free server-side route. It requires explicit same-repository/server `br:/...@<repository>@<server>` source and target selectors plus a nonempty message. Its preflight renders the command only; it does not contact Plastic or analyze conflicts. A completed result proves one emitted root-mount target changeset for the dispatch, but does not verify the source merge link, target-head exclusivity, rollback, or xlink effects. No-op and conflict output do not prove that no effect occurred. Do not use it for xlink handling, conflict-policy selection, shelve merges, retries, target-head unification, or a workspace fallback; undetected server-side xlink effects remain possible.
|
|
20
|
+
|
|
19
21
|
Plastic merges are workspace- and branch-oriented, not Git fast-forwards: the merge is applied to the branch currently loaded in the workspace, and the merge checkin must be created on that target branch. Switching to the target after a checkin on the source branch does not move or integrate that changeset. Treat any source/target/final-branch mismatch as a failed closeout; inspect target-branch history and rerun the merge from the actual target rather than merely switching branches.
|
|
20
22
|
|
|
21
23
|
`plastic_diff` is intentionally disabled. Route changed-path listing to `plastic_status()`, one intentional workspace-file comparison to `plastic_diffFile(path="<workspace-path>")`, an explicitly scoped pending review to `plastic_workspaceDiff(paths=[...])`, and an explicit historical pair to `plastic_diffRevisions`. Do not use diffs as routine post-edit validation or checkin preflight. Prefer focused tests, `plastic_status` for pending scope, and direct reads for current file content; diff only when change-boundary evidence is needed. `plastic_diffFile` uses machine-readable status: added and explicitly selected private/new files compare against an empty base; changed, moved, and deleted items use their reported base revision when available. An added empty file reports `status="added-empty"`, not generic unchanged. A `--nodata` base failure means Plastic cannot provide historical bytes; refresh/update the workspace or use two known file-qualified revisions. Explicit `revision` behavior remains a separate historical request. Binary content is reported rather than decoded as text. Text backends receive ASCII-safe package materializations while returned headers preserve logical Unicode labels.
|
package/src/plastic-core.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { isUtf8 } from "node:buffer";
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
4
5
|
import { tool } from "./pi-tool-compat";
|
|
5
6
|
import { promises as fs, realpathSync, statSync } from "node:fs";
|
|
6
7
|
import { tmpdir } from "os";
|
|
@@ -12,8 +13,11 @@ type SpawnResult = {
|
|
|
12
13
|
stderr: string;
|
|
13
14
|
exitCode: number;
|
|
14
15
|
aborted: boolean;
|
|
16
|
+
timedOut?: boolean;
|
|
15
17
|
stdoutTruncated?: boolean;
|
|
16
18
|
stdoutTotalChars?: number;
|
|
19
|
+
stderrTruncated?: boolean;
|
|
20
|
+
stderrTotalChars?: number;
|
|
17
21
|
};
|
|
18
22
|
|
|
19
23
|
const abortSignalStorage = new AsyncLocalStorage<AbortSignal | undefined>();
|
|
@@ -63,7 +67,14 @@ export const runWithAbortSignal = async <T>(
|
|
|
63
67
|
signal: AbortSignal | undefined,
|
|
64
68
|
fn: () => Promise<T>,
|
|
65
69
|
commandExecution?: SpawnAndCollectDependencies,
|
|
66
|
-
): Promise<T> =>
|
|
70
|
+
): Promise<T> =>
|
|
71
|
+
{
|
|
72
|
+
const inheritedSignal = getActiveAbortSignal();
|
|
73
|
+
const inheritedExecution = commandExecutionStorage.getStore();
|
|
74
|
+
// Nested public-tool execution must retain an injected process seam and the
|
|
75
|
+
// outer abort signal instead of silently dropping either boundary.
|
|
76
|
+
return abortSignalStorage.run(signal ?? inheritedSignal, () => commandExecutionStorage.run(commandExecution ?? inheritedExecution, fn));
|
|
77
|
+
};
|
|
67
78
|
|
|
68
79
|
const writeInput = async (stdin: NodeJS.WritableStream | null | undefined, input: string): Promise<void> =>
|
|
69
80
|
{
|
|
@@ -114,6 +125,7 @@ type SpawnAndCollectDependencies = {
|
|
|
114
125
|
setTimeout?: (callback: () => void, delay: number) => NodeJS.Timeout;
|
|
115
126
|
clearTimeout?: (timeout: NodeJS.Timeout) => void;
|
|
116
127
|
abortKillDelayMs?: number;
|
|
128
|
+
timeoutMs?: number;
|
|
117
129
|
outputLimitChars?: number;
|
|
118
130
|
};
|
|
119
131
|
|
|
@@ -150,8 +162,10 @@ const spawnAndCollect = async (
|
|
|
150
162
|
const stdoutPromise = readStream(proc.stdout, dependencies.outputLimitChars);
|
|
151
163
|
const stderrPromise = readStream(proc.stderr, dependencies.outputLimitChars);
|
|
152
164
|
let aborted = false;
|
|
165
|
+
let timedOut = false;
|
|
153
166
|
let terminalSettled = false;
|
|
154
167
|
let killTimeout: NodeJS.Timeout | undefined;
|
|
168
|
+
let operationTimeout: NodeJS.Timeout | undefined;
|
|
155
169
|
|
|
156
170
|
let resolveExit: (exitCode: number) => void;
|
|
157
171
|
let rejectExit: (error: Error) => void;
|
|
@@ -185,14 +199,13 @@ const spawnAndCollect = async (
|
|
|
185
199
|
proc.once("error", onProcessError);
|
|
186
200
|
proc.once("close", onProcessClose);
|
|
187
201
|
|
|
188
|
-
const
|
|
202
|
+
const terminate = () =>
|
|
189
203
|
{
|
|
190
204
|
if (terminalSettled)
|
|
191
205
|
{
|
|
192
206
|
return;
|
|
193
207
|
}
|
|
194
208
|
|
|
195
|
-
aborted = true;
|
|
196
209
|
try
|
|
197
210
|
{
|
|
198
211
|
proc.kill("SIGTERM");
|
|
@@ -217,8 +230,21 @@ const spawnAndCollect = async (
|
|
|
217
230
|
}
|
|
218
231
|
}, dependencies.abortKillDelayMs ?? 5000);
|
|
219
232
|
};
|
|
233
|
+
const onAbort = () =>
|
|
234
|
+
{
|
|
235
|
+
aborted = true;
|
|
236
|
+
terminate();
|
|
237
|
+
};
|
|
220
238
|
|
|
221
239
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
240
|
+
if (dependencies.timeoutMs !== undefined)
|
|
241
|
+
{
|
|
242
|
+
operationTimeout = scheduleTimeout(() =>
|
|
243
|
+
{
|
|
244
|
+
timedOut = true;
|
|
245
|
+
terminate();
|
|
246
|
+
}, dependencies.timeoutMs);
|
|
247
|
+
}
|
|
222
248
|
|
|
223
249
|
try
|
|
224
250
|
{
|
|
@@ -234,8 +260,11 @@ const spawnAndCollect = async (
|
|
|
234
260
|
stderr: stderrResult.output,
|
|
235
261
|
exitCode,
|
|
236
262
|
aborted,
|
|
263
|
+
timedOut,
|
|
237
264
|
stdoutTruncated: stdoutResult.truncated,
|
|
238
265
|
stdoutTotalChars: stdoutResult.totalChars,
|
|
266
|
+
stderrTruncated: stderrResult.truncated,
|
|
267
|
+
stderrTotalChars: stderrResult.totalChars,
|
|
239
268
|
};
|
|
240
269
|
}
|
|
241
270
|
finally
|
|
@@ -247,6 +276,10 @@ const spawnAndCollect = async (
|
|
|
247
276
|
{
|
|
248
277
|
cancelTimeout(killTimeout);
|
|
249
278
|
}
|
|
279
|
+
if (operationTimeout)
|
|
280
|
+
{
|
|
281
|
+
cancelTimeout(operationTimeout);
|
|
282
|
+
}
|
|
250
283
|
}
|
|
251
284
|
};
|
|
252
285
|
|
|
@@ -1217,6 +1250,47 @@ const isSwitchBringBlockedForUnattended = (pendingChoice: "shelve" | "bring" | "
|
|
|
1217
1250
|
return pendingChoice === "bring" && profile.hasTrackedPendingChanges;
|
|
1218
1251
|
};
|
|
1219
1252
|
|
|
1253
|
+
type CanceledSwitchOutcome = {
|
|
1254
|
+
kind: "canceled";
|
|
1255
|
+
strategy: "cancel-with-pending";
|
|
1256
|
+
branchBefore: string;
|
|
1257
|
+
branchTarget: string;
|
|
1258
|
+
pendingSummary: PendingSummary;
|
|
1259
|
+
pendingSummaryDetailed: PendingItemSummary;
|
|
1260
|
+
pendingPolicy: "cancel";
|
|
1261
|
+
defaultedPolicy: boolean;
|
|
1262
|
+
reason: string;
|
|
1263
|
+
};
|
|
1264
|
+
|
|
1265
|
+
const createCanceledSwitchOutcome = (
|
|
1266
|
+
branchBefore: string,
|
|
1267
|
+
branchTarget: string,
|
|
1268
|
+
pendingSummary: PendingSummary,
|
|
1269
|
+
pendingSummaryDetailed: PendingItemSummary,
|
|
1270
|
+
pendingChoice: "shelve" | "bring" | "cancel",
|
|
1271
|
+
defaultedPolicy: boolean,
|
|
1272
|
+
): CanceledSwitchOutcome | undefined =>
|
|
1273
|
+
{
|
|
1274
|
+
if (pendingChoice !== "cancel" || pendingSummaryDetailed.totalPending === 0 || isSameBranchSpec(branchBefore, branchTarget))
|
|
1275
|
+
{
|
|
1276
|
+
return undefined;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
return {
|
|
1280
|
+
kind: "canceled",
|
|
1281
|
+
strategy: "cancel-with-pending",
|
|
1282
|
+
branchBefore,
|
|
1283
|
+
branchTarget,
|
|
1284
|
+
pendingSummary,
|
|
1285
|
+
pendingSummaryDetailed,
|
|
1286
|
+
pendingPolicy: "cancel",
|
|
1287
|
+
defaultedPolicy,
|
|
1288
|
+
reason: defaultedPolicy
|
|
1289
|
+
? "Switch canceled because pending changes were detected and the default policy is cancel unless pendingChanges is set."
|
|
1290
|
+
: "Switch canceled because pending changes were detected and pendingChanges was set to cancel.",
|
|
1291
|
+
};
|
|
1292
|
+
};
|
|
1293
|
+
|
|
1220
1294
|
const canSwitchDirectWithPrivateOnlyPending = (
|
|
1221
1295
|
pendingChoice: "shelve" | "bring" | "cancel",
|
|
1222
1296
|
defaultedPolicy: boolean,
|
|
@@ -2566,30 +2640,130 @@ const buildMergeInProgressCheckinMessage = async (originalMessage: string, workd
|
|
|
2566
2640
|
return lines.join("\n");
|
|
2567
2641
|
};
|
|
2568
2642
|
|
|
2569
|
-
|
|
2643
|
+
type BranchParentLookupOutcome =
|
|
2644
|
+
| { kind: "resolved"; branch: string; parent: string }
|
|
2645
|
+
| { kind: "root"; branch: string; matchedBranch: string }
|
|
2646
|
+
| { kind: "not-found"; branch: string; attemptedCandidates: string[] }
|
|
2647
|
+
| { kind: "malformed-output"; branch: string; attemptedCandidates: string[]; diagnostics: string[] }
|
|
2648
|
+
| { kind: "command-failed"; branch: string; attemptedCandidates: string[]; diagnostics: string[] };
|
|
2649
|
+
|
|
2650
|
+
type BranchParentRow = { name: string; parent?: string };
|
|
2651
|
+
|
|
2652
|
+
const parseBranchParentRows = (output: string): BranchParentRow[] | undefined =>
|
|
2653
|
+
{
|
|
2654
|
+
const lines = normalizeFindOutputLines(output);
|
|
2655
|
+
const rows: BranchParentRow[] = [];
|
|
2656
|
+
for (const line of lines)
|
|
2657
|
+
{
|
|
2658
|
+
const separator = line.indexOf("|");
|
|
2659
|
+
if (separator <= 0 || line.indexOf("|", separator + 1) !== -1)
|
|
2660
|
+
{
|
|
2661
|
+
return undefined;
|
|
2662
|
+
}
|
|
2663
|
+
const name = line.slice(0, separator).trim();
|
|
2664
|
+
const parent = line.slice(separator + 1).trim();
|
|
2665
|
+
if (!name)
|
|
2666
|
+
{
|
|
2667
|
+
return undefined;
|
|
2668
|
+
}
|
|
2669
|
+
rows.push({ name, ...(parent ? { parent } : {}) });
|
|
2670
|
+
}
|
|
2671
|
+
return rows;
|
|
2672
|
+
};
|
|
2673
|
+
|
|
2674
|
+
const branchRepositoryQualifier = (branch: string): string | undefined =>
|
|
2675
|
+
{
|
|
2676
|
+
const match = branch.trim().match(/^(?:br:)?[^@]+(@.+)$/);
|
|
2677
|
+
return match?.[1];
|
|
2678
|
+
};
|
|
2679
|
+
|
|
2680
|
+
const branchRepositorySpec = (branch: string): string | undefined =>
|
|
2681
|
+
{
|
|
2682
|
+
const qualifier = branchRepositoryQualifier(branch);
|
|
2683
|
+
return qualifier?.slice(1);
|
|
2684
|
+
};
|
|
2685
|
+
|
|
2686
|
+
const branchNameForLookup = (branch: string): string => normalizeBranchSpecForComparison(branch);
|
|
2687
|
+
|
|
2688
|
+
const qualifyBranchName = (branchName: string, requestedBranch: string): string =>
|
|
2689
|
+
{
|
|
2690
|
+
const qualifier = branchRepositoryQualifier(requestedBranch);
|
|
2691
|
+
return qualifier && !branchName.includes("@") ? `${branchName}${qualifier}` : branchName;
|
|
2692
|
+
};
|
|
2693
|
+
|
|
2694
|
+
const resolveBranchParentName = async (branch: string, workdir?: string): Promise<BranchParentLookupOutcome> =>
|
|
2570
2695
|
{
|
|
2571
|
-
const
|
|
2572
|
-
|
|
2573
|
-
|
|
2696
|
+
const requestedBranch = branch.trim();
|
|
2697
|
+
// A qualified selector is identity-bearing input; never strip its repository/server.
|
|
2698
|
+
const lookupBranchName = branchNameForLookup(requestedBranch);
|
|
2699
|
+
const candidates = Array.from(new Set(
|
|
2700
|
+
(requestedBranch.includes("@")
|
|
2701
|
+
? [lookupBranchName]
|
|
2702
|
+
: [requestedBranch, lookupBranchName, `br:${lookupBranchName}`])
|
|
2703
|
+
.filter((candidate) => candidate.length > 0),
|
|
2704
|
+
));
|
|
2705
|
+
const diagnostics: string[] = [];
|
|
2706
|
+
const repositorySpec = branchRepositorySpec(requestedBranch);
|
|
2707
|
+
const repositoryClause = repositorySpec
|
|
2708
|
+
? ` on repository '${repositorySpec.replace(/'/g, "''")}'`
|
|
2709
|
+
: "";
|
|
2574
2710
|
|
|
2575
2711
|
for (const candidate of candidates)
|
|
2576
2712
|
{
|
|
2577
|
-
|
|
2578
|
-
"find",
|
|
2579
|
-
"branch",
|
|
2580
|
-
`where ${cmWhereEquals("name", candidate)}`,
|
|
2581
|
-
"--format={parent}",
|
|
2582
|
-
"--nototal",
|
|
2583
|
-
], workdir).catch(() => "");
|
|
2584
|
-
const lines = normalizeFindOutputLines(output);
|
|
2585
|
-
const parent = lines[0]?.trim();
|
|
2586
|
-
if (parent)
|
|
2713
|
+
try
|
|
2587
2714
|
{
|
|
2588
|
-
|
|
2715
|
+
const output = await runCmRaw([
|
|
2716
|
+
"find",
|
|
2717
|
+
"branch",
|
|
2718
|
+
`where ${cmWhereEquals("name", candidate)}${repositoryClause}`,
|
|
2719
|
+
"--format={name}|{parent}",
|
|
2720
|
+
"--nototal",
|
|
2721
|
+
], workdir);
|
|
2722
|
+
const rows = parseBranchParentRows(output);
|
|
2723
|
+
if (!rows || rows.length > 1)
|
|
2724
|
+
{
|
|
2725
|
+
return {
|
|
2726
|
+
kind: "malformed-output",
|
|
2727
|
+
branch: requestedBranch,
|
|
2728
|
+
attemptedCandidates: candidates,
|
|
2729
|
+
diagnostics: [`Expected one name|parent row, received ${rows ? rows.length : "malformed"}.`],
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
const row = rows[0];
|
|
2733
|
+
if (row && branchNameForLookup(row.name) !== lookupBranchName)
|
|
2734
|
+
{
|
|
2735
|
+
return {
|
|
2736
|
+
kind: "malformed-output",
|
|
2737
|
+
branch: requestedBranch,
|
|
2738
|
+
attemptedCandidates: candidates,
|
|
2739
|
+
diagnostics: ["Returned branch identity did not match the requested branch name."],
|
|
2740
|
+
};
|
|
2741
|
+
}
|
|
2742
|
+
if (row?.parent)
|
|
2743
|
+
{
|
|
2744
|
+
return {
|
|
2745
|
+
kind: "resolved",
|
|
2746
|
+
branch: qualifyBranchName(row.name, requestedBranch),
|
|
2747
|
+
parent: qualifyBranchName(row.parent, requestedBranch),
|
|
2748
|
+
};
|
|
2749
|
+
}
|
|
2750
|
+
if (row)
|
|
2751
|
+
{
|
|
2752
|
+
return { kind: "root", branch: requestedBranch, matchedBranch: qualifyBranchName(row.name, requestedBranch) };
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
catch (error)
|
|
2756
|
+
{
|
|
2757
|
+
diagnostics.push(normalizeErrorMessage(error).slice(0, 256));
|
|
2589
2758
|
}
|
|
2590
2759
|
}
|
|
2591
2760
|
|
|
2592
|
-
|
|
2761
|
+
if (diagnostics.length > 0)
|
|
2762
|
+
{
|
|
2763
|
+
return { kind: "command-failed", branch: requestedBranch, attemptedCandidates: candidates, diagnostics: diagnostics.slice(0, 3) };
|
|
2764
|
+
}
|
|
2765
|
+
|
|
2766
|
+
return { kind: "not-found", branch: requestedBranch, attemptedCandidates: candidates };
|
|
2593
2767
|
};
|
|
2594
2768
|
|
|
2595
2769
|
const resolveCurrentBranchName = async (workdir?: string): Promise<string> =>
|
|
@@ -4047,11 +4221,17 @@ export const switchBranch = tool({
|
|
|
4047
4221
|
throw new Error(reason);
|
|
4048
4222
|
}
|
|
4049
4223
|
|
|
4050
|
-
|
|
4224
|
+
const canceledOutcome = createCanceledSwitchOutcome(
|
|
4225
|
+
branchBefore,
|
|
4226
|
+
args.branch,
|
|
4227
|
+
pendingSummary,
|
|
4228
|
+
pendingSummaryDetailed,
|
|
4229
|
+
pendingChoice,
|
|
4230
|
+
defaulted,
|
|
4231
|
+
);
|
|
4232
|
+
if (canceledOutcome)
|
|
4051
4233
|
{
|
|
4052
|
-
const reason =
|
|
4053
|
-
? "Switch canceled because pending changes were detected and the default policy is cancel unless pendingChanges is set."
|
|
4054
|
-
: "Switch canceled because pending changes were detected and pendingChanges was set to cancel.";
|
|
4234
|
+
const reason = canceledOutcome.reason;
|
|
4055
4235
|
if (preflight)
|
|
4056
4236
|
{
|
|
4057
4237
|
return toStructuredResult(
|
|
@@ -4360,6 +4540,340 @@ export const merge = tool({
|
|
|
4360
4540
|
},
|
|
4361
4541
|
});
|
|
4362
4542
|
|
|
4543
|
+
type QualifiedServerBranch = {
|
|
4544
|
+
raw: string;
|
|
4545
|
+
branch: string;
|
|
4546
|
+
repository: string;
|
|
4547
|
+
server: string;
|
|
4548
|
+
};
|
|
4549
|
+
|
|
4550
|
+
type ServerMergeRecord = {
|
|
4551
|
+
operation: string;
|
|
4552
|
+
fields: string[];
|
|
4553
|
+
};
|
|
4554
|
+
|
|
4555
|
+
type ServerMergeParse = {
|
|
4556
|
+
records: ServerMergeRecord[];
|
|
4557
|
+
malformed: boolean;
|
|
4558
|
+
unknownOperations: string[];
|
|
4559
|
+
changesets: Array<{ id: string; branch: string; repository: string; mount: string }>;
|
|
4560
|
+
isAlreadyConnected: boolean;
|
|
4561
|
+
hasConflict: boolean;
|
|
4562
|
+
};
|
|
4563
|
+
|
|
4564
|
+
const SERVER_MERGE_OUTPUT_LIMIT = 16_384;
|
|
4565
|
+
const SERVER_MERGE_HELP_TIMEOUT_MS = 3_000;
|
|
4566
|
+
const SERVER_MERGE_COMMAND_TIMEOUT_MS = 30_000;
|
|
4567
|
+
const serverMergeControlPattern = /[\u0000-\u001f\u007f-\u009f]/;
|
|
4568
|
+
const serverMergeCapabilityTokens = ["--to", "--merge", "--nointeractiveresolution", "--machinereadable", "--startlineseparator", "--endlineseparator", "--fieldseparator"];
|
|
4569
|
+
|
|
4570
|
+
const assertSafeServerMergeValue = (name: string, value: string): string =>
|
|
4571
|
+
{
|
|
4572
|
+
const trimmed = value.trim();
|
|
4573
|
+
if (!trimmed || serverMergeControlPattern.test(value))
|
|
4574
|
+
{
|
|
4575
|
+
throw new Error(`${name} must be non-empty and must not contain control characters.`);
|
|
4576
|
+
}
|
|
4577
|
+
|
|
4578
|
+
return trimmed;
|
|
4579
|
+
};
|
|
4580
|
+
|
|
4581
|
+
const parseQualifiedServerBranch = (name: string, value: string): QualifiedServerBranch =>
|
|
4582
|
+
{
|
|
4583
|
+
const raw = assertSafeServerMergeValue(name, value);
|
|
4584
|
+
if (!raw.startsWith("br:/"))
|
|
4585
|
+
{
|
|
4586
|
+
throw new Error(`${name} must use fully qualified syntax 'br:/<branch>@<repository>@<server>'.`);
|
|
4587
|
+
}
|
|
4588
|
+
|
|
4589
|
+
const lastAt = raw.lastIndexOf("@");
|
|
4590
|
+
const previousAt = raw.lastIndexOf("@", lastAt - 1);
|
|
4591
|
+
if (previousAt <= 3 || lastAt <= previousAt + 1 || lastAt === raw.length - 1)
|
|
4592
|
+
{
|
|
4593
|
+
throw new Error(`${name} must use fully qualified syntax 'br:/<branch>@<repository>@<server>'.`);
|
|
4594
|
+
}
|
|
4595
|
+
|
|
4596
|
+
const branch = raw.slice(3, previousAt);
|
|
4597
|
+
const repository = raw.slice(previousAt + 1, lastAt);
|
|
4598
|
+
const server = raw.slice(lastAt + 1);
|
|
4599
|
+
if (!branch.startsWith("/") || branch.endsWith("/") || [branch, repository, server].some((part) => !part || part !== part.trim() || serverMergeControlPattern.test(part)))
|
|
4600
|
+
{
|
|
4601
|
+
throw new Error(`${name} must use fully qualified syntax 'br:/<branch>@<repository>@<server>'.`);
|
|
4602
|
+
}
|
|
4603
|
+
|
|
4604
|
+
return { raw, branch, repository, server };
|
|
4605
|
+
};
|
|
4606
|
+
|
|
4607
|
+
const createServerMergeSeparators = (): { start: string; end: string; field: string } =>
|
|
4608
|
+
{
|
|
4609
|
+
const nonce = randomBytes(16).toString("hex");
|
|
4610
|
+
return {
|
|
4611
|
+
start: `__PI_PLASTIC_MERGE_START_${nonce}__`,
|
|
4612
|
+
end: `__PI_PLASTIC_MERGE_END_${nonce}__`,
|
|
4613
|
+
field: `__PI_PLASTIC_MERGE_FIELD_${nonce}__`,
|
|
4614
|
+
};
|
|
4615
|
+
};
|
|
4616
|
+
|
|
4617
|
+
const parseServerMergeOutput = (output: string, separators: { start: string; end: string; field: string }): ServerMergeParse =>
|
|
4618
|
+
{
|
|
4619
|
+
const records: ServerMergeRecord[] = [];
|
|
4620
|
+
const unknownOperations = new Set<string>();
|
|
4621
|
+
let malformed = false;
|
|
4622
|
+
let cursor = 0;
|
|
4623
|
+
|
|
4624
|
+
while (true)
|
|
4625
|
+
{
|
|
4626
|
+
const start = output.indexOf(separators.start, cursor);
|
|
4627
|
+
if (start < 0)
|
|
4628
|
+
{
|
|
4629
|
+
if (output.slice(cursor).includes(separators.end) || output.slice(cursor).includes(separators.field))
|
|
4630
|
+
{
|
|
4631
|
+
malformed = true;
|
|
4632
|
+
}
|
|
4633
|
+
break;
|
|
4634
|
+
}
|
|
4635
|
+
const prefix = output.slice(cursor, start);
|
|
4636
|
+
if (prefix.includes(separators.end) || prefix.includes(separators.field))
|
|
4637
|
+
{
|
|
4638
|
+
malformed = true;
|
|
4639
|
+
}
|
|
4640
|
+
const payloadStart = start + separators.start.length;
|
|
4641
|
+
const end = output.indexOf(separators.end, payloadStart);
|
|
4642
|
+
if (end < 0)
|
|
4643
|
+
{
|
|
4644
|
+
malformed = true;
|
|
4645
|
+
break;
|
|
4646
|
+
}
|
|
4647
|
+
const payload = output.slice(payloadStart, end);
|
|
4648
|
+
if (!payload || payload.includes(separators.start) || payload.includes(separators.end))
|
|
4649
|
+
{
|
|
4650
|
+
malformed = true;
|
|
4651
|
+
}
|
|
4652
|
+
else
|
|
4653
|
+
{
|
|
4654
|
+
const fields = payload.split(separators.field);
|
|
4655
|
+
const operation = fields[0] ?? "";
|
|
4656
|
+
if (!operation || fields.some((field) => field.length === 0))
|
|
4657
|
+
{
|
|
4658
|
+
malformed = true;
|
|
4659
|
+
}
|
|
4660
|
+
else
|
|
4661
|
+
{
|
|
4662
|
+
records.push({ operation, fields });
|
|
4663
|
+
if (!["FILE_SRC", "CHANGESET", "STATUS", "FILE_CONFLICT"].includes(operation))
|
|
4664
|
+
{
|
|
4665
|
+
unknownOperations.add(operation);
|
|
4666
|
+
malformed = true;
|
|
4667
|
+
}
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
cursor = end + separators.end.length;
|
|
4671
|
+
}
|
|
4672
|
+
|
|
4673
|
+
const changesets: Array<{ id: string; branch: string; repository: string; mount: string }> = [];
|
|
4674
|
+
let isAlreadyConnected = false;
|
|
4675
|
+
let hasConflict = false;
|
|
4676
|
+
for (const record of records)
|
|
4677
|
+
{
|
|
4678
|
+
switch (record.operation)
|
|
4679
|
+
{
|
|
4680
|
+
case "FILE_SRC":
|
|
4681
|
+
if (record.fields.length !== 5 || !record.fields[1]?.startsWith("/") || !record.fields.slice(2).every((field) => /^\d+$/.test(field)))
|
|
4682
|
+
{
|
|
4683
|
+
malformed = true;
|
|
4684
|
+
}
|
|
4685
|
+
break;
|
|
4686
|
+
case "CHANGESET":
|
|
4687
|
+
{
|
|
4688
|
+
if (record.fields.length !== 2)
|
|
4689
|
+
{
|
|
4690
|
+
malformed = true;
|
|
4691
|
+
break;
|
|
4692
|
+
}
|
|
4693
|
+
const match = record.fields[1]?.match(/^cs:(\d+)@(?<branch>\/[^@]+)@(?<repository>[^@]+) \(mount:'(?<mount>[^']+)'\)$/);
|
|
4694
|
+
if (!match?.groups)
|
|
4695
|
+
{
|
|
4696
|
+
malformed = true;
|
|
4697
|
+
break;
|
|
4698
|
+
}
|
|
4699
|
+
changesets.push({
|
|
4700
|
+
id: match[1]!,
|
|
4701
|
+
branch: match.groups.branch!,
|
|
4702
|
+
repository: match.groups.repository!,
|
|
4703
|
+
mount: match.groups.mount!,
|
|
4704
|
+
});
|
|
4705
|
+
break;
|
|
4706
|
+
}
|
|
4707
|
+
case "STATUS":
|
|
4708
|
+
if (record.fields.length !== 3 || record.fields[1] !== "ALREADY_CONNECTED" || record.fields[2] !== "No merges detected" || isAlreadyConnected)
|
|
4709
|
+
{
|
|
4710
|
+
malformed = true;
|
|
4711
|
+
}
|
|
4712
|
+
isAlreadyConnected = true;
|
|
4713
|
+
break;
|
|
4714
|
+
case "FILE_CONFLICT":
|
|
4715
|
+
if (record.fields.length !== 6 || !record.fields[1]?.startsWith("/") || !record.fields.slice(2).every((field) => /^\d+$/.test(field)))
|
|
4716
|
+
{
|
|
4717
|
+
malformed = true;
|
|
4718
|
+
}
|
|
4719
|
+
hasConflict = true;
|
|
4720
|
+
break;
|
|
4721
|
+
default:
|
|
4722
|
+
// Unknown operations were recorded above and make the response uncertain.
|
|
4723
|
+
break;
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
return { records, malformed, unknownOperations: [...unknownOperations], changesets, isAlreadyConnected, hasConflict };
|
|
4727
|
+
};
|
|
4728
|
+
|
|
4729
|
+
const formatServerMergeResult = async (
|
|
4730
|
+
format: OutputFormat,
|
|
4731
|
+
outcome: "preflight" | "completed" | "no-op" | "conflict" | "uncertain" | "unsupported",
|
|
4732
|
+
text: string,
|
|
4733
|
+
data: Record<string, unknown>,
|
|
4734
|
+
): Promise<string> =>
|
|
4735
|
+
{
|
|
4736
|
+
if (format === "text")
|
|
4737
|
+
{
|
|
4738
|
+
return text;
|
|
4739
|
+
}
|
|
4740
|
+
return `## merge-branches\n\n\`\`\`json\n${JSON.stringify({
|
|
4741
|
+
ok: outcome === "completed" || outcome === "preflight" || outcome === "no-op",
|
|
4742
|
+
action: "merge-branches",
|
|
4743
|
+
outcome,
|
|
4744
|
+
toolVersion: TOOL_VERSION,
|
|
4745
|
+
data,
|
|
4746
|
+
}, null, 2)}\n\`\`\``;
|
|
4747
|
+
};
|
|
4748
|
+
|
|
4749
|
+
const getServerMergeCapability = async (): Promise<{ supported: boolean; diagnostics: string }> =>
|
|
4750
|
+
{
|
|
4751
|
+
const result = await spawnAndCollect(getCmExecutable(), ["help", "merge"], process.cwd(), undefined, getActiveAbortSignal(), {
|
|
4752
|
+
...(commandExecutionStorage.getStore() ?? {}),
|
|
4753
|
+
timeoutMs: SERVER_MERGE_HELP_TIMEOUT_MS,
|
|
4754
|
+
outputLimitChars: SERVER_MERGE_OUTPUT_LIMIT,
|
|
4755
|
+
});
|
|
4756
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
|
4757
|
+
const missing = serverMergeCapabilityTokens.filter((token) => !output.includes(token));
|
|
4758
|
+
if (result.aborted || result.timedOut || result.exitCode !== 0 || result.stdoutTruncated || result.stderrTruncated || missing.length > 0)
|
|
4759
|
+
{
|
|
4760
|
+
return { supported: false, diagnostics: `Local cm help merge capability check did not prove required syntax${missing.length > 0 ? `: missing ${missing.join(", ")}` : "."}` };
|
|
4761
|
+
}
|
|
4762
|
+
return { supported: true, diagnostics: "Local cm help merge advertised the required merge-to and machine-readable syntax. Server capability remains unverified until dispatch." };
|
|
4763
|
+
};
|
|
4764
|
+
|
|
4765
|
+
export const mergeBranches = tool({
|
|
4766
|
+
description: "Perform one bounded workspace-free server-side merge between explicitly qualified branches; merge-link and xlink effects remain unverified.",
|
|
4767
|
+
args: {
|
|
4768
|
+
source: tool.schema.string().min(1).describe("Fully qualified source branch: br:/<branch>@<repository>@<server>."),
|
|
4769
|
+
target: tool.schema.string().min(1).describe("Fully qualified target branch in the same repository/server as source."),
|
|
4770
|
+
message: tool.schema.string().min(1).describe("Non-empty changeset comment; no editor fallback is allowed."),
|
|
4771
|
+
preflight: tool.schema.boolean().optional().describe("Render the exact command only; does not contact Plastic or analyze remote conflicts."),
|
|
4772
|
+
format: outputFormatArg,
|
|
4773
|
+
},
|
|
4774
|
+
async execute(args)
|
|
4775
|
+
{
|
|
4776
|
+
const format = args.format ?? "text";
|
|
4777
|
+
const source = parseQualifiedServerBranch("source", args.source);
|
|
4778
|
+
const target = parseQualifiedServerBranch("target", args.target);
|
|
4779
|
+
const message = assertSafeServerMergeValue("message", args.message);
|
|
4780
|
+
if (source.repository !== target.repository || source.server !== target.server)
|
|
4781
|
+
{
|
|
4782
|
+
throw new Error("source and target must identify the same exact repository and server.");
|
|
4783
|
+
}
|
|
4784
|
+
if (source.branch === target.branch)
|
|
4785
|
+
{
|
|
4786
|
+
throw new Error("source and target must identify different branches.");
|
|
4787
|
+
}
|
|
4788
|
+
|
|
4789
|
+
const separators = createServerMergeSeparators();
|
|
4790
|
+
const command = [
|
|
4791
|
+
"merge", source.raw, `--to=${target.raw}`, "--merge", `-c=${message}`, "--nointeractiveresolution", "--machinereadable",
|
|
4792
|
+
`--startlineseparator=${separators.start}`, `--endlineseparator=${separators.end}`, `--fieldseparator=${separators.field}`,
|
|
4793
|
+
];
|
|
4794
|
+
const requestedIdentity = { source: source.raw, target: target.raw, repository: source.repository, server: source.server };
|
|
4795
|
+
if (args.preflight)
|
|
4796
|
+
{
|
|
4797
|
+
return formatServerMergeResult(format, "preflight", [
|
|
4798
|
+
"## Server Merge Preflight",
|
|
4799
|
+
"",
|
|
4800
|
+
"- Would run: yes",
|
|
4801
|
+
"- Remote analysis: not performed",
|
|
4802
|
+
`- Command: cm ${command.join(" ")}`,
|
|
4803
|
+
].join("\n"), { wouldRun: true, requestedIdentity, command: ["cm", ...command] });
|
|
4804
|
+
}
|
|
4805
|
+
|
|
4806
|
+
const capability = await getServerMergeCapability();
|
|
4807
|
+
if (!capability.supported)
|
|
4808
|
+
{
|
|
4809
|
+
return formatServerMergeResult(format, "unsupported", "## Server Merge Unsupported\n\n- No merge command was dispatched because local client syntax could not be proven.", {
|
|
4810
|
+
requestedIdentity,
|
|
4811
|
+
capability,
|
|
4812
|
+
dispatched: false,
|
|
4813
|
+
});
|
|
4814
|
+
}
|
|
4815
|
+
|
|
4816
|
+
const attempt = await spawnAndCollect(getCmExecutable(), command, process.cwd(), undefined, getActiveAbortSignal(), {
|
|
4817
|
+
...(commandExecutionStorage.getStore() ?? {}),
|
|
4818
|
+
timeoutMs: SERVER_MERGE_COMMAND_TIMEOUT_MS,
|
|
4819
|
+
outputLimitChars: SERVER_MERGE_OUTPUT_LIMIT,
|
|
4820
|
+
});
|
|
4821
|
+
const output = [attempt.stdout, attempt.stderr].filter(Boolean).join("\n");
|
|
4822
|
+
const parsed = parseServerMergeOutput(output, separators);
|
|
4823
|
+
const matchingChangesets = parsed.changesets.filter((changeset) => changeset.branch === target.branch && changeset.repository === target.repository && changeset.mount === "/");
|
|
4824
|
+
const effect = "not-proven";
|
|
4825
|
+
const diagnostics = output.slice(0, 4_000);
|
|
4826
|
+
const baseData = {
|
|
4827
|
+
requestedIdentity,
|
|
4828
|
+
capability,
|
|
4829
|
+
command: ["cm", ...command],
|
|
4830
|
+
dispatched: true,
|
|
4831
|
+
exitCode: attempt.exitCode,
|
|
4832
|
+
aborted: attempt.aborted,
|
|
4833
|
+
timedOut: attempt.timedOut,
|
|
4834
|
+
outputTruncated: Boolean(attempt.stdoutTruncated || attempt.stderrTruncated),
|
|
4835
|
+
records: parsed.records,
|
|
4836
|
+
observedChangesets: parsed.changesets,
|
|
4837
|
+
diagnostics,
|
|
4838
|
+
mergeLinkIdentity: "unverified",
|
|
4839
|
+
xlinkEffects: "unverified",
|
|
4840
|
+
effect,
|
|
4841
|
+
};
|
|
4842
|
+
|
|
4843
|
+
const ambiguous = attempt.aborted || attempt.timedOut || attempt.stdoutTruncated || attempt.stderrTruncated || parsed.malformed
|
|
4844
|
+
|| parsed.unknownOperations.length > 0 || parsed.changesets.length > 1
|
|
4845
|
+
|| (parsed.changesets.length > 0 && matchingChangesets.length !== 1)
|
|
4846
|
+
|| (parsed.hasConflict && parsed.changesets.length > 0)
|
|
4847
|
+
|| (parsed.isAlreadyConnected && (parsed.changesets.length > 0 || parsed.hasConflict));
|
|
4848
|
+
if (!ambiguous && attempt.exitCode === 0 && matchingChangesets.length === 1 && !parsed.hasConflict && !parsed.isAlreadyConnected)
|
|
4849
|
+
{
|
|
4850
|
+
const changeset = matchingChangesets[0]!;
|
|
4851
|
+
return formatServerMergeResult(format, "completed", `## Server Merge Completed\n\n- Created target changeset: cs:${changeset.id}\n- Merge-link identity: unverified\n- Xlink effects: unverified`, {
|
|
4852
|
+
...baseData,
|
|
4853
|
+
createdChangeset: changeset,
|
|
4854
|
+
effect: "changeset-created",
|
|
4855
|
+
});
|
|
4856
|
+
}
|
|
4857
|
+
if (!ambiguous && attempt.exitCode === 0 && parsed.isAlreadyConnected && parsed.changesets.length === 0 && !parsed.hasConflict)
|
|
4858
|
+
{
|
|
4859
|
+
return formatServerMergeResult(format, "no-op", "## Server Merge No-op\n\n- Plastic reported ALREADY_CONNECTED.\n- Effect: not independently verified.", baseData);
|
|
4860
|
+
}
|
|
4861
|
+
if (!ambiguous && attempt.exitCode !== 0 && parsed.hasConflict && parsed.changesets.length === 0)
|
|
4862
|
+
{
|
|
4863
|
+
return formatServerMergeResult(format, "conflict", "## Server Merge Conflict\n\n- Plastic reported a file conflict.\n- Effect: uncertain; inspect the server before any retry.", { ...baseData, effect: "uncertain" });
|
|
4864
|
+
}
|
|
4865
|
+
return formatServerMergeResult(format, "uncertain", "## Server Merge Uncertain\n\n- The command may have had an effect, but its bounded output does not prove a safe classification.\n- Do not retry automatically; inspect server state first.", {
|
|
4866
|
+
...baseData,
|
|
4867
|
+
effect: "uncertain",
|
|
4868
|
+
parseWarnings: {
|
|
4869
|
+
malformed: parsed.malformed,
|
|
4870
|
+
unknownOperations: parsed.unknownOperations,
|
|
4871
|
+
matchingChangesetCount: matchingChangesets.length,
|
|
4872
|
+
},
|
|
4873
|
+
});
|
|
4874
|
+
},
|
|
4875
|
+
});
|
|
4876
|
+
|
|
4363
4877
|
export const mergeToBranch = tool({
|
|
4364
4878
|
description: "Merge a source branch into a target branch using safe Plastic update, switch, merge, and checkin steps.",
|
|
4365
4879
|
args: {
|
|
@@ -4379,11 +4893,19 @@ export const mergeToBranch = tool({
|
|
|
4379
4893
|
const format = args.format ?? "text";
|
|
4380
4894
|
const startingBranch = await resolveCurrentBranchName(args.workdir);
|
|
4381
4895
|
const sourceBranch = args.source ?? startingBranch;
|
|
4382
|
-
const
|
|
4383
|
-
if (!args.target &&
|
|
4384
|
-
{
|
|
4385
|
-
|
|
4386
|
-
|
|
4896
|
+
const parentLookup = args.target ? undefined : await resolveBranchParentName(sourceBranch, args.workdir);
|
|
4897
|
+
if (!args.target && parentLookup?.kind !== "resolved")
|
|
4898
|
+
{
|
|
4899
|
+
const details = parentLookup?.kind === "command-failed"
|
|
4900
|
+
? ` Plastic lookup failed: ${parentLookup.diagnostics.join(" | ")}`
|
|
4901
|
+
: parentLookup?.kind === "malformed-output"
|
|
4902
|
+
? ` Plastic lookup returned unusable output: ${parentLookup.diagnostics.join(" | ")}`
|
|
4903
|
+
: parentLookup?.kind === "root"
|
|
4904
|
+
? ` ${parentLookup.matchedBranch} is a root branch with no parent.`
|
|
4905
|
+
: " Plastic found no matching branch row.";
|
|
4906
|
+
throw new Error(`Unable to resolve the parent branch for ${sourceBranch}. Pass target explicitly.${details}`);
|
|
4907
|
+
}
|
|
4908
|
+
const resolvedParentBranch = parentLookup?.kind === "resolved" ? parentLookup.parent : undefined;
|
|
4387
4909
|
const targetBranch = args.target ?? resolvedParentBranch!;
|
|
4388
4910
|
const strategy: MergeConflictStrategy = args.strategy ?? "auto";
|
|
4389
4911
|
const updateTarget = args.updateTarget ?? true;
|
|
@@ -4448,12 +4970,50 @@ export const mergeToBranch = tool({
|
|
|
4448
4970
|
);
|
|
4449
4971
|
}
|
|
4450
4972
|
|
|
4973
|
+
// Inspect the cancel policy as typed data before any target-side mutation.
|
|
4974
|
+
const pendingBeforeSwitchItems = await getMachineReadablePendingItems(args.workdir);
|
|
4975
|
+
const pendingBeforeSwitchDetailed = summarizePendingItems(pendingBeforeSwitchItems, args.workdir ?? process.cwd());
|
|
4976
|
+
const canceledSwitchOutcome = createCanceledSwitchOutcome(
|
|
4977
|
+
startingBranch,
|
|
4978
|
+
targetBranch,
|
|
4979
|
+
toLegacyPendingSummary(pendingBeforeSwitchDetailed),
|
|
4980
|
+
pendingBeforeSwitchDetailed,
|
|
4981
|
+
"cancel",
|
|
4982
|
+
false,
|
|
4983
|
+
);
|
|
4984
|
+
if (canceledSwitchOutcome)
|
|
4985
|
+
{
|
|
4986
|
+
return toStructuredResult(
|
|
4987
|
+
"merge-to-branch",
|
|
4988
|
+
format,
|
|
4989
|
+
[
|
|
4990
|
+
"## Merge To Branch Blocked",
|
|
4991
|
+
"",
|
|
4992
|
+
`- Source branch: ${sourceBranch}`,
|
|
4993
|
+
`- Target branch: ${targetBranch}`,
|
|
4994
|
+
`- ${canceledSwitchOutcome.reason}`,
|
|
4995
|
+
"- No switch, update, merge, checkin, or shelveset command was run.",
|
|
4996
|
+
].join("\n"),
|
|
4997
|
+
{
|
|
4998
|
+
sourceBranch,
|
|
4999
|
+
targetBranch,
|
|
5000
|
+
switchOutcome: canceledSwitchOutcome,
|
|
5001
|
+
checkedIn: false,
|
|
5002
|
+
},
|
|
5003
|
+
args.workdir,
|
|
5004
|
+
[canceledSwitchOutcome.reason],
|
|
5005
|
+
"Resolve or shelve pending changes before retrying the merge closeout.",
|
|
5006
|
+
);
|
|
5007
|
+
}
|
|
5008
|
+
|
|
4451
5009
|
const switchResult = await switchBranch.execute({
|
|
4452
5010
|
branch: targetBranch,
|
|
4453
5011
|
pendingChanges: "cancel",
|
|
4454
5012
|
format: "json",
|
|
4455
5013
|
workdir: args.workdir,
|
|
4456
5014
|
});
|
|
5015
|
+
const branchAfterSwitch = await resolveCurrentBranchName(args.workdir);
|
|
5016
|
+
assertWorkspaceOnBranch(branchAfterSwitch, targetBranch, "branch switch and before target update");
|
|
4457
5017
|
const updateResult = updateTarget ? await update.execute({ workdir: args.workdir }) : "(skipped)";
|
|
4458
5018
|
const branchBeforeMerge = await resolveCurrentBranchName(args.workdir);
|
|
4459
5019
|
assertWorkspaceOnBranch(branchBeforeMerge, targetBranch, "target update and before merge");
|
|
@@ -33,6 +33,7 @@ export const PLASTIC_SEARCH_CATALOG: readonly PlasticSearchCatalogEntry[] = [
|
|
|
33
33
|
{ name: "plastic_branchCreate", aliases: ["create branch", "new branch"], tags: ["branch", "mutation", "workflow"], guidance: ["Create normal work branches beneath an intended parent, which may differ from the loaded branch. Use allowRootBranch only for intentional top-level branches."] },
|
|
34
34
|
{ name: "plastic_switchBranch", aliases: ["switch branch", "checkout branch", "change branch"], tags: ["branch", "workspace", "mutation"], guidance: ["Check pending changes and choose how to handle them before switching branches."] },
|
|
35
35
|
{ name: "plastic_merge", aliases: ["merge branch", "merge changes"], tags: ["merge", "conflict", "workflow"], guidance: ["Inspect merge state and resolve conflicts before checkin."] },
|
|
36
|
+
{ name: "plastic_mergeBranches", aliases: ["server merge", "workspace-free merge", "merge qualified branches"], tags: ["merge", "branch", "server", "mutation"], guidance: ["Require explicit same-repository/server qualified source and target plus a nonempty message. Preflight only renders the command; completed output verifies a target changeset, not a merge link, exclusive target ownership, or xlink effects."] },
|
|
36
37
|
{ name: "plastic_mergeToBranch", aliases: ["merge to branch", "close branch", "merge closeout"], tags: ["merge", "branch", "closeout"], guidance: ["Plastic merges into the branch loaded in the workspace; confirm source, target, final branch, and target-branch checkin rather than assuming a Git-style fast-forward."] },
|
|
37
38
|
{ name: "plastic_finalizeMerge", aliases: ["finalize merge", "complete merge", "resolve merge"], tags: ["merge", "conflict", "finalize"], guidance: ["Use after reviewed manual conflict resolution when Plastic still needs merge metadata finalized."] },
|
|
38
39
|
{ name: "plastic_currentBranch", aliases: ["current branch", "which branch", "branch name"], tags: ["branch", "workspace", "inspect"], guidance: ["Inspect the current branch before branch, merge, or checkin operations."] },
|