@aefree/pi-plastic 0.4.0 → 0.5.1
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 +9 -7
- package/extensions/bash-cm-diff-guard.ts +2 -1
- package/extensions/file-discovery-filter.ts +67 -0
- package/index.ts +18 -2
- package/package.json +7 -8
- package/skills/_shared/references/conventional-commits.md +198 -198
- package/skills/using-plastic/SKILL.md +3 -3
- package/skills/using-plastic/references/changeset-operations.md +5 -5
- package/skills/using-plastic/references/code-review-creation.md +30 -30
- package/skills/using-plastic/references/integration.md +5 -3
- package/skills/using-plastic/references/quick-reference.md +2 -1
- package/skills/using-plastic/references/resources.md +7 -7
- package/skills/using-plastic/references/reviewing-changes.md +9 -7
- package/skills/using-plastic/references/shelving.md +25 -25
- package/skills/using-plastic/references/troubleshooting.md +5 -3
- package/skills/using-plastic/references/workspaces.md +33 -33
- package/src/file-discovery-filter-rendezvous.ts +104 -0
- package/src/{repository-search-provider.ts → file-discovery-filter.ts} +21 -17
- package/src/plastic-core.ts +579 -170
- package/src/plastic-tool-loading.ts +3 -3
- package/evals/tool-loading/README.md +0 -38
- package/evals/tool-loading/RESULTS.md +0 -57
- package/evals/tool-loading/baseline.json +0 -34
- package/evals/tool-loading/cases.json +0 -70
- package/evals/tool-loading/config.json +0 -19
- package/evals/tool-loading/mutation-guard.ts +0 -30
- package/evals/tool-loading/provider-capture.ts +0 -22
- package/evals/tool-loading/results/.gitignore +0 -3
- package/evals/tool-loading/run-eval.ts +0 -372
- package/extensions/repository-search-provider.ts +0 -66
- package/tests/bash-cm-diff-guard-validation.ts +0 -55
- package/tests/fixtures/plastic.selector +0 -3
- package/tests/fixtures/status-compact.txt +0 -1
- package/tests/fixtures/status-normal.txt +0 -1
- package/tests/packed-copy-owner-conflict.test.ts +0 -28
- package/tests/pi-tool-harness.ts +0 -116
- package/tests/plastic-branch-create-live.test.ts +0 -78
- package/tests/plastic-branch-query.test.ts +0 -26
- package/tests/plastic-branch-status.test.ts +0 -241
- package/tests/plastic-checkin-path-resolution.ts +0 -219
- package/tests/plastic-constrained-sampling.test.ts +0 -77
- package/tests/plastic-diff-workflow.test.ts +0 -86
- package/tests/plastic-dynamic-tool-loading.test.ts +0 -224
- package/tests/plastic-extension-registration.test.ts +0 -34
- package/tests/plastic-live-smoke.test.ts +0 -37
- package/tests/plastic-mutation-execution.test.ts +0 -61
- package/tests/plastic-patch-command.test.ts +0 -118
- package/tests/plastic-process-lifecycle.test.ts +0 -148
- package/tests/plastic-tool-rendering.test.ts +0 -16
- package/tests/plastic-tool-validation.ts +0 -153
- package/tests/plastic-workspace.test.ts +0 -62
- package/tests/repository-search-provider-registration.test.ts +0 -37
- package/tests/repository-search-provider.test.ts +0 -27
package/src/plastic-core.ts
CHANGED
|
@@ -28,7 +28,6 @@ const resolveExecutable = (environment: ExecutableEnvironment, overrideVariable:
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
const getCmExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_CM_EXECUTABLE", "cm");
|
|
31
|
-
const getGitExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_GIT_EXECUTABLE", "git");
|
|
32
31
|
// `diff -u` has compatible exit semantics on Windows GNU diff and macOS BSD diff.
|
|
33
32
|
// Keep the executable name configurable rather than assuming a .exe suffix or package-manager path.
|
|
34
33
|
const getDiffExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
|
|
@@ -45,14 +44,9 @@ const executableNotFoundError = (command: string, error: Error): Error =>
|
|
|
45
44
|
return new Error(`Unable to launch Plastic SCM executable '${command}' (ENOENT). Set PI_PLASTIC_CM_EXECUTABLE to the full path to cm, or add cm to PATH.`);
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
if (command === getGitExecutable())
|
|
49
|
-
{
|
|
50
|
-
return new Error(`Unable to launch Git executable '${command}' (ENOENT). Set PI_PLASTIC_GIT_EXECUTABLE to the full path to git, or add git to PATH.`);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
47
|
if (command === getDiffExecutable())
|
|
54
48
|
{
|
|
55
|
-
return new Error(`Unable to launch text diff executable '${command}' (ENOENT). Set PI_PLASTIC_DIFF_EXECUTABLE to
|
|
49
|
+
return new Error(`Unable to launch GNU/POSIX text diff executable '${command}' (ENOENT). Set PI_PLASTIC_DIFF_EXECUTABLE to the full path of GNU/POSIX diff (paths containing spaces are supported), or install bare diff on PATH. Pi does not discover Git Bash paths automatically.`);
|
|
56
50
|
}
|
|
57
51
|
|
|
58
52
|
return new Error(`Unable to launch executable '${command}' (ENOENT). Verify that it is installed and available on PATH.`);
|
|
@@ -251,7 +245,7 @@ const spawnAndCollect = async (
|
|
|
251
245
|
}
|
|
252
246
|
};
|
|
253
247
|
|
|
254
|
-
const BLOCKED_CM_DIFF_MESSAGE = "`cm diff` is blocked in Pi because it may launch GUI windows and hang the CLI. Use `plastic_status`
|
|
248
|
+
const BLOCKED_CM_DIFF_MESSAGE = "`cm diff` is blocked in Pi because it may launch GUI windows and hang the CLI. Use `plastic_status` when only changed paths are needed. Do not diff routinely; when change-boundary evidence is necessary, use a focused `plastic_diffFile`, explicitly scoped `plastic_workspaceDiff`, or `plastic_diffRevisions`.";
|
|
255
249
|
|
|
256
250
|
const ensureCmCommandAllowed = (args: string[]): void =>
|
|
257
251
|
{
|
|
@@ -313,6 +307,7 @@ type PendingItem = {
|
|
|
313
307
|
isDirectory: boolean;
|
|
314
308
|
kind: PendingItemKind;
|
|
315
309
|
revisionId?: string;
|
|
310
|
+
sourceWorkspacePath?: string;
|
|
316
311
|
};
|
|
317
312
|
|
|
318
313
|
type PendingItemSummary = {
|
|
@@ -591,11 +586,30 @@ const inferPendingItemKind = (statusCode: string): PendingItemKind =>
|
|
|
591
586
|
return "other";
|
|
592
587
|
};
|
|
593
588
|
|
|
589
|
+
// Unit separator is accepted by Plastic and cannot occur in supported workspace paths.
|
|
590
|
+
const STATUS_FIELD_SEPARATOR = "\x1f";
|
|
591
|
+
|
|
594
592
|
const parseMachineReadablePendingItems = (output: string, cwd: string): PendingItem[] =>
|
|
595
593
|
{
|
|
596
594
|
const lines = normalizeFindOutputLines(output);
|
|
597
|
-
|
|
595
|
+
// Legacy records have no reliable way to distinguish the source and destination
|
|
596
|
+
// fields of a move, so they remain supported for normal records only.
|
|
597
|
+
const legacyStatusLinePattern = /^([A-Z+]+)\s+(.+)\s+(True|False)(?:\s+(.*))?$/;
|
|
598
598
|
const pendingItems: PendingItem[] = [];
|
|
599
|
+
const addItem = (statusCode: string, workspacePath: string, isDirectory: boolean, revisionId?: string, sourceWorkspacePath?: string): void =>
|
|
600
|
+
{
|
|
601
|
+
const normalizedPath = toNormalizedAbsolutePath(workspacePath, cwd);
|
|
602
|
+
pendingItems.push({
|
|
603
|
+
statusCode,
|
|
604
|
+
workspacePath,
|
|
605
|
+
normalizedPath,
|
|
606
|
+
comparisonKey: toPathComparisonKeyFromAbsolutePath(normalizedPath),
|
|
607
|
+
isDirectory,
|
|
608
|
+
kind: inferPendingItemKind(statusCode),
|
|
609
|
+
...(revisionId ? { revisionId } : {}),
|
|
610
|
+
...(sourceWorkspacePath ? { sourceWorkspacePath } : {}),
|
|
611
|
+
});
|
|
612
|
+
};
|
|
599
613
|
|
|
600
614
|
for (const line of lines)
|
|
601
615
|
{
|
|
@@ -604,27 +618,57 @@ const parseMachineReadablePendingItems = (output: string, cwd: string): PendingI
|
|
|
604
618
|
continue;
|
|
605
619
|
}
|
|
606
620
|
|
|
607
|
-
|
|
621
|
+
if (line.includes(STATUS_FIELD_SEPARATOR))
|
|
622
|
+
{
|
|
623
|
+
const fields = line.split(STATUS_FIELD_SEPARATOR);
|
|
624
|
+
const statusCode = fields[0];
|
|
625
|
+
if (!/^[A-Z+]+$/.test(statusCode))
|
|
626
|
+
{
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const kind = inferPendingItemKind(statusCode);
|
|
631
|
+
if (kind === "moved")
|
|
632
|
+
{
|
|
633
|
+
// MV: status, percent, source, destination, directory, revision, metadata.
|
|
634
|
+
const [,, sourceWorkspacePath, workspacePath, directoryField, revisionField] = fields;
|
|
635
|
+
if (!sourceWorkspacePath || !workspacePath || (directoryField !== "True" && directoryField !== "False"))
|
|
636
|
+
{
|
|
637
|
+
continue;
|
|
638
|
+
}
|
|
639
|
+
addItem(statusCode, workspacePath, directoryField === "True", /^\d+$/.test(revisionField ?? "") ? revisionField : undefined, sourceWorkspacePath);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Ordinary records: status, path, directory, revision, metadata.
|
|
644
|
+
// Only moved records include a similarity-percent field.
|
|
645
|
+
const [, workspacePath, directoryField, revisionField] = fields;
|
|
646
|
+
if (!workspacePath || (directoryField !== "True" && directoryField !== "False"))
|
|
647
|
+
{
|
|
648
|
+
continue;
|
|
649
|
+
}
|
|
650
|
+
addItem(statusCode, workspacePath, directoryField === "True", /^\d+$/.test(revisionField ?? "") ? revisionField : undefined);
|
|
651
|
+
continue;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const match = line.match(legacyStatusLinePattern);
|
|
608
655
|
if (!match)
|
|
609
656
|
{
|
|
610
657
|
continue;
|
|
611
658
|
}
|
|
612
659
|
|
|
613
660
|
const statusCode = match[1];
|
|
661
|
+
// A whitespace-delimited MV record cannot safely reveal which portion is
|
|
662
|
+
// its destination. Skipping it prevents checkin/diff from targeting a
|
|
663
|
+
// wrong workspace path; package-owned calls always request a separator.
|
|
664
|
+
if (inferPendingItemKind(statusCode) === "moved")
|
|
665
|
+
{
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
614
668
|
const workspacePath = match[2];
|
|
615
669
|
const isDirectory = match[3] === "True";
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
const normalizedPath = toNormalizedAbsolutePath(workspacePath, cwd);
|
|
619
|
-
pendingItems.push({
|
|
620
|
-
statusCode,
|
|
621
|
-
workspacePath,
|
|
622
|
-
normalizedPath,
|
|
623
|
-
comparisonKey: toPathComparisonKeyFromAbsolutePath(normalizedPath),
|
|
624
|
-
isDirectory,
|
|
625
|
-
kind: inferPendingItemKind(statusCode),
|
|
626
|
-
...(revisionId ? { revisionId } : {}),
|
|
627
|
-
});
|
|
670
|
+
const revisionId = (match[4] ?? "").match(/^\s*(\d+)\b/)?.[1];
|
|
671
|
+
addItem(statusCode, workspacePath, isDirectory, revisionId);
|
|
628
672
|
}
|
|
629
673
|
|
|
630
674
|
return pendingItems;
|
|
@@ -633,7 +677,7 @@ const parseMachineReadablePendingItems = (output: string, cwd: string): PendingI
|
|
|
633
677
|
const getMachineReadablePendingItems = async (workdir?: string): Promise<PendingItem[]> =>
|
|
634
678
|
{
|
|
635
679
|
const cwd = workdir ?? process.cwd();
|
|
636
|
-
const output = await runCmRaw(["status", "--machinereadable", "--includeRevId"], workdir);
|
|
680
|
+
const output = await runCmRaw(["status", "--machinereadable", "--includeRevId", `--fieldseparator=${STATUS_FIELD_SEPARATOR}`], workdir);
|
|
637
681
|
return parseMachineReadablePendingItems(output, cwd);
|
|
638
682
|
};
|
|
639
683
|
|
|
@@ -856,14 +900,6 @@ const buildFallbackScopePaths = (absolutePaths: string[], cwd: string): string[]
|
|
|
856
900
|
return dedupeAndMinimizeAbsolutePaths(fallbackAbsolutePaths).map((path) => toCommandPath(path, cwd));
|
|
857
901
|
};
|
|
858
902
|
|
|
859
|
-
const isGitUnknownOptionLabelError = (message: string): boolean =>
|
|
860
|
-
{
|
|
861
|
-
const normalized = message.toLowerCase();
|
|
862
|
-
return normalized.includes("unknown option `label'")
|
|
863
|
-
|| normalized.includes("unknown option 'label'")
|
|
864
|
-
|| normalized.includes("unknown option \"label\"");
|
|
865
|
-
};
|
|
866
|
-
|
|
867
903
|
const isRevisionNotFoundError = (message: string): boolean =>
|
|
868
904
|
{
|
|
869
905
|
const normalized = message.toLowerCase();
|
|
@@ -1193,7 +1229,6 @@ const buildPatchCommandArgs = (args: PatchCommandArgs): string[] =>
|
|
|
1193
1229
|
|
|
1194
1230
|
export const __plasticProcessInternals = {
|
|
1195
1231
|
resolveCmExecutable: (environment: ExecutableEnvironment = process.env): string => resolveExecutable(environment, "PI_PLASTIC_CM_EXECUTABLE", "cm"),
|
|
1196
|
-
resolveGitExecutable: (environment: ExecutableEnvironment = process.env): string => resolveExecutable(environment, "PI_PLASTIC_GIT_EXECUTABLE", "git"),
|
|
1197
1232
|
resolveDiffExecutable: (environment: ExecutableEnvironment = process.env): string => resolveExecutable(environment, "PI_PLASTIC_DIFF_EXECUTABLE", "diff"),
|
|
1198
1233
|
spawnAndCollect,
|
|
1199
1234
|
runCm,
|
|
@@ -1218,7 +1253,6 @@ export const __plasticCheckinInternals = {
|
|
|
1218
1253
|
resolveCheckinPaths,
|
|
1219
1254
|
buildFallbackScopePaths,
|
|
1220
1255
|
isNoChangesWorkspaceCheckinError,
|
|
1221
|
-
isGitUnknownOptionLabelError,
|
|
1222
1256
|
isRevisionNotFoundError,
|
|
1223
1257
|
normalizeDiffFileRevisionSpec,
|
|
1224
1258
|
resolveDiffFileRevision,
|
|
@@ -1242,84 +1276,14 @@ export const __plasticBranchInternals = {
|
|
|
1242
1276
|
resolveBranchCreationTarget,
|
|
1243
1277
|
};
|
|
1244
1278
|
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
exitCode: number;
|
|
1249
|
-
output: string;
|
|
1250
|
-
};
|
|
1251
|
-
|
|
1252
|
-
const runGitDiffNoIndexOnce = async (args: string[], cwd: string): Promise<GitDiffResult> =>
|
|
1253
|
-
{
|
|
1254
|
-
const { stdout, stderr, exitCode } = await spawnAndCollect(getGitExecutable(), args, cwd);
|
|
1255
|
-
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
1256
|
-
|
|
1257
|
-
return {
|
|
1258
|
-
exitCode,
|
|
1259
|
-
output,
|
|
1260
|
-
};
|
|
1261
|
-
};
|
|
1262
|
-
|
|
1263
|
-
const buildGitDiffNoIndexArgs = (
|
|
1264
|
-
left: string,
|
|
1265
|
-
right: string,
|
|
1266
|
-
labelLeft?: string,
|
|
1267
|
-
labelRight?: string,
|
|
1268
|
-
includeLabels = true,
|
|
1269
|
-
): string[] =>
|
|
1270
|
-
{
|
|
1271
|
-
const cmdArgs = ["diff", "--no-index"];
|
|
1272
|
-
|
|
1273
|
-
if (includeLabels && labelLeft)
|
|
1274
|
-
{
|
|
1275
|
-
cmdArgs.push("--label", labelLeft);
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
if (includeLabels && labelRight)
|
|
1279
|
-
{
|
|
1280
|
-
cmdArgs.push("--label", labelRight);
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
cmdArgs.push("--", left, right);
|
|
1284
|
-
return cmdArgs;
|
|
1285
|
-
};
|
|
1286
|
-
|
|
1287
|
-
const runGitDiffNoIndex = async (
|
|
1288
|
-
left: string,
|
|
1289
|
-
right: string,
|
|
1290
|
-
workdir?: string,
|
|
1291
|
-
labelLeft?: string,
|
|
1292
|
-
labelRight?: string,
|
|
1293
|
-
): Promise<string> =>
|
|
1294
|
-
{
|
|
1295
|
-
const cwd = workdir ?? process.cwd();
|
|
1296
|
-
const hasLabels = Boolean(labelLeft || labelRight);
|
|
1297
|
-
const includeLabels = hasLabels && gitNoIndexSupportsLabel !== false;
|
|
1298
|
-
let result = await runGitDiffNoIndexOnce(
|
|
1299
|
-
buildGitDiffNoIndexArgs(left, right, labelLeft, labelRight, includeLabels),
|
|
1300
|
-
cwd,
|
|
1301
|
-
);
|
|
1302
|
-
|
|
1303
|
-
if (includeLabels && result.exitCode > 1 && isGitUnknownOptionLabelError(result.output))
|
|
1304
|
-
{
|
|
1305
|
-
gitNoIndexSupportsLabel = false;
|
|
1306
|
-
result = await runGitDiffNoIndexOnce(buildGitDiffNoIndexArgs(left, right), cwd);
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
if (includeLabels && result.exitCode <= 1 && gitNoIndexSupportsLabel === null)
|
|
1310
|
-
{
|
|
1311
|
-
gitNoIndexSupportsLabel = true;
|
|
1312
|
-
}
|
|
1313
|
-
|
|
1314
|
-
if (result.exitCode > 1)
|
|
1315
|
-
{
|
|
1316
|
-
throw new Error(result.output.length > 0 ? result.output : `git diff failed with exit code ${result.exitCode}`);
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
return result.output;
|
|
1320
|
-
};
|
|
1321
|
-
|
|
1279
|
+
// Capture enough subprocess output for diagnostics while keeping the default
|
|
1280
|
+
// agent-facing response intentionally small. Callers may opt into a larger
|
|
1281
|
+
// focused response, but never the full capture bound.
|
|
1322
1282
|
const DIFF_OUTPUT_MAX_CHARS = 60_000;
|
|
1283
|
+
const DIFF_RESPONSE_DEFAULT_MAX_CHARS = 8_000;
|
|
1284
|
+
const DIFF_RESPONSE_MAX_CHARS = 20_000;
|
|
1285
|
+
const DIFF_RESPONSE_MIN_CHARS = 500;
|
|
1286
|
+
const DIFF_RESPONSE_TOTAL_MAX_CHARS = 24_000;
|
|
1323
1287
|
|
|
1324
1288
|
type TextDiffResult = {
|
|
1325
1289
|
backend: "diff";
|
|
@@ -1385,8 +1349,8 @@ const runPortableTextDiff = async (
|
|
|
1385
1349
|
["-u", leftPath, rightPath],
|
|
1386
1350
|
cwd,
|
|
1387
1351
|
undefined,
|
|
1388
|
-
|
|
1389
|
-
{ outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
|
|
1352
|
+
getActiveAbortSignal(),
|
|
1353
|
+
{ ...commandExecutionStorage.getStore(), outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
|
|
1390
1354
|
);
|
|
1391
1355
|
if (aborted)
|
|
1392
1356
|
{
|
|
@@ -1438,45 +1402,105 @@ const materializeRevision = async (revision: string, destination: string, workdi
|
|
|
1438
1402
|
}
|
|
1439
1403
|
};
|
|
1440
1404
|
|
|
1405
|
+
const normalizeDiffResponseMaxChars = (value: unknown): number =>
|
|
1406
|
+
{
|
|
1407
|
+
if (typeof value !== "number" || !Number.isFinite(value))
|
|
1408
|
+
{
|
|
1409
|
+
return DIFF_RESPONSE_DEFAULT_MAX_CHARS;
|
|
1410
|
+
}
|
|
1411
|
+
return Math.min(DIFF_RESPONSE_MAX_CHARS, Math.max(DIFF_RESPONSE_MIN_CHARS, Math.trunc(value)));
|
|
1412
|
+
};
|
|
1413
|
+
|
|
1441
1414
|
const formatTextDiff = async (
|
|
1442
1415
|
action: "diffFile" | "diffRevisions",
|
|
1443
1416
|
format: OutputFormat | undefined,
|
|
1444
1417
|
comparisonKind: string,
|
|
1445
|
-
|
|
1418
|
+
unboundedResult: TextDiffResult,
|
|
1446
1419
|
workdir: string | undefined,
|
|
1420
|
+
comparisonMetadata: Record<string, unknown> = {},
|
|
1421
|
+
maxChars?: number,
|
|
1447
1422
|
): Promise<string> =>
|
|
1448
1423
|
{
|
|
1424
|
+
const result = boundTextDiffResult(unboundedResult, normalizeDiffResponseMaxChars(maxChars));
|
|
1449
1425
|
const status = result.binary ? (result.changed ? "binary-different" : "unchanged") : (result.changed ? "changed" : "unchanged");
|
|
1450
1426
|
const text = result.binary
|
|
1451
1427
|
? (result.changed ? "Binary content differs; a text diff is unavailable." : "No differences.")
|
|
1452
1428
|
: (result.changed ? result.output : "No differences.");
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
workdir,
|
|
1468
|
-
|
|
1429
|
+
const data = {
|
|
1430
|
+
comparisonKind,
|
|
1431
|
+
...comparisonMetadata,
|
|
1432
|
+
backend: result.backend,
|
|
1433
|
+
status,
|
|
1434
|
+
changed: result.changed,
|
|
1435
|
+
binary: result.binary,
|
|
1436
|
+
truncated: result.truncated,
|
|
1437
|
+
totalChars: result.totalChars,
|
|
1438
|
+
diff: result.changed && !result.binary ? result.output : undefined,
|
|
1439
|
+
};
|
|
1440
|
+
const warnings = result.truncated ? ["Diff output was truncated; use a narrower file or generate a review patch for the complete result."] : undefined;
|
|
1441
|
+
if (format === "json")
|
|
1442
|
+
{
|
|
1443
|
+
return toBoundedDiffStructuredResult(action, data, workdir, warnings);
|
|
1444
|
+
}
|
|
1445
|
+
return text;
|
|
1446
|
+
};
|
|
1447
|
+
|
|
1448
|
+
const isNoDataError = (message: string): boolean =>
|
|
1449
|
+
{
|
|
1450
|
+
const normalized = message.toLowerCase();
|
|
1451
|
+
return normalized.includes("--nodata")
|
|
1452
|
+
|| normalized.includes("no data available")
|
|
1453
|
+
|| normalized.includes("data is not available")
|
|
1454
|
+
|| normalized.includes("cannot retrieve data")
|
|
1455
|
+
|| normalized.includes("could not retrieve data");
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
const withWorkspaceBaseUnavailableDiagnostic = (path: string, error: unknown): Error =>
|
|
1459
|
+
{
|
|
1460
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1461
|
+
if (!isNoDataError(message))
|
|
1462
|
+
{
|
|
1463
|
+
return error instanceof Error ? error : new Error(message);
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
return new Error(
|
|
1467
|
+
`Plastic cannot supply historical/base bytes for '${path}' because this workspace/status record is --nodata. `
|
|
1468
|
+
+ "A workspace diff cannot be calculated without those bytes. Run plastic_status(machineReadable=true, includeRevId=true) to verify the item, then update/refresh the workspace or use plastic_diffRevisions with two known file-qualified revisions when historical content is available.",
|
|
1469
1469
|
);
|
|
1470
1470
|
};
|
|
1471
1471
|
|
|
1472
|
+
const boundTextDiffResult = (result: TextDiffResult, maxChars: number): TextDiffResult =>
|
|
1473
|
+
{
|
|
1474
|
+
if (!result.output || result.output.length <= maxChars)
|
|
1475
|
+
{
|
|
1476
|
+
return result;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
const suffix = `\n\n[Diff output truncated at the requested ${maxChars}-character response bound.]`;
|
|
1480
|
+
return {
|
|
1481
|
+
...result,
|
|
1482
|
+
output: suffix.length >= maxChars ? suffix.slice(0, maxChars) : `${result.output.slice(0, maxChars - suffix.length)}${suffix}`,
|
|
1483
|
+
truncated: true,
|
|
1484
|
+
totalChars: result.totalChars,
|
|
1485
|
+
};
|
|
1486
|
+
};
|
|
1487
|
+
|
|
1472
1488
|
export const __plasticDiffInternals = {
|
|
1473
1489
|
DIFF_OUTPUT_MAX_CHARS,
|
|
1490
|
+
DIFF_RESPONSE_DEFAULT_MAX_CHARS,
|
|
1491
|
+
DIFF_RESPONSE_MAX_CHARS,
|
|
1492
|
+
DIFF_RESPONSE_MIN_CHARS,
|
|
1493
|
+
DIFF_RESPONSE_TOTAL_MAX_CHARS,
|
|
1494
|
+
normalizeDiffResponseMaxChars,
|
|
1474
1495
|
isBinaryContent,
|
|
1475
1496
|
stableDiffHeaders,
|
|
1476
1497
|
boundDiffOutput,
|
|
1477
1498
|
runPortableTextDiff,
|
|
1478
1499
|
safeTempExtension,
|
|
1479
1500
|
materializeRevision,
|
|
1501
|
+
isNoDataError,
|
|
1502
|
+
withWorkspaceBaseUnavailableDiagnostic,
|
|
1503
|
+
boundTextDiffResult,
|
|
1480
1504
|
resolveDiffFileRevision,
|
|
1481
1505
|
isUnscopedDiffRevisionSpec,
|
|
1482
1506
|
};
|
|
@@ -1729,6 +1753,60 @@ const toStructuredResult = async (
|
|
|
1729
1753
|
return `## ${action}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
1730
1754
|
};
|
|
1731
1755
|
|
|
1756
|
+
const toBoundedDiffStructuredResult = async (
|
|
1757
|
+
action: "diffFile" | "diffRevisions",
|
|
1758
|
+
data: Record<string, unknown>,
|
|
1759
|
+
workdir?: string,
|
|
1760
|
+
warnings?: string[],
|
|
1761
|
+
): Promise<string> =>
|
|
1762
|
+
{
|
|
1763
|
+
const wrap = (payload: Record<string, unknown>): string => `## ${action}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
1764
|
+
const basePayload = {
|
|
1765
|
+
ok: true,
|
|
1766
|
+
action,
|
|
1767
|
+
toolVersion: TOOL_VERSION,
|
|
1768
|
+
cliVersion: (await getCmVersion(workdir)).slice(0, 256),
|
|
1769
|
+
};
|
|
1770
|
+
const initial = wrap({ ...basePayload, data, ...(warnings?.length ? { warnings } : {}) });
|
|
1771
|
+
if (initial.length <= DIFF_RESPONSE_TOTAL_MAX_CHARS)
|
|
1772
|
+
{
|
|
1773
|
+
return initial;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
const originalDiff = typeof data.diff === "string" ? data.diff : "";
|
|
1777
|
+
const responseWarning = `Diff content was further truncated after JSON escaping to keep the complete response within ${DIFF_RESPONSE_TOTAL_MAX_CHARS} characters.`;
|
|
1778
|
+
const boundedWarnings = [...(warnings ?? []), responseWarning];
|
|
1779
|
+
const suffix = `\n\n[Diff output truncated to keep the complete JSON response within ${DIFF_RESPONSE_TOTAL_MAX_CHARS} characters.]`;
|
|
1780
|
+
const render = (prefixChars: number): string => wrap({
|
|
1781
|
+
...basePayload,
|
|
1782
|
+
data: {
|
|
1783
|
+
...data,
|
|
1784
|
+
truncated: true,
|
|
1785
|
+
diff: `${originalDiff.slice(0, prefixChars)}${suffix}`,
|
|
1786
|
+
},
|
|
1787
|
+
warnings: boundedWarnings,
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
let low = 0;
|
|
1791
|
+
let high = originalDiff.length;
|
|
1792
|
+
let best = render(0);
|
|
1793
|
+
while (low <= high)
|
|
1794
|
+
{
|
|
1795
|
+
const middle = Math.floor((low + high) / 2);
|
|
1796
|
+
const candidate = render(middle);
|
|
1797
|
+
if (candidate.length <= DIFF_RESPONSE_TOTAL_MAX_CHARS)
|
|
1798
|
+
{
|
|
1799
|
+
best = candidate;
|
|
1800
|
+
low = middle + 1;
|
|
1801
|
+
}
|
|
1802
|
+
else
|
|
1803
|
+
{
|
|
1804
|
+
high = middle - 1;
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
return best;
|
|
1808
|
+
};
|
|
1809
|
+
|
|
1732
1810
|
type MergeConflictStrategy = "auto" | "source" | "destination";
|
|
1733
1811
|
|
|
1734
1812
|
type MergeMachineReadableRecord = {
|
|
@@ -2655,6 +2733,7 @@ export const diffRevisions = tool({
|
|
|
2655
2733
|
args: {
|
|
2656
2734
|
leftRevision: tool.schema.string().min(1).describe("Left file-qualified Plastic revision spec for cm cat."),
|
|
2657
2735
|
rightRevision: tool.schema.string().min(1).describe("Right file-qualified Plastic revision spec for cm cat."),
|
|
2736
|
+
maxChars: tool.schema.number().int().min(DIFF_RESPONSE_MIN_CHARS).max(DIFF_RESPONSE_MAX_CHARS).optional().describe(`Maximum diff-body characters returned (default ${DIFF_RESPONSE_DEFAULT_MAX_CHARS}, maximum ${DIFF_RESPONSE_MAX_CHARS}).`),
|
|
2658
2737
|
format: outputFormatArg,
|
|
2659
2738
|
workdir: workdirArg,
|
|
2660
2739
|
},
|
|
@@ -2677,7 +2756,7 @@ export const diffRevisions = tool({
|
|
|
2677
2756
|
await materializeRevision(args.leftRevision, leftPath, args.workdir);
|
|
2678
2757
|
await materializeRevision(args.rightRevision, rightPath, args.workdir);
|
|
2679
2758
|
const result = await runPortableTextDiff(leftPath, rightPath, cwd, leftLabel, rightLabel);
|
|
2680
|
-
return formatTextDiff("diffRevisions", args.format, "revision-to-revision", result, args.workdir);
|
|
2759
|
+
return formatTextDiff("diffRevisions", args.format, "revision-to-revision", result, args.workdir, {}, args.maxChars);
|
|
2681
2760
|
}
|
|
2682
2761
|
finally
|
|
2683
2762
|
{
|
|
@@ -2686,53 +2765,124 @@ export const diffRevisions = tool({
|
|
|
2686
2765
|
},
|
|
2687
2766
|
});
|
|
2688
2767
|
|
|
2768
|
+
type WorkspacePendingDiff = {
|
|
2769
|
+
item?: PendingItem;
|
|
2770
|
+
workspacePath: string;
|
|
2771
|
+
displayPath: string;
|
|
2772
|
+
revisionPath: string;
|
|
2773
|
+
pendingKind: "added" | "changed" | "moved" | "deleted" | "private" | "other";
|
|
2774
|
+
result: TextDiffResult;
|
|
2775
|
+
comparisonKind: string;
|
|
2776
|
+
};
|
|
2777
|
+
|
|
2778
|
+
const diffPendingWorkspaceFile = async (
|
|
2779
|
+
args: { path: string; workdir?: string },
|
|
2780
|
+
pendingItem?: PendingItem,
|
|
2781
|
+
): Promise<WorkspacePendingDiff> =>
|
|
2782
|
+
{
|
|
2783
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2784
|
+
const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
|
|
2785
|
+
const pendingKind = pendingItem?.kind ?? "changed";
|
|
2786
|
+
const isAdded = pendingKind === "added";
|
|
2787
|
+
const isPrivate = pendingKind === "private";
|
|
2788
|
+
const isDeleted = pendingKind === "deleted";
|
|
2789
|
+
const workspaceStat = await fs.stat(workspacePath).catch(() => null);
|
|
2790
|
+
if (!workspaceStat?.isFile() && !isDeleted)
|
|
2791
|
+
{
|
|
2792
|
+
throw new Error(`Workspace file '${args.path}' does not exist or is not a regular file. Use plastic_status to confirm its pending status.`);
|
|
2793
|
+
}
|
|
2794
|
+
|
|
2795
|
+
const displayPath = isAbsolute(args.path) ? toCommandPath(workspacePath, cwd) : args.path;
|
|
2796
|
+
const revisionPath = displayPath.length > 0 ? displayPath : args.path;
|
|
2797
|
+
const workspaceBase = resolveDiffFileRevision(revisionPath);
|
|
2798
|
+
const leftLabel = isAdded
|
|
2799
|
+
? `${revisionPath} (empty before add)`
|
|
2800
|
+
: isPrivate
|
|
2801
|
+
? `${revisionPath} (empty before private/new file)`
|
|
2802
|
+
: workspaceBase.resolved.includes("#") ? workspaceBase.resolved.replace("#", "@") : `${revisionPath} (Plastic base)`;
|
|
2803
|
+
const rightLabel = isDeleted ? `${displayPath} (empty after delete)` : `${displayPath} (workspace)`;
|
|
2804
|
+
const materializeSpec = pendingItem?.revisionId ? `revid:${pendingItem.revisionId}` : workspaceBase.resolved;
|
|
2805
|
+
if (isDeleted && !pendingItem?.revisionId)
|
|
2806
|
+
{
|
|
2807
|
+
throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
|
|
2808
|
+
}
|
|
2809
|
+
|
|
2810
|
+
const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
|
|
2811
|
+
const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
|
|
2812
|
+
const emptyWorkspacePath = join(tempDir, `workspace${safeTempExtension(workspacePath)}`);
|
|
2813
|
+
try
|
|
2814
|
+
{
|
|
2815
|
+
if (isAdded || isPrivate)
|
|
2816
|
+
{
|
|
2817
|
+
await fs.writeFile(basePath, "");
|
|
2818
|
+
}
|
|
2819
|
+
else
|
|
2820
|
+
{
|
|
2821
|
+
try
|
|
2822
|
+
{
|
|
2823
|
+
// Status revision IDs preserve the exact pre-change materialization for
|
|
2824
|
+
// changed, moved, and deleted records. Explicit revision requests use a
|
|
2825
|
+
// separate path below and never consult workspace status.
|
|
2826
|
+
await materializeRevision(materializeSpec, basePath, args.workdir);
|
|
2827
|
+
}
|
|
2828
|
+
catch (error)
|
|
2829
|
+
{
|
|
2830
|
+
throw withWorkspaceBaseUnavailableDiagnostic(args.path, error);
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
|
|
2834
|
+
if (isDeleted)
|
|
2835
|
+
{
|
|
2836
|
+
await fs.writeFile(emptyWorkspacePath, "");
|
|
2837
|
+
}
|
|
2838
|
+
const result = await runPortableTextDiff(basePath, isDeleted ? emptyWorkspacePath : workspacePath, cwd, leftLabel, rightLabel);
|
|
2839
|
+
return {
|
|
2840
|
+
item: pendingItem,
|
|
2841
|
+
workspacePath,
|
|
2842
|
+
displayPath,
|
|
2843
|
+
revisionPath,
|
|
2844
|
+
pendingKind,
|
|
2845
|
+
result,
|
|
2846
|
+
comparisonKind: isPrivate ? "workspace-private-new" : (isAdded ? "workspace-added" : (isDeleted ? "workspace-deleted" : `workspace-${pendingKind}`)),
|
|
2847
|
+
};
|
|
2848
|
+
}
|
|
2849
|
+
finally
|
|
2850
|
+
{
|
|
2851
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
2852
|
+
}
|
|
2853
|
+
};
|
|
2854
|
+
|
|
2689
2855
|
export const diffFile = tool({
|
|
2690
2856
|
description: "Show a bounded text-only diff between a workspace file and its Plastic base (or a supported revision). Omit revision for the common workspace-base comparison.",
|
|
2691
2857
|
args: {
|
|
2692
2858
|
path: tool.schema.string().min(1).describe("Workspace file path to diff."),
|
|
2693
2859
|
revision: tool.schema.string().optional().describe("Optional revision: changeset number/cs:<number>, br:/<branch>, lb:<label>, file-qualified '<path>#<selector>', or global revid:/rev:. Omit for the workspace base."),
|
|
2860
|
+
maxChars: tool.schema.number().int().min(DIFF_RESPONSE_MIN_CHARS).max(DIFF_RESPONSE_MAX_CHARS).optional().describe(`Maximum diff-body characters returned (default ${DIFF_RESPONSE_DEFAULT_MAX_CHARS}, maximum ${DIFF_RESPONSE_MAX_CHARS}).`),
|
|
2694
2861
|
format: outputFormatArg,
|
|
2695
2862
|
workdir: workdirArg,
|
|
2696
2863
|
},
|
|
2697
2864
|
async execute(args)
|
|
2698
2865
|
{
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
const pendingItems = args.revision ? [] : await getMachineReadablePendingItems(args.workdir);
|
|
2703
|
-
const pendingItem = pendingItems.find((item) => item.comparisonKey === toPathComparisonKeyFromAbsolutePath(workspacePath));
|
|
2704
|
-
const isAdded = !args.revision && pendingItem?.kind === "added";
|
|
2705
|
-
const isDeleted = !args.revision && pendingItem?.kind === "deleted";
|
|
2706
|
-
if (!workspaceStat?.isFile() && !isDeleted)
|
|
2707
|
-
{
|
|
2708
|
-
throw new Error(`Workspace file '${args.path}' does not exist or is not a regular file. Use plastic_status to confirm whether it is a pending deletion.`);
|
|
2709
|
-
}
|
|
2710
|
-
|
|
2711
|
-
const displayPath = isAbsolute(args.path) ? relative(cwd, args.path) : args.path;
|
|
2712
|
-
const revisionPath = displayPath.length > 0 ? displayPath : args.path;
|
|
2713
|
-
const revision = resolveDiffFileRevision(revisionPath, args.revision);
|
|
2714
|
-
const leftLabel = isAdded ? `${revisionPath} (empty before add)` : (revision.resolved.includes("#") ? revision.resolved.replace("#", "@") : `${revisionPath} (Plastic base)`);
|
|
2715
|
-
const rightLabel = isDeleted ? `${displayPath} (empty after delete)` : `${displayPath} (workspace)`;
|
|
2716
|
-
const materializeSpec = isDeleted && pendingItem?.revisionId ? `revid:${pendingItem.revisionId}` : revision.resolved;
|
|
2717
|
-
if (isDeleted && !pendingItem?.revisionId)
|
|
2718
|
-
{
|
|
2719
|
-
throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
|
|
2720
|
-
}
|
|
2721
|
-
const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
|
|
2722
|
-
const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
|
|
2723
|
-
const emptyWorkspacePath = join(tempDir, `workspace${safeTempExtension(workspacePath)}`);
|
|
2724
|
-
|
|
2725
|
-
try
|
|
2866
|
+
// Explicit revision behavior intentionally bypasses workspace status. It is
|
|
2867
|
+
// a request for the supplied historical selector, not a pending-item diff.
|
|
2868
|
+
if (args.revision)
|
|
2726
2869
|
{
|
|
2727
|
-
|
|
2870
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2871
|
+
const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
|
|
2872
|
+
const workspaceStat = await fs.stat(workspacePath).catch(() => null);
|
|
2873
|
+
if (!workspaceStat?.isFile())
|
|
2728
2874
|
{
|
|
2729
|
-
|
|
2875
|
+
throw new Error(`Workspace file '${args.path}' does not exist or is not a regular file.`);
|
|
2730
2876
|
}
|
|
2731
|
-
|
|
2877
|
+
const displayPath = isAbsolute(args.path) ? toCommandPath(workspacePath, cwd) : args.path;
|
|
2878
|
+
const revision = resolveDiffFileRevision(displayPath || args.path, args.revision);
|
|
2879
|
+
const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
|
|
2880
|
+
const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
|
|
2881
|
+
try
|
|
2732
2882
|
{
|
|
2733
2883
|
try
|
|
2734
2884
|
{
|
|
2735
|
-
await materializeRevision(
|
|
2885
|
+
await materializeRevision(revision.resolved, basePath, args.workdir);
|
|
2736
2886
|
}
|
|
2737
2887
|
catch (error)
|
|
2738
2888
|
{
|
|
@@ -2747,21 +2897,280 @@ export const diffFile = tool({
|
|
|
2747
2897
|
}
|
|
2748
2898
|
throw error;
|
|
2749
2899
|
}
|
|
2900
|
+
const result = await runPortableTextDiff(basePath, workspacePath, cwd, revision.resolved.replace("#", "@"), `${displayPath} (workspace)`);
|
|
2901
|
+
return formatTextDiff("diffFile", args.format, revision.kind, result, args.workdir, { pendingKind: null, isNew: false }, args.maxChars);
|
|
2750
2902
|
}
|
|
2751
|
-
|
|
2752
|
-
if (isDeleted)
|
|
2903
|
+
finally
|
|
2753
2904
|
{
|
|
2754
|
-
await fs.
|
|
2905
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
2755
2906
|
}
|
|
2756
|
-
const comparisonPath = isDeleted ? emptyWorkspacePath : workspacePath;
|
|
2757
|
-
const comparisonKind = isAdded ? "workspace-added" : (isDeleted ? "workspace-deleted" : revision.kind);
|
|
2758
|
-
const result = await runPortableTextDiff(basePath, comparisonPath, cwd, leftLabel, rightLabel);
|
|
2759
|
-
return formatTextDiff("diffFile", args.format, comparisonKind, result, args.workdir);
|
|
2760
2907
|
}
|
|
2761
|
-
|
|
2908
|
+
|
|
2909
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2910
|
+
const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
|
|
2911
|
+
const pendingItems = await getMachineReadablePendingItems(args.workdir);
|
|
2912
|
+
const pendingItem = pendingItems.find((item) => item.comparisonKey === toPathComparisonKeyFromAbsolutePath(workspacePath));
|
|
2913
|
+
const compared = await diffPendingWorkspaceFile(args, pendingItem);
|
|
2914
|
+
return formatTextDiff(
|
|
2915
|
+
"diffFile",
|
|
2916
|
+
args.format,
|
|
2917
|
+
compared.comparisonKind,
|
|
2918
|
+
compared.result,
|
|
2919
|
+
args.workdir,
|
|
2920
|
+
{ pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId },
|
|
2921
|
+
args.maxChars,
|
|
2922
|
+
);
|
|
2923
|
+
},
|
|
2924
|
+
});
|
|
2925
|
+
|
|
2926
|
+
const WORKSPACE_DIFF_DEFAULT_MAX_FILES = 3;
|
|
2927
|
+
const WORKSPACE_DIFF_MAX_FILES = 20;
|
|
2928
|
+
const WORKSPACE_DIFF_MAX_PATHS = 20;
|
|
2929
|
+
const WORKSPACE_DIFF_PATH_MAX_CHARS = 1_024;
|
|
2930
|
+
const WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS = 256;
|
|
2931
|
+
const WORKSPACE_DIFF_ERROR_MAX_CHARS = 1_024;
|
|
2932
|
+
const WORKSPACE_DIFF_DEFAULT_MAX_CHARS = 3_000;
|
|
2933
|
+
const WORKSPACE_DIFF_PER_FILE_MAX_CHARS = 8_000;
|
|
2934
|
+
const WORKSPACE_DIFF_MIN_CHARS = 500;
|
|
2935
|
+
// This is a response bound, not just a diff-body bound. Reserve space for
|
|
2936
|
+
// framing and an omission summary so both text and JSON remain useful.
|
|
2937
|
+
const WORKSPACE_DIFF_TOTAL_MAX_CHARS = 20_000;
|
|
2938
|
+
const WORKSPACE_DIFF_CONTENT_MAX_CHARS = 16_000;
|
|
2939
|
+
|
|
2940
|
+
const boundWorkspaceValue = (value: string, maxChars: number): string =>
|
|
2941
|
+
{
|
|
2942
|
+
if (maxChars <= 0)
|
|
2943
|
+
{
|
|
2944
|
+
return "";
|
|
2945
|
+
}
|
|
2946
|
+
if (value.length <= maxChars)
|
|
2947
|
+
{
|
|
2948
|
+
return value;
|
|
2949
|
+
}
|
|
2950
|
+
const suffix = `… [truncated; original ${value.length} characters]`;
|
|
2951
|
+
return suffix.length >= maxChars ? value.slice(0, maxChars) : `${value.slice(0, maxChars - suffix.length)}${suffix}`;
|
|
2952
|
+
};
|
|
2953
|
+
|
|
2954
|
+
const boundWorkspaceDiffResult = (result: TextDiffResult, maxChars: number): TextDiffResult =>
|
|
2955
|
+
{
|
|
2956
|
+
if (result.output.length <= maxChars)
|
|
2957
|
+
{
|
|
2958
|
+
return result;
|
|
2959
|
+
}
|
|
2960
|
+
return { ...result, output: boundWorkspaceValue(result.output, maxChars), truncated: true };
|
|
2961
|
+
};
|
|
2962
|
+
|
|
2963
|
+
const workspacePathPreview = (paths: string[]): string[] => paths.map((path) => boundWorkspaceValue(path, WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS));
|
|
2964
|
+
|
|
2965
|
+
const appendBoundedWorkspaceText = (prefix: string, sections: string[], omitted: number): string =>
|
|
2966
|
+
{
|
|
2967
|
+
const omission = (count: number): string => `\n\n[${count} file outcome(s) omitted to keep this response within ${WORKSPACE_DIFF_TOTAL_MAX_CHARS} characters.]`;
|
|
2968
|
+
let text = prefix;
|
|
2969
|
+
let omittedCount = omitted;
|
|
2970
|
+
for (let index = 0; index < sections.length; index += 1)
|
|
2971
|
+
{
|
|
2972
|
+
const candidate = `${text}\n\n${sections[index]}`;
|
|
2973
|
+
if (candidate.length + omission(omittedCount + sections.length - index - 1).length > WORKSPACE_DIFF_TOTAL_MAX_CHARS)
|
|
2762
2974
|
{
|
|
2763
|
-
|
|
2975
|
+
omittedCount += sections.length - index;
|
|
2976
|
+
break;
|
|
2764
2977
|
}
|
|
2978
|
+
text = candidate;
|
|
2979
|
+
}
|
|
2980
|
+
return omittedCount > 0 ? `${text}${omission(omittedCount)}` : text;
|
|
2981
|
+
};
|
|
2982
|
+
|
|
2983
|
+
const formatWorkspaceDiffResult = async (
|
|
2984
|
+
format: OutputFormat,
|
|
2985
|
+
textPrefix: string,
|
|
2986
|
+
textSections: string[],
|
|
2987
|
+
data: Record<string, unknown>,
|
|
2988
|
+
warnings: string[],
|
|
2989
|
+
outcomes: Array<Record<string, unknown>>,
|
|
2990
|
+
preOmittedOutcomes: number,
|
|
2991
|
+
workdir?: string,
|
|
2992
|
+
): Promise<string> =>
|
|
2993
|
+
{
|
|
2994
|
+
if (format !== "json")
|
|
2995
|
+
{
|
|
2996
|
+
return appendBoundedWorkspaceText(textPrefix, textSections, preOmittedOutcomes);
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
const cliVersion = boundWorkspaceValue(await getCmVersion(workdir), WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS);
|
|
3000
|
+
const wrap = (payload: Record<string, unknown>): string => `## workspaceDiff\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
3001
|
+
const retained: Array<Record<string, unknown>> = [];
|
|
3002
|
+
let omittedOutcomes = preOmittedOutcomes;
|
|
3003
|
+
const payloadFor = (): Record<string, unknown> => ({
|
|
3004
|
+
ok: true,
|
|
3005
|
+
action: "workspaceDiff",
|
|
3006
|
+
toolVersion: TOOL_VERSION,
|
|
3007
|
+
cliVersion,
|
|
3008
|
+
data: { ...data, outcomes: retained, omittedOutcomes },
|
|
3009
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
3010
|
+
});
|
|
3011
|
+
|
|
3012
|
+
for (let index = 0; index < outcomes.length; index += 1)
|
|
3013
|
+
{
|
|
3014
|
+
retained.push(outcomes[index]);
|
|
3015
|
+
const remainingCount = outcomes.length - index - 1;
|
|
3016
|
+
// Keep an omission field in the serialized payload before deciding to
|
|
3017
|
+
// retain another result; JSON escaping can otherwise exceed the bound.
|
|
3018
|
+
omittedOutcomes = preOmittedOutcomes + remainingCount;
|
|
3019
|
+
if (wrap(payloadFor()).length > WORKSPACE_DIFF_TOTAL_MAX_CHARS)
|
|
3020
|
+
{
|
|
3021
|
+
retained.pop();
|
|
3022
|
+
omittedOutcomes = preOmittedOutcomes + outcomes.length - index;
|
|
3023
|
+
break;
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
return wrap(payloadFor());
|
|
3027
|
+
};
|
|
3028
|
+
|
|
3029
|
+
export const workspaceDiff = tool({
|
|
3030
|
+
description: "Review explicitly selected pending workspace file diffs, or opt into a small bounded whole-workspace review with allPending=true. Use plastic_status when only changed paths are needed.",
|
|
3031
|
+
args: {
|
|
3032
|
+
paths: tool.schema.array(tool.schema.string().min(1).max(WORKSPACE_DIFF_PATH_MAX_CHARS)).max(WORKSPACE_DIFF_MAX_PATHS).optional().describe(`Pending workspace paths to review (maximum ${WORKSPACE_DIFF_MAX_PATHS}, ${WORKSPACE_DIFF_PATH_MAX_CHARS} characters each). Selecting paths includes matching private files.`),
|
|
3033
|
+
allPending: tool.schema.boolean().optional().describe("Explicitly review pending non-private files when paths are omitted. Defaults to false; required for an unscoped workspace diff."),
|
|
3034
|
+
includePrivate: tool.schema.boolean().optional().describe("Include private pending files with allPending=true. Selected private paths do not require this flag."),
|
|
3035
|
+
maxFiles: tool.schema.number().int().min(1).max(WORKSPACE_DIFF_MAX_FILES).optional().describe(`Maximum files to compare for allPending review (default ${WORKSPACE_DIFF_DEFAULT_MAX_FILES}, maximum ${WORKSPACE_DIFF_MAX_FILES}); selected paths are all considered unless this is set.`),
|
|
3036
|
+
maxChars: tool.schema.number().int().min(WORKSPACE_DIFF_MIN_CHARS).max(WORKSPACE_DIFF_PER_FILE_MAX_CHARS).optional().describe(`Maximum diff-body characters returned per file (default ${WORKSPACE_DIFF_DEFAULT_MAX_CHARS}, maximum ${WORKSPACE_DIFF_PER_FILE_MAX_CHARS}).`),
|
|
3037
|
+
format: outputFormatArg,
|
|
3038
|
+
workdir: workdirArg,
|
|
3039
|
+
},
|
|
3040
|
+
async execute(args)
|
|
3041
|
+
{
|
|
3042
|
+
const cwd = args.workdir ?? process.cwd();
|
|
3043
|
+
// Runtime guards also cover direct execute() callers that bypass schema validation.
|
|
3044
|
+
if (args.paths !== undefined && !Array.isArray(args.paths))
|
|
3045
|
+
{
|
|
3046
|
+
throw new Error("paths must be an array of non-blank workspace paths.");
|
|
3047
|
+
}
|
|
3048
|
+
if (args.allPending !== undefined && typeof args.allPending !== "boolean")
|
|
3049
|
+
{
|
|
3050
|
+
throw new Error("allPending must be a boolean; whole-workspace review requires allPending=true explicitly.");
|
|
3051
|
+
}
|
|
3052
|
+
if (args.includePrivate !== undefined && typeof args.includePrivate !== "boolean")
|
|
3053
|
+
{
|
|
3054
|
+
throw new Error("includePrivate must be a boolean and only applies with allPending=true.");
|
|
3055
|
+
}
|
|
3056
|
+
const rawPaths = Array.isArray(args.paths) ? args.paths : [];
|
|
3057
|
+
if (rawPaths.some((path) => typeof path !== "string" || path.trim().length === 0))
|
|
3058
|
+
{
|
|
3059
|
+
throw new Error("paths must contain only non-blank workspace paths.");
|
|
3060
|
+
}
|
|
3061
|
+
const selectedPaths = rawPaths.length > 0
|
|
3062
|
+
? rawPaths.slice(0, WORKSPACE_DIFF_MAX_PATHS).map((path) => boundWorkspaceValue(path, WORKSPACE_DIFF_PATH_MAX_CHARS))
|
|
3063
|
+
: undefined;
|
|
3064
|
+
const allPending = args.allPending === true;
|
|
3065
|
+
const includePrivate = args.includePrivate === true;
|
|
3066
|
+
const workspaceRoot = toNormalizedAbsolutePath(".", cwd);
|
|
3067
|
+
if (selectedPaths?.some((path) => toPathComparisonKeyFromAbsolutePath(toNormalizedAbsolutePath(path, cwd)) === toPathComparisonKeyFromAbsolutePath(workspaceRoot)))
|
|
3068
|
+
{
|
|
3069
|
+
throw new Error("A workspace-root path is a whole-workspace review; use allPending=true instead of selecting the workspace root.");
|
|
3070
|
+
}
|
|
3071
|
+
if (selectedPaths && allPending)
|
|
3072
|
+
{
|
|
3073
|
+
throw new Error("Choose either explicit paths or allPending=true, not both.");
|
|
3074
|
+
}
|
|
3075
|
+
if (!selectedPaths && !allPending)
|
|
3076
|
+
{
|
|
3077
|
+
throw new Error("plastic_workspaceDiff requires explicit paths or allPending=true. Use plastic_status when only pending paths are needed.");
|
|
3078
|
+
}
|
|
3079
|
+
if (selectedPaths && includePrivate)
|
|
3080
|
+
{
|
|
3081
|
+
throw new Error("includePrivate is only used with allPending=true; explicitly selected private paths are already included.");
|
|
3082
|
+
}
|
|
3083
|
+
const ignoredPathInputs = Math.max(0, rawPaths.length - (selectedPaths?.length ?? 0));
|
|
3084
|
+
const pendingItems = await getMachineReadablePendingItems(args.workdir); // exactly one status command per batch
|
|
3085
|
+
const selectedScopes = selectedPaths?.map((path) => toNormalizedAbsolutePath(path, cwd)) ?? [];
|
|
3086
|
+
let candidates = selectedPaths ? filterPendingItemsByScope(pendingItems, selectedScopes) : pendingItems.filter((item) => item.kind !== "private");
|
|
3087
|
+
if (!selectedPaths && includePrivate)
|
|
3088
|
+
{
|
|
3089
|
+
candidates = pendingItems;
|
|
3090
|
+
}
|
|
3091
|
+
const defaultMaxFiles = selectedPaths ? selectedPaths.length : WORKSPACE_DIFF_DEFAULT_MAX_FILES;
|
|
3092
|
+
const requestedMaxFiles = typeof args.maxFiles === "number" && Number.isFinite(args.maxFiles) ? Math.trunc(args.maxFiles) : defaultMaxFiles;
|
|
3093
|
+
const maxFiles = Math.min(WORKSPACE_DIFF_MAX_FILES, Math.max(1, requestedMaxFiles));
|
|
3094
|
+
const requestedMaxChars = typeof args.maxChars === "number" && Number.isFinite(args.maxChars) ? Math.trunc(args.maxChars) : WORKSPACE_DIFF_DEFAULT_MAX_CHARS;
|
|
3095
|
+
const maxChars = Math.min(WORKSPACE_DIFF_PER_FILE_MAX_CHARS, Math.max(WORKSPACE_DIFF_MIN_CHARS, requestedMaxChars));
|
|
3096
|
+
const skippedByLimit = Math.max(0, candidates.length - maxFiles);
|
|
3097
|
+
candidates = candidates.slice(0, maxFiles);
|
|
3098
|
+
|
|
3099
|
+
const outcomes: Array<Record<string, unknown>> = [];
|
|
3100
|
+
const textSections: string[] = [];
|
|
3101
|
+
let totalOutputChars = 0;
|
|
3102
|
+
let unprocessedCandidates = 0;
|
|
3103
|
+
for (let index = 0; index < candidates.length; index += 1)
|
|
3104
|
+
{
|
|
3105
|
+
if (totalOutputChars >= WORKSPACE_DIFF_CONTENT_MAX_CHARS)
|
|
3106
|
+
{
|
|
3107
|
+
unprocessedCandidates = candidates.length - index;
|
|
3108
|
+
break;
|
|
3109
|
+
}
|
|
3110
|
+
const item = candidates[index];
|
|
3111
|
+
const path = toCommandPath(item.normalizedPath, cwd);
|
|
3112
|
+
const displayPath = boundWorkspaceValue(path, WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS);
|
|
3113
|
+
if (item.isDirectory)
|
|
3114
|
+
{
|
|
3115
|
+
outcomes.push({ path: displayPath, kind: item.kind, status: "skipped-directory" });
|
|
3116
|
+
textSections.push(`### ${displayPath} (${item.kind})\nDirectory entries are not diffed.`);
|
|
3117
|
+
totalOutputChars += textSections[textSections.length - 1].length;
|
|
3118
|
+
continue;
|
|
3119
|
+
}
|
|
3120
|
+
try
|
|
3121
|
+
{
|
|
3122
|
+
const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item);
|
|
3123
|
+
const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
|
|
3124
|
+
const perFile = boundWorkspaceDiffResult(compared.result, maxChars);
|
|
3125
|
+
const bounded = boundWorkspaceDiffResult(perFile, remaining);
|
|
3126
|
+
const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : "unchanged");
|
|
3127
|
+
const diffOutput = bounded.changed && !bounded.binary ? bounded.output : undefined;
|
|
3128
|
+
outcomes.push({ path: displayPath, kind: item.kind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, ...(diffOutput ? { diff: diffOutput } : {}) });
|
|
3129
|
+
const body = bounded.binary ? (bounded.changed ? "Binary content differs; a text diff is unavailable." : "No differences.") : (bounded.changed ? bounded.output : "No differences.");
|
|
3130
|
+
const section = `### ${displayPath} (${item.kind})\n${body}`;
|
|
3131
|
+
textSections.push(section);
|
|
3132
|
+
totalOutputChars += section.length;
|
|
3133
|
+
}
|
|
3134
|
+
catch (error)
|
|
3135
|
+
{
|
|
3136
|
+
const message = boundWorkspaceValue(error instanceof Error ? error.message : String(error), WORKSPACE_DIFF_ERROR_MAX_CHARS);
|
|
3137
|
+
outcomes.push({ path: displayPath, kind: item.kind, status: "unavailable", error: message });
|
|
3138
|
+
const section = `### ${displayPath} (${item.kind})\nUnavailable: ${message}`;
|
|
3139
|
+
textSections.push(section);
|
|
3140
|
+
totalOutputChars += section.length;
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
const unmatchedPaths = selectedPaths?.filter((path) => !filterPendingItemsByScope(pendingItems, [toNormalizedAbsolutePath(path, cwd)]).length) ?? [];
|
|
3145
|
+
const unmatchedPreviews = workspacePathPreview(unmatchedPaths);
|
|
3146
|
+
const warnings = [
|
|
3147
|
+
...(skippedByLimit > 0 ? [`Skipped ${skippedByLimit} pending item(s) after the maxFiles bound of ${maxFiles}.`] : []),
|
|
3148
|
+
...(unmatchedPreviews.length > 0 ? [`${unmatchedPreviews.length} selected path(s) had no pending status record: ${unmatchedPreviews.slice(0, 5).join(", ")}${unmatchedPreviews.length > 5 ? `; ${unmatchedPreviews.length - 5} more omitted` : ""}.`] : []),
|
|
3149
|
+
...(ignoredPathInputs > 0 ? [`Ignored ${ignoredPathInputs} path input(s) after the ${WORKSPACE_DIFF_MAX_PATHS}-path bound.`] : []),
|
|
3150
|
+
...(!selectedPaths && !includePrivate && pendingItems.some((item) => item.kind === "private") ? ["Private pending files were excluded; select their paths explicitly or pass includePrivate=true."] : []),
|
|
3151
|
+
].map((warning) => boundWorkspaceValue(warning, WORKSPACE_DIFF_ERROR_MAX_CHARS));
|
|
3152
|
+
const textPrefix = [
|
|
3153
|
+
"## Workspace Diff",
|
|
3154
|
+
"",
|
|
3155
|
+
`- Status records inspected once: ${pendingItems.length}`,
|
|
3156
|
+
`- Files considered: ${candidates.length}`,
|
|
3157
|
+
`- Per-file output bound: ${maxChars} characters; complete response bound: ${WORKSPACE_DIFF_TOTAL_MAX_CHARS} characters.`,
|
|
3158
|
+
...warnings.map((warning) => `- Warning: ${warning}`),
|
|
3159
|
+
...(textSections.length > 0 ? [] : ["", "No eligible pending files."]),
|
|
3160
|
+
].join("\n");
|
|
3161
|
+
return formatWorkspaceDiffResult(args.format ?? "text", textPrefix, textSections, {
|
|
3162
|
+
statusRecords: pendingItems.length,
|
|
3163
|
+
selectedPaths: selectedPaths ? workspacePathPreview(selectedPaths) : null,
|
|
3164
|
+
selectedPathCount: selectedPaths?.length ?? 0,
|
|
3165
|
+
allPending,
|
|
3166
|
+
includePrivate,
|
|
3167
|
+
maxFiles,
|
|
3168
|
+
perFileMaxChars: maxChars,
|
|
3169
|
+
totalMaxChars: WORKSPACE_DIFF_TOTAL_MAX_CHARS,
|
|
3170
|
+
skippedByLimit,
|
|
3171
|
+
unmatchedPaths: unmatchedPreviews,
|
|
3172
|
+
unprocessedCandidates,
|
|
3173
|
+
}, warnings, outcomes, unprocessedCandidates, args.workdir);
|
|
2765
3174
|
},
|
|
2766
3175
|
});
|
|
2767
3176
|
|