@aefree/pi-plastic 0.5.7 → 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 +24 -0
- package/README.md +6 -3
- package/index.ts +7 -1
- package/package.json +6 -5
- package/skills/using-plastic/SKILL.md +2 -0
- package/src/plastic-core.ts +633 -33
- package/src/plastic-tool-loading.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ 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
|
+
|
|
27
|
+
## [0.5.8] - 2026-09-15
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- Resolve pending-diff workspace containment and Xlink ownership by filesystem identity when a workspace is accessed through a real filesystem alias, while retaining the caller's lexical path for Plastic commands and displayed paths. Missing deleted or moved ownership ancestors still fail closed.
|
|
32
|
+
- Retry only npm registry E404 availability checks for a bounded period after trusted publishing; a mismatched, malformed, or unauthorized release identity still fails immediately.
|
|
33
|
+
|
|
10
34
|
## [0.5.7] - 2026-09-14
|
|
11
35
|
|
|
12
36
|
### 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
|
|
|
@@ -75,10 +76,12 @@ The Bash guards run only in Pi processes that actually load this package. A proj
|
|
|
75
76
|
- `plastic_branchCreate` supports an explicit parent branch independent of the loaded workspace branch, defaults relative names to the current branch when no parent is supplied, and rejects top-level paths unless `allowRootBranch=true` is explicit.
|
|
76
77
|
- `plastic_diff` remains a disabled alias by design. Use `plastic_status` for changed-path listing; do not diff as routine post-edit validation or checkin preflight. When change-boundary evidence is needed, use `plastic_diffFile` for one exact file, `plastic_workspaceDiff` with selected paths or explicit `allPending=true`, or `plastic_diffRevisions` for an explicit historical pair. Diff responses use small defaults and caller-controlled `maxChars` bounds. Automatic pending-file bases bind each status revision ID to its owning workspace or Xlink repository/server, including mixed-Xlink reviews. If ownership cannot be resolved, that comparison is reported unavailable rather than retrieving a bare revision from the caller repository. Historical and workspace bytes are copied to ASCII-safe package-owned paths before the backend runs, while normalized headers retain the logical Unicode labels.
|
|
77
78
|
- `plastic_status(machineReadable=true, format="json")` returns parsed `items`, aggregate `summary`, and `itemCount` (`total`, `returned`, `omitted`) rather than requiring agents to parse Plastic rows. It returns at most 100 items by default; set `maxItems` (up to 500) when more are intentional. `includeRaw=true` adds the original unbounded Plastic output for diagnostics, so omit it for normal agent use to conserve context tokens.
|
|
79
|
+
- Pending-diff ownership containment uses filesystem identity, so a workspace reached through a symlink, junction, or platform filesystem alias still resolves its workspace and nested Xlink bases safely. Plastic commands and returned labels retain the caller's original lexical path; missing deleted or moved ownership ancestors remain unavailable rather than falling back to a parent repository.
|
|
78
80
|
- Preflight is not routine confirmation. Use it for ambiguous or broad mutation scope, moved/deleted path rewriting, compound operations, or explicit preview requests; otherwise rely on exact targets and the tools' runtime guards.
|
|
79
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>`.
|
|
80
82
|
- Bash safety rails block `cm diff` and unsafe interactive `cm merge --merge` usage.
|
|
81
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.
|
|
82
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.
|
|
83
86
|
- `plastic_finalizeMerge` supports reviewed/manual-resolution flows where Plastic still needs merge metadata finalized before checkin.
|
|
84
87
|
|
|
@@ -108,13 +111,13 @@ pi install npm:@aefree/pi-plastic
|
|
|
108
111
|
Install a pinned GitHub release over HTTPS:
|
|
109
112
|
|
|
110
113
|
```bash
|
|
111
|
-
pi install git:github.com/aefreedman/pi-plastic@v0.
|
|
114
|
+
pi install git:github.com/aefreedman/pi-plastic@v0.6.0
|
|
112
115
|
```
|
|
113
116
|
|
|
114
117
|
Equivalent SSH install:
|
|
115
118
|
|
|
116
119
|
```bash
|
|
117
|
-
pi install git:git@github.com/aefreedman/pi-plastic@v0.
|
|
120
|
+
pi install git:git@github.com/aefreedman/pi-plastic@v0.6.0
|
|
118
121
|
```
|
|
119
122
|
|
|
120
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
|
|
|
@@ -406,6 +439,37 @@ const findCanonicalExistingAncestor = (absolutePath: string): string | null =>
|
|
|
406
439
|
}
|
|
407
440
|
};
|
|
408
441
|
|
|
442
|
+
const toFilesystemIdentityPath = async (absolutePath: string): Promise<string> =>
|
|
443
|
+
{
|
|
444
|
+
let candidate = absolutePath;
|
|
445
|
+
const missingSegments: string[] = [];
|
|
446
|
+
while (true)
|
|
447
|
+
{
|
|
448
|
+
try
|
|
449
|
+
{
|
|
450
|
+
const canonicalAncestor = (await fs.realpath(candidate)).replace(/\\/g, "/");
|
|
451
|
+
return missingSegments.reduce((identityPath, segment) => join(identityPath, segment), canonicalAncestor).replace(/\\/g, "/");
|
|
452
|
+
}
|
|
453
|
+
catch (error)
|
|
454
|
+
{
|
|
455
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
456
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
457
|
+
{
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const parent = dirname(candidate);
|
|
462
|
+
if (parent === candidate)
|
|
463
|
+
{
|
|
464
|
+
throw error;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
missingSegments.unshift(basename(candidate));
|
|
468
|
+
candidate = parent;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
};
|
|
472
|
+
|
|
409
473
|
const toggleFirstAsciiLetterCase = (value: string): string | null =>
|
|
410
474
|
{
|
|
411
475
|
const index = value.search(/[A-Za-z]/);
|
|
@@ -1186,6 +1250,47 @@ const isSwitchBringBlockedForUnattended = (pendingChoice: "shelve" | "bring" | "
|
|
|
1186
1250
|
return pendingChoice === "bring" && profile.hasTrackedPendingChanges;
|
|
1187
1251
|
};
|
|
1188
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
|
+
|
|
1189
1294
|
const canSwitchDirectWithPrivateOnlyPending = (
|
|
1190
1295
|
pendingChoice: "shelve" | "bring" | "cancel",
|
|
1191
1296
|
defaultedPolicy: boolean,
|
|
@@ -1814,9 +1919,10 @@ const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): Pendi
|
|
|
1814
1919
|
});
|
|
1815
1920
|
return workspaceRepository;
|
|
1816
1921
|
};
|
|
1817
|
-
const getXlinkRepository = (candidate: string): Promise<RepositoryIdentity | null> =>
|
|
1922
|
+
const getXlinkRepository = async (candidate: string): Promise<RepositoryIdentity | null> =>
|
|
1818
1923
|
{
|
|
1819
|
-
|
|
1924
|
+
// Use the physical identity for cache keys, but keep candidate lexical for cm.
|
|
1925
|
+
const key = toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate));
|
|
1820
1926
|
let lookup = xlinkLookups.get(key);
|
|
1821
1927
|
if (!lookup)
|
|
1822
1928
|
{
|
|
@@ -1851,15 +1957,22 @@ const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): Pendi
|
|
|
1851
1957
|
throw new Error(`Plastic status did not provide a base revision ID for '${item.workspacePath}'.`);
|
|
1852
1958
|
}
|
|
1853
1959
|
const root = await getWorkspaceRoot();
|
|
1960
|
+
const rootIdentity = await toFilesystemIdentityPath(root);
|
|
1854
1961
|
const ownershipPath = item.kind === "moved" && item.sourceWorkspacePath
|
|
1855
1962
|
? toNormalizedAbsolutePath(item.sourceWorkspacePath, cwd)
|
|
1856
1963
|
: item.normalizedPath;
|
|
1857
|
-
if (!isWithinPathScope(
|
|
1964
|
+
if (!isWithinPathScope(
|
|
1965
|
+
toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(ownershipPath)),
|
|
1966
|
+
toPathComparisonKeyFromAbsolutePath(rootIdentity),
|
|
1967
|
+
))
|
|
1858
1968
|
{
|
|
1859
1969
|
throw new Error(`Plastic cannot resolve an owning repository for '${item.workspacePath}' outside the requested workspace.`);
|
|
1860
1970
|
}
|
|
1861
1971
|
let candidate = dirname(ownershipPath);
|
|
1862
|
-
while (isWithinPathScope(
|
|
1972
|
+
while (isWithinPathScope(
|
|
1973
|
+
toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate)),
|
|
1974
|
+
toPathComparisonKeyFromAbsolutePath(rootIdentity),
|
|
1975
|
+
))
|
|
1863
1976
|
{
|
|
1864
1977
|
// A missing ancestor can be a removed Xlink mount. Status does not
|
|
1865
1978
|
// retain ownership, so parent-repository fallback would be unsafe.
|
|
@@ -1873,7 +1986,8 @@ const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): Pendi
|
|
|
1873
1986
|
item.baseRepository = `${repository.repository}@${repository.server}`;
|
|
1874
1987
|
return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
|
|
1875
1988
|
}
|
|
1876
|
-
if (candidate
|
|
1989
|
+
if (toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate))
|
|
1990
|
+
=== toPathComparisonKeyFromAbsolutePath(rootIdentity))
|
|
1877
1991
|
{
|
|
1878
1992
|
break;
|
|
1879
1993
|
}
|
|
@@ -2526,30 +2640,130 @@ const buildMergeInProgressCheckinMessage = async (originalMessage: string, workd
|
|
|
2526
2640
|
return lines.join("\n");
|
|
2527
2641
|
};
|
|
2528
2642
|
|
|
2529
|
-
|
|
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 =>
|
|
2530
2675
|
{
|
|
2531
|
-
const
|
|
2532
|
-
|
|
2533
|
-
|
|
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> =>
|
|
2695
|
+
{
|
|
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
|
+
: "";
|
|
2534
2710
|
|
|
2535
2711
|
for (const candidate of candidates)
|
|
2536
2712
|
{
|
|
2537
|
-
|
|
2538
|
-
"find",
|
|
2539
|
-
"branch",
|
|
2540
|
-
`where ${cmWhereEquals("name", candidate)}`,
|
|
2541
|
-
"--format={parent}",
|
|
2542
|
-
"--nototal",
|
|
2543
|
-
], workdir).catch(() => "");
|
|
2544
|
-
const lines = normalizeFindOutputLines(output);
|
|
2545
|
-
const parent = lines[0]?.trim();
|
|
2546
|
-
if (parent)
|
|
2713
|
+
try
|
|
2547
2714
|
{
|
|
2548
|
-
|
|
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));
|
|
2549
2758
|
}
|
|
2550
2759
|
}
|
|
2551
2760
|
|
|
2552
|
-
|
|
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 };
|
|
2553
2767
|
};
|
|
2554
2768
|
|
|
2555
2769
|
const resolveCurrentBranchName = async (workdir?: string): Promise<string> =>
|
|
@@ -4007,11 +4221,17 @@ export const switchBranch = tool({
|
|
|
4007
4221
|
throw new Error(reason);
|
|
4008
4222
|
}
|
|
4009
4223
|
|
|
4010
|
-
|
|
4224
|
+
const canceledOutcome = createCanceledSwitchOutcome(
|
|
4225
|
+
branchBefore,
|
|
4226
|
+
args.branch,
|
|
4227
|
+
pendingSummary,
|
|
4228
|
+
pendingSummaryDetailed,
|
|
4229
|
+
pendingChoice,
|
|
4230
|
+
defaulted,
|
|
4231
|
+
);
|
|
4232
|
+
if (canceledOutcome)
|
|
4011
4233
|
{
|
|
4012
|
-
const reason =
|
|
4013
|
-
? "Switch canceled because pending changes were detected and the default policy is cancel unless pendingChanges is set."
|
|
4014
|
-
: "Switch canceled because pending changes were detected and pendingChanges was set to cancel.";
|
|
4234
|
+
const reason = canceledOutcome.reason;
|
|
4015
4235
|
if (preflight)
|
|
4016
4236
|
{
|
|
4017
4237
|
return toStructuredResult(
|
|
@@ -4320,6 +4540,340 @@ export const merge = tool({
|
|
|
4320
4540
|
},
|
|
4321
4541
|
});
|
|
4322
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
|
+
|
|
4323
4877
|
export const mergeToBranch = tool({
|
|
4324
4878
|
description: "Merge a source branch into a target branch using safe Plastic update, switch, merge, and checkin steps.",
|
|
4325
4879
|
args: {
|
|
@@ -4339,11 +4893,19 @@ export const mergeToBranch = tool({
|
|
|
4339
4893
|
const format = args.format ?? "text";
|
|
4340
4894
|
const startingBranch = await resolveCurrentBranchName(args.workdir);
|
|
4341
4895
|
const sourceBranch = args.source ?? startingBranch;
|
|
4342
|
-
const
|
|
4343
|
-
if (!args.target &&
|
|
4344
|
-
{
|
|
4345
|
-
|
|
4346
|
-
|
|
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;
|
|
4347
4909
|
const targetBranch = args.target ?? resolvedParentBranch!;
|
|
4348
4910
|
const strategy: MergeConflictStrategy = args.strategy ?? "auto";
|
|
4349
4911
|
const updateTarget = args.updateTarget ?? true;
|
|
@@ -4408,12 +4970,50 @@ export const mergeToBranch = tool({
|
|
|
4408
4970
|
);
|
|
4409
4971
|
}
|
|
4410
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
|
+
|
|
4411
5009
|
const switchResult = await switchBranch.execute({
|
|
4412
5010
|
branch: targetBranch,
|
|
4413
5011
|
pendingChanges: "cancel",
|
|
4414
5012
|
format: "json",
|
|
4415
5013
|
workdir: args.workdir,
|
|
4416
5014
|
});
|
|
5015
|
+
const branchAfterSwitch = await resolveCurrentBranchName(args.workdir);
|
|
5016
|
+
assertWorkspaceOnBranch(branchAfterSwitch, targetBranch, "branch switch and before target update");
|
|
4417
5017
|
const updateResult = updateTarget ? await update.execute({ workdir: args.workdir }) : "(skipped)";
|
|
4418
5018
|
const branchBeforeMerge = await resolveCurrentBranchName(args.workdir);
|
|
4419
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."] },
|