@aefree/pi-plastic 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +8 -7
- package/extensions/bash-cm-diff-guard.ts +2 -1
- package/extensions/file-discovery-filter.ts +67 -0
- package/index.ts +12 -0
- package/package.json +7 -8
- package/skills/_shared/references/conventional-commits.md +198 -198
- package/skills/using-plastic/SKILL.md +2 -2
- package/skills/using-plastic/references/changeset-operations.md +2 -1
- package/skills/using-plastic/references/code-review-creation.md +30 -30
- package/skills/using-plastic/references/integration.md +2 -1
- 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 +4 -3
- package/skills/using-plastic/references/shelving.md +25 -25
- package/skills/using-plastic/references/troubleshooting.md +2 -1
- 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 +438 -152
- package/src/plastic-tool-loading.ts +2 -1
- 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` to list changed paths, `plastic_diffFile` for
|
|
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` to list changed paths, `plastic_diffFile` for one file, `plastic_workspaceDiff` for pending review, or `plastic_diffRevisions` for two explicit revision specs.";
|
|
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,83 +1276,6 @@ export const __plasticBranchInternals = {
|
|
|
1242
1276
|
resolveBranchCreationTarget,
|
|
1243
1277
|
};
|
|
1244
1278
|
|
|
1245
|
-
let gitNoIndexSupportsLabel: boolean | null = null;
|
|
1246
|
-
|
|
1247
|
-
type GitDiffResult = {
|
|
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
|
-
|
|
1322
1279
|
const DIFF_OUTPUT_MAX_CHARS = 60_000;
|
|
1323
1280
|
|
|
1324
1281
|
type TextDiffResult = {
|
|
@@ -1385,8 +1342,8 @@ const runPortableTextDiff = async (
|
|
|
1385
1342
|
["-u", leftPath, rightPath],
|
|
1386
1343
|
cwd,
|
|
1387
1344
|
undefined,
|
|
1388
|
-
|
|
1389
|
-
{ outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
|
|
1345
|
+
getActiveAbortSignal(),
|
|
1346
|
+
{ ...commandExecutionStorage.getStore(), outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
|
|
1390
1347
|
);
|
|
1391
1348
|
if (aborted)
|
|
1392
1349
|
{
|
|
@@ -1444,6 +1401,7 @@ const formatTextDiff = async (
|
|
|
1444
1401
|
comparisonKind: string,
|
|
1445
1402
|
result: TextDiffResult,
|
|
1446
1403
|
workdir: string | undefined,
|
|
1404
|
+
comparisonMetadata: Record<string, unknown> = {},
|
|
1447
1405
|
): Promise<string> =>
|
|
1448
1406
|
{
|
|
1449
1407
|
const status = result.binary ? (result.changed ? "binary-different" : "unchanged") : (result.changed ? "changed" : "unchanged");
|
|
@@ -1456,6 +1414,7 @@ const formatTextDiff = async (
|
|
|
1456
1414
|
text,
|
|
1457
1415
|
{
|
|
1458
1416
|
comparisonKind,
|
|
1417
|
+
...comparisonMetadata,
|
|
1459
1418
|
backend: result.backend,
|
|
1460
1419
|
status,
|
|
1461
1420
|
changed: result.changed,
|
|
@@ -1469,6 +1428,45 @@ const formatTextDiff = async (
|
|
|
1469
1428
|
);
|
|
1470
1429
|
};
|
|
1471
1430
|
|
|
1431
|
+
const isNoDataError = (message: string): boolean =>
|
|
1432
|
+
{
|
|
1433
|
+
const normalized = message.toLowerCase();
|
|
1434
|
+
return normalized.includes("--nodata")
|
|
1435
|
+
|| normalized.includes("no data available")
|
|
1436
|
+
|| normalized.includes("data is not available")
|
|
1437
|
+
|| normalized.includes("cannot retrieve data")
|
|
1438
|
+
|| normalized.includes("could not retrieve data");
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
const withWorkspaceBaseUnavailableDiagnostic = (path: string, error: unknown): Error =>
|
|
1442
|
+
{
|
|
1443
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1444
|
+
if (!isNoDataError(message))
|
|
1445
|
+
{
|
|
1446
|
+
return error instanceof Error ? error : new Error(message);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
return new Error(
|
|
1450
|
+
`Plastic cannot supply historical/base bytes for '${path}' because this workspace/status record is --nodata. `
|
|
1451
|
+
+ "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.",
|
|
1452
|
+
);
|
|
1453
|
+
};
|
|
1454
|
+
|
|
1455
|
+
const boundTextDiffResult = (result: TextDiffResult, maxChars: number): TextDiffResult =>
|
|
1456
|
+
{
|
|
1457
|
+
if (!result.output || result.output.length <= maxChars)
|
|
1458
|
+
{
|
|
1459
|
+
return result;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
return {
|
|
1463
|
+
...result,
|
|
1464
|
+
output: `${result.output.slice(0, maxChars)}\n\n[Per-file diff output truncated at ${maxChars} characters.]`,
|
|
1465
|
+
truncated: true,
|
|
1466
|
+
totalChars: result.totalChars,
|
|
1467
|
+
};
|
|
1468
|
+
};
|
|
1469
|
+
|
|
1472
1470
|
export const __plasticDiffInternals = {
|
|
1473
1471
|
DIFF_OUTPUT_MAX_CHARS,
|
|
1474
1472
|
isBinaryContent,
|
|
@@ -1477,6 +1475,9 @@ export const __plasticDiffInternals = {
|
|
|
1477
1475
|
runPortableTextDiff,
|
|
1478
1476
|
safeTempExtension,
|
|
1479
1477
|
materializeRevision,
|
|
1478
|
+
isNoDataError,
|
|
1479
|
+
withWorkspaceBaseUnavailableDiagnostic,
|
|
1480
|
+
boundTextDiffResult,
|
|
1480
1481
|
resolveDiffFileRevision,
|
|
1481
1482
|
isUnscopedDiffRevisionSpec,
|
|
1482
1483
|
};
|
|
@@ -2686,6 +2687,93 @@ export const diffRevisions = tool({
|
|
|
2686
2687
|
},
|
|
2687
2688
|
});
|
|
2688
2689
|
|
|
2690
|
+
type WorkspacePendingDiff = {
|
|
2691
|
+
item?: PendingItem;
|
|
2692
|
+
workspacePath: string;
|
|
2693
|
+
displayPath: string;
|
|
2694
|
+
revisionPath: string;
|
|
2695
|
+
pendingKind: "added" | "changed" | "moved" | "deleted" | "private" | "other";
|
|
2696
|
+
result: TextDiffResult;
|
|
2697
|
+
comparisonKind: string;
|
|
2698
|
+
};
|
|
2699
|
+
|
|
2700
|
+
const diffPendingWorkspaceFile = async (
|
|
2701
|
+
args: { path: string; workdir?: string },
|
|
2702
|
+
pendingItem?: PendingItem,
|
|
2703
|
+
): Promise<WorkspacePendingDiff> =>
|
|
2704
|
+
{
|
|
2705
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2706
|
+
const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
|
|
2707
|
+
const pendingKind = pendingItem?.kind ?? "changed";
|
|
2708
|
+
const isAdded = pendingKind === "added";
|
|
2709
|
+
const isPrivate = pendingKind === "private";
|
|
2710
|
+
const isDeleted = pendingKind === "deleted";
|
|
2711
|
+
const workspaceStat = await fs.stat(workspacePath).catch(() => null);
|
|
2712
|
+
if (!workspaceStat?.isFile() && !isDeleted)
|
|
2713
|
+
{
|
|
2714
|
+
throw new Error(`Workspace file '${args.path}' does not exist or is not a regular file. Use plastic_status to confirm its pending status.`);
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
const displayPath = isAbsolute(args.path) ? toCommandPath(workspacePath, cwd) : args.path;
|
|
2718
|
+
const revisionPath = displayPath.length > 0 ? displayPath : args.path;
|
|
2719
|
+
const workspaceBase = resolveDiffFileRevision(revisionPath);
|
|
2720
|
+
const leftLabel = isAdded
|
|
2721
|
+
? `${revisionPath} (empty before add)`
|
|
2722
|
+
: isPrivate
|
|
2723
|
+
? `${revisionPath} (empty before private/new file)`
|
|
2724
|
+
: workspaceBase.resolved.includes("#") ? workspaceBase.resolved.replace("#", "@") : `${revisionPath} (Plastic base)`;
|
|
2725
|
+
const rightLabel = isDeleted ? `${displayPath} (empty after delete)` : `${displayPath} (workspace)`;
|
|
2726
|
+
const materializeSpec = pendingItem?.revisionId ? `revid:${pendingItem.revisionId}` : workspaceBase.resolved;
|
|
2727
|
+
if (isDeleted && !pendingItem?.revisionId)
|
|
2728
|
+
{
|
|
2729
|
+
throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
|
|
2733
|
+
const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
|
|
2734
|
+
const emptyWorkspacePath = join(tempDir, `workspace${safeTempExtension(workspacePath)}`);
|
|
2735
|
+
try
|
|
2736
|
+
{
|
|
2737
|
+
if (isAdded || isPrivate)
|
|
2738
|
+
{
|
|
2739
|
+
await fs.writeFile(basePath, "");
|
|
2740
|
+
}
|
|
2741
|
+
else
|
|
2742
|
+
{
|
|
2743
|
+
try
|
|
2744
|
+
{
|
|
2745
|
+
// Status revision IDs preserve the exact pre-change materialization for
|
|
2746
|
+
// changed, moved, and deleted records. Explicit revision requests use a
|
|
2747
|
+
// separate path below and never consult workspace status.
|
|
2748
|
+
await materializeRevision(materializeSpec, basePath, args.workdir);
|
|
2749
|
+
}
|
|
2750
|
+
catch (error)
|
|
2751
|
+
{
|
|
2752
|
+
throw withWorkspaceBaseUnavailableDiagnostic(args.path, error);
|
|
2753
|
+
}
|
|
2754
|
+
}
|
|
2755
|
+
|
|
2756
|
+
if (isDeleted)
|
|
2757
|
+
{
|
|
2758
|
+
await fs.writeFile(emptyWorkspacePath, "");
|
|
2759
|
+
}
|
|
2760
|
+
const result = await runPortableTextDiff(basePath, isDeleted ? emptyWorkspacePath : workspacePath, cwd, leftLabel, rightLabel);
|
|
2761
|
+
return {
|
|
2762
|
+
item: pendingItem,
|
|
2763
|
+
workspacePath,
|
|
2764
|
+
displayPath,
|
|
2765
|
+
revisionPath,
|
|
2766
|
+
pendingKind,
|
|
2767
|
+
result,
|
|
2768
|
+
comparisonKind: isPrivate ? "workspace-private-new" : (isAdded ? "workspace-added" : (isDeleted ? "workspace-deleted" : `workspace-${pendingKind}`)),
|
|
2769
|
+
};
|
|
2770
|
+
}
|
|
2771
|
+
finally
|
|
2772
|
+
{
|
|
2773
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
2774
|
+
}
|
|
2775
|
+
};
|
|
2776
|
+
|
|
2689
2777
|
export const diffFile = tool({
|
|
2690
2778
|
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
2779
|
args: {
|
|
@@ -2696,43 +2784,26 @@ export const diffFile = tool({
|
|
|
2696
2784
|
},
|
|
2697
2785
|
async execute(args)
|
|
2698
2786
|
{
|
|
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)
|
|
2787
|
+
// Explicit revision behavior intentionally bypasses workspace status. It is
|
|
2788
|
+
// a request for the supplied historical selector, not a pending-item diff.
|
|
2789
|
+
if (args.revision)
|
|
2718
2790
|
{
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
const emptyWorkspacePath = join(tempDir, `workspace${safeTempExtension(workspacePath)}`);
|
|
2724
|
-
|
|
2725
|
-
try
|
|
2726
|
-
{
|
|
2727
|
-
if (isAdded)
|
|
2791
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2792
|
+
const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
|
|
2793
|
+
const workspaceStat = await fs.stat(workspacePath).catch(() => null);
|
|
2794
|
+
if (!workspaceStat?.isFile())
|
|
2728
2795
|
{
|
|
2729
|
-
|
|
2796
|
+
throw new Error(`Workspace file '${args.path}' does not exist or is not a regular file.`);
|
|
2730
2797
|
}
|
|
2731
|
-
|
|
2798
|
+
const displayPath = isAbsolute(args.path) ? toCommandPath(workspacePath, cwd) : args.path;
|
|
2799
|
+
const revision = resolveDiffFileRevision(displayPath || args.path, args.revision);
|
|
2800
|
+
const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
|
|
2801
|
+
const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
|
|
2802
|
+
try
|
|
2732
2803
|
{
|
|
2733
2804
|
try
|
|
2734
2805
|
{
|
|
2735
|
-
await materializeRevision(
|
|
2806
|
+
await materializeRevision(revision.resolved, basePath, args.workdir);
|
|
2736
2807
|
}
|
|
2737
2808
|
catch (error)
|
|
2738
2809
|
{
|
|
@@ -2747,21 +2818,236 @@ export const diffFile = tool({
|
|
|
2747
2818
|
}
|
|
2748
2819
|
throw error;
|
|
2749
2820
|
}
|
|
2821
|
+
const result = await runPortableTextDiff(basePath, workspacePath, cwd, revision.resolved.replace("#", "@"), `${displayPath} (workspace)`);
|
|
2822
|
+
return formatTextDiff("diffFile", args.format, revision.kind, result, args.workdir, { pendingKind: null, isNew: false });
|
|
2750
2823
|
}
|
|
2751
|
-
|
|
2752
|
-
if (isDeleted)
|
|
2824
|
+
finally
|
|
2753
2825
|
{
|
|
2754
|
-
await fs.
|
|
2826
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
2755
2827
|
}
|
|
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
2828
|
}
|
|
2761
|
-
|
|
2829
|
+
|
|
2830
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2831
|
+
const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
|
|
2832
|
+
const pendingItems = await getMachineReadablePendingItems(args.workdir);
|
|
2833
|
+
const pendingItem = pendingItems.find((item) => item.comparisonKey === toPathComparisonKeyFromAbsolutePath(workspacePath));
|
|
2834
|
+
const compared = await diffPendingWorkspaceFile(args, pendingItem);
|
|
2835
|
+
return formatTextDiff(
|
|
2836
|
+
"diffFile",
|
|
2837
|
+
args.format,
|
|
2838
|
+
compared.comparisonKind,
|
|
2839
|
+
compared.result,
|
|
2840
|
+
args.workdir,
|
|
2841
|
+
{ pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId },
|
|
2842
|
+
);
|
|
2843
|
+
},
|
|
2844
|
+
});
|
|
2845
|
+
|
|
2846
|
+
const WORKSPACE_DIFF_DEFAULT_MAX_FILES = 12;
|
|
2847
|
+
const WORKSPACE_DIFF_MAX_FILES = 20;
|
|
2848
|
+
const WORKSPACE_DIFF_MAX_PATHS = 20;
|
|
2849
|
+
const WORKSPACE_DIFF_PATH_MAX_CHARS = 1_024;
|
|
2850
|
+
const WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS = 256;
|
|
2851
|
+
const WORKSPACE_DIFF_ERROR_MAX_CHARS = 1_024;
|
|
2852
|
+
const WORKSPACE_DIFF_PER_FILE_MAX_CHARS = 12_000;
|
|
2853
|
+
// This is a response bound, not just a diff-body bound. Reserve space for
|
|
2854
|
+
// framing and an omission summary so both text and JSON remain useful.
|
|
2855
|
+
const WORKSPACE_DIFF_TOTAL_MAX_CHARS = 60_000;
|
|
2856
|
+
const WORKSPACE_DIFF_CONTENT_MAX_CHARS = 48_000;
|
|
2857
|
+
|
|
2858
|
+
const boundWorkspaceValue = (value: string, maxChars: number): string =>
|
|
2859
|
+
{
|
|
2860
|
+
if (maxChars <= 0)
|
|
2861
|
+
{
|
|
2862
|
+
return "";
|
|
2863
|
+
}
|
|
2864
|
+
if (value.length <= maxChars)
|
|
2865
|
+
{
|
|
2866
|
+
return value;
|
|
2867
|
+
}
|
|
2868
|
+
const suffix = `… [truncated; original ${value.length} characters]`;
|
|
2869
|
+
return suffix.length >= maxChars ? value.slice(0, maxChars) : `${value.slice(0, maxChars - suffix.length)}${suffix}`;
|
|
2870
|
+
};
|
|
2871
|
+
|
|
2872
|
+
const boundWorkspaceDiffResult = (result: TextDiffResult, maxChars: number): TextDiffResult =>
|
|
2873
|
+
{
|
|
2874
|
+
if (result.output.length <= maxChars)
|
|
2875
|
+
{
|
|
2876
|
+
return result;
|
|
2877
|
+
}
|
|
2878
|
+
return { ...result, output: boundWorkspaceValue(result.output, maxChars), truncated: true };
|
|
2879
|
+
};
|
|
2880
|
+
|
|
2881
|
+
const workspacePathPreview = (paths: string[]): string[] => paths.map((path) => boundWorkspaceValue(path, WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS));
|
|
2882
|
+
|
|
2883
|
+
const appendBoundedWorkspaceText = (prefix: string, sections: string[], omitted: number): string =>
|
|
2884
|
+
{
|
|
2885
|
+
const omission = (count: number): string => `\n\n[${count} file outcome(s) omitted to keep this response within ${WORKSPACE_DIFF_TOTAL_MAX_CHARS} characters.]`;
|
|
2886
|
+
let text = prefix;
|
|
2887
|
+
let omittedCount = omitted;
|
|
2888
|
+
for (let index = 0; index < sections.length; index += 1)
|
|
2889
|
+
{
|
|
2890
|
+
const candidate = `${text}\n\n${sections[index]}`;
|
|
2891
|
+
if (candidate.length + omission(omittedCount + sections.length - index - 1).length > WORKSPACE_DIFF_TOTAL_MAX_CHARS)
|
|
2762
2892
|
{
|
|
2763
|
-
|
|
2893
|
+
omittedCount += sections.length - index;
|
|
2894
|
+
break;
|
|
2764
2895
|
}
|
|
2896
|
+
text = candidate;
|
|
2897
|
+
}
|
|
2898
|
+
return omittedCount > 0 ? `${text}${omission(omittedCount)}` : text;
|
|
2899
|
+
};
|
|
2900
|
+
|
|
2901
|
+
const formatWorkspaceDiffResult = async (
|
|
2902
|
+
format: OutputFormat,
|
|
2903
|
+
textPrefix: string,
|
|
2904
|
+
textSections: string[],
|
|
2905
|
+
data: Record<string, unknown>,
|
|
2906
|
+
warnings: string[],
|
|
2907
|
+
outcomes: Array<Record<string, unknown>>,
|
|
2908
|
+
preOmittedOutcomes: number,
|
|
2909
|
+
workdir?: string,
|
|
2910
|
+
): Promise<string> =>
|
|
2911
|
+
{
|
|
2912
|
+
if (format !== "json")
|
|
2913
|
+
{
|
|
2914
|
+
return appendBoundedWorkspaceText(textPrefix, textSections, preOmittedOutcomes);
|
|
2915
|
+
}
|
|
2916
|
+
|
|
2917
|
+
const cliVersion = boundWorkspaceValue(await getCmVersion(workdir), WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS);
|
|
2918
|
+
const wrap = (payload: Record<string, unknown>): string => `## workspaceDiff\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
2919
|
+
const retained: Array<Record<string, unknown>> = [];
|
|
2920
|
+
let omittedOutcomes = preOmittedOutcomes;
|
|
2921
|
+
const payloadFor = (): Record<string, unknown> => ({
|
|
2922
|
+
ok: true,
|
|
2923
|
+
action: "workspaceDiff",
|
|
2924
|
+
toolVersion: TOOL_VERSION,
|
|
2925
|
+
cliVersion,
|
|
2926
|
+
data: { ...data, outcomes: retained, omittedOutcomes },
|
|
2927
|
+
...(warnings.length > 0 ? { warnings } : {}),
|
|
2928
|
+
});
|
|
2929
|
+
|
|
2930
|
+
for (let index = 0; index < outcomes.length; index += 1)
|
|
2931
|
+
{
|
|
2932
|
+
retained.push(outcomes[index]);
|
|
2933
|
+
const remainingCount = outcomes.length - index - 1;
|
|
2934
|
+
// Keep an omission field in the serialized payload before deciding to
|
|
2935
|
+
// retain another result; JSON escaping can otherwise exceed the bound.
|
|
2936
|
+
omittedOutcomes = preOmittedOutcomes + remainingCount;
|
|
2937
|
+
if (wrap(payloadFor()).length > WORKSPACE_DIFF_TOTAL_MAX_CHARS)
|
|
2938
|
+
{
|
|
2939
|
+
retained.pop();
|
|
2940
|
+
omittedOutcomes = preOmittedOutcomes + outcomes.length - index;
|
|
2941
|
+
break;
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
return wrap(payloadFor());
|
|
2945
|
+
};
|
|
2946
|
+
|
|
2947
|
+
export const workspaceDiff = tool({
|
|
2948
|
+
description: "Review bounded pending workspace file diffs in one status pass. Private files are excluded unless explicitly selected or includePrivate=true; unavailable files are reported without aborting the batch.",
|
|
2949
|
+
args: {
|
|
2950
|
+
paths: tool.schema.array(tool.schema.string().min(1).max(WORKSPACE_DIFF_PATH_MAX_CHARS)).max(WORKSPACE_DIFF_MAX_PATHS).optional().describe(`Optional pending workspace paths to review (maximum ${WORKSPACE_DIFF_MAX_PATHS}, ${WORKSPACE_DIFF_PATH_MAX_CHARS} characters each). Selecting paths includes matching private files.`),
|
|
2951
|
+
includePrivate: tool.schema.boolean().optional().describe("Include private pending files when paths are omitted. Defaults to false."),
|
|
2952
|
+
maxFiles: tool.schema.number().int().min(1).max(WORKSPACE_DIFF_MAX_FILES).optional().describe(`Maximum files to compare (default ${WORKSPACE_DIFF_DEFAULT_MAX_FILES}, maximum ${WORKSPACE_DIFF_MAX_FILES}).`),
|
|
2953
|
+
format: outputFormatArg,
|
|
2954
|
+
workdir: workdirArg,
|
|
2955
|
+
},
|
|
2956
|
+
async execute(args)
|
|
2957
|
+
{
|
|
2958
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2959
|
+
// Runtime guards also cover direct execute() callers that bypass schema validation.
|
|
2960
|
+
const rawPaths = Array.isArray(args.paths) ? args.paths.filter((path): path is string => typeof path === "string") : [];
|
|
2961
|
+
const selectedPaths = rawPaths.length > 0
|
|
2962
|
+
? rawPaths.slice(0, WORKSPACE_DIFF_MAX_PATHS).map((path) => boundWorkspaceValue(path, WORKSPACE_DIFF_PATH_MAX_CHARS))
|
|
2963
|
+
: undefined;
|
|
2964
|
+
const ignoredPathInputs = Math.max(0, rawPaths.length - (selectedPaths?.length ?? 0));
|
|
2965
|
+
const pendingItems = await getMachineReadablePendingItems(args.workdir); // exactly one status command per batch
|
|
2966
|
+
const selectedScopes = selectedPaths?.map((path) => toNormalizedAbsolutePath(path, cwd)) ?? [];
|
|
2967
|
+
let candidates = selectedPaths ? filterPendingItemsByScope(pendingItems, selectedScopes) : pendingItems.filter((item) => item.kind !== "private");
|
|
2968
|
+
if (!selectedPaths && args.includePrivate)
|
|
2969
|
+
{
|
|
2970
|
+
candidates = pendingItems;
|
|
2971
|
+
}
|
|
2972
|
+
const requestedMaxFiles = typeof args.maxFiles === "number" && Number.isFinite(args.maxFiles) ? Math.trunc(args.maxFiles) : WORKSPACE_DIFF_DEFAULT_MAX_FILES;
|
|
2973
|
+
const maxFiles = Math.min(WORKSPACE_DIFF_MAX_FILES, Math.max(1, requestedMaxFiles));
|
|
2974
|
+
const skippedByLimit = Math.max(0, candidates.length - maxFiles);
|
|
2975
|
+
candidates = candidates.slice(0, maxFiles);
|
|
2976
|
+
|
|
2977
|
+
const outcomes: Array<Record<string, unknown>> = [];
|
|
2978
|
+
const textSections: string[] = [];
|
|
2979
|
+
let totalOutputChars = 0;
|
|
2980
|
+
let unprocessedCandidates = 0;
|
|
2981
|
+
for (let index = 0; index < candidates.length; index += 1)
|
|
2982
|
+
{
|
|
2983
|
+
if (totalOutputChars >= WORKSPACE_DIFF_CONTENT_MAX_CHARS)
|
|
2984
|
+
{
|
|
2985
|
+
unprocessedCandidates = candidates.length - index;
|
|
2986
|
+
break;
|
|
2987
|
+
}
|
|
2988
|
+
const item = candidates[index];
|
|
2989
|
+
const path = toCommandPath(item.normalizedPath, cwd);
|
|
2990
|
+
const displayPath = boundWorkspaceValue(path, WORKSPACE_DIFF_DISPLAY_PATH_MAX_CHARS);
|
|
2991
|
+
if (item.isDirectory)
|
|
2992
|
+
{
|
|
2993
|
+
outcomes.push({ path: displayPath, kind: item.kind, status: "skipped-directory" });
|
|
2994
|
+
textSections.push(`### ${displayPath} (${item.kind})\nDirectory entries are not diffed.`);
|
|
2995
|
+
totalOutputChars += textSections[textSections.length - 1].length;
|
|
2996
|
+
continue;
|
|
2997
|
+
}
|
|
2998
|
+
try
|
|
2999
|
+
{
|
|
3000
|
+
const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item);
|
|
3001
|
+
const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
|
|
3002
|
+
const perFile = boundWorkspaceDiffResult(compared.result, WORKSPACE_DIFF_PER_FILE_MAX_CHARS);
|
|
3003
|
+
const bounded = boundWorkspaceDiffResult(perFile, remaining);
|
|
3004
|
+
const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : "unchanged");
|
|
3005
|
+
const diffOutput = bounded.changed && !bounded.binary ? bounded.output : undefined;
|
|
3006
|
+
outcomes.push({ path: displayPath, kind: item.kind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, ...(diffOutput ? { diff: diffOutput } : {}) });
|
|
3007
|
+
const body = bounded.binary ? (bounded.changed ? "Binary content differs; a text diff is unavailable." : "No differences.") : (bounded.changed ? bounded.output : "No differences.");
|
|
3008
|
+
const section = `### ${displayPath} (${item.kind})\n${body}`;
|
|
3009
|
+
textSections.push(section);
|
|
3010
|
+
totalOutputChars += section.length;
|
|
3011
|
+
}
|
|
3012
|
+
catch (error)
|
|
3013
|
+
{
|
|
3014
|
+
const message = boundWorkspaceValue(error instanceof Error ? error.message : String(error), WORKSPACE_DIFF_ERROR_MAX_CHARS);
|
|
3015
|
+
outcomes.push({ path: displayPath, kind: item.kind, status: "unavailable", error: message });
|
|
3016
|
+
const section = `### ${displayPath} (${item.kind})\nUnavailable: ${message}`;
|
|
3017
|
+
textSections.push(section);
|
|
3018
|
+
totalOutputChars += section.length;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
|
|
3022
|
+
const unmatchedPaths = selectedPaths?.filter((path) => !filterPendingItemsByScope(pendingItems, [toNormalizedAbsolutePath(path, cwd)]).length) ?? [];
|
|
3023
|
+
const unmatchedPreviews = workspacePathPreview(unmatchedPaths);
|
|
3024
|
+
const warnings = [
|
|
3025
|
+
...(skippedByLimit > 0 ? [`Skipped ${skippedByLimit} pending item(s) after the maxFiles bound of ${maxFiles}.`] : []),
|
|
3026
|
+
...(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` : ""}.`] : []),
|
|
3027
|
+
...(ignoredPathInputs > 0 ? [`Ignored ${ignoredPathInputs} path input(s) after the ${WORKSPACE_DIFF_MAX_PATHS}-path bound.`] : []),
|
|
3028
|
+
...(!selectedPaths && !args.includePrivate && pendingItems.some((item) => item.kind === "private") ? ["Private pending files were excluded; select their paths explicitly or pass includePrivate=true."] : []),
|
|
3029
|
+
].map((warning) => boundWorkspaceValue(warning, WORKSPACE_DIFF_ERROR_MAX_CHARS));
|
|
3030
|
+
const textPrefix = [
|
|
3031
|
+
"## Workspace Diff",
|
|
3032
|
+
"",
|
|
3033
|
+
`- Status records inspected once: ${pendingItems.length}`,
|
|
3034
|
+
`- Files considered: ${candidates.length}`,
|
|
3035
|
+
`- Per-file output bound: ${WORKSPACE_DIFF_PER_FILE_MAX_CHARS} characters; complete response bound: ${WORKSPACE_DIFF_TOTAL_MAX_CHARS} characters.`,
|
|
3036
|
+
...warnings.map((warning) => `- Warning: ${warning}`),
|
|
3037
|
+
...(textSections.length > 0 ? [] : ["", "No eligible pending files."]),
|
|
3038
|
+
].join("\n");
|
|
3039
|
+
return formatWorkspaceDiffResult(args.format ?? "text", textPrefix, textSections, {
|
|
3040
|
+
statusRecords: pendingItems.length,
|
|
3041
|
+
selectedPaths: selectedPaths ? workspacePathPreview(selectedPaths) : null,
|
|
3042
|
+
selectedPathCount: selectedPaths?.length ?? 0,
|
|
3043
|
+
includePrivate: args.includePrivate ?? false,
|
|
3044
|
+
maxFiles,
|
|
3045
|
+
perFileMaxChars: WORKSPACE_DIFF_PER_FILE_MAX_CHARS,
|
|
3046
|
+
totalMaxChars: WORKSPACE_DIFF_TOTAL_MAX_CHARS,
|
|
3047
|
+
skippedByLimit,
|
|
3048
|
+
unmatchedPaths: unmatchedPreviews,
|
|
3049
|
+
unprocessedCandidates,
|
|
3050
|
+
}, warnings, outcomes, unprocessedCandidates, args.workdir);
|
|
2765
3051
|
},
|
|
2766
3052
|
});
|
|
2767
3053
|
|