@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +8 -7
  3. package/extensions/bash-cm-diff-guard.ts +2 -1
  4. package/extensions/file-discovery-filter.ts +67 -0
  5. package/index.ts +12 -0
  6. package/package.json +7 -8
  7. package/skills/_shared/references/conventional-commits.md +198 -198
  8. package/skills/using-plastic/SKILL.md +2 -2
  9. package/skills/using-plastic/references/changeset-operations.md +2 -1
  10. package/skills/using-plastic/references/code-review-creation.md +30 -30
  11. package/skills/using-plastic/references/integration.md +2 -1
  12. package/skills/using-plastic/references/quick-reference.md +2 -1
  13. package/skills/using-plastic/references/resources.md +7 -7
  14. package/skills/using-plastic/references/reviewing-changes.md +4 -3
  15. package/skills/using-plastic/references/shelving.md +25 -25
  16. package/skills/using-plastic/references/troubleshooting.md +2 -1
  17. package/skills/using-plastic/references/workspaces.md +33 -33
  18. package/src/file-discovery-filter-rendezvous.ts +104 -0
  19. package/src/{repository-search-provider.ts → file-discovery-filter.ts} +21 -17
  20. package/src/plastic-core.ts +438 -152
  21. package/src/plastic-tool-loading.ts +2 -1
  22. package/evals/tool-loading/README.md +0 -38
  23. package/evals/tool-loading/RESULTS.md +0 -57
  24. package/evals/tool-loading/baseline.json +0 -34
  25. package/evals/tool-loading/cases.json +0 -70
  26. package/evals/tool-loading/config.json +0 -19
  27. package/evals/tool-loading/mutation-guard.ts +0 -30
  28. package/evals/tool-loading/provider-capture.ts +0 -22
  29. package/evals/tool-loading/results/.gitignore +0 -3
  30. package/evals/tool-loading/run-eval.ts +0 -372
  31. package/extensions/repository-search-provider.ts +0 -66
  32. package/tests/bash-cm-diff-guard-validation.ts +0 -55
  33. package/tests/fixtures/plastic.selector +0 -3
  34. package/tests/fixtures/status-compact.txt +0 -1
  35. package/tests/fixtures/status-normal.txt +0 -1
  36. package/tests/packed-copy-owner-conflict.test.ts +0 -28
  37. package/tests/pi-tool-harness.ts +0 -116
  38. package/tests/plastic-branch-create-live.test.ts +0 -78
  39. package/tests/plastic-branch-query.test.ts +0 -26
  40. package/tests/plastic-branch-status.test.ts +0 -241
  41. package/tests/plastic-checkin-path-resolution.ts +0 -219
  42. package/tests/plastic-constrained-sampling.test.ts +0 -77
  43. package/tests/plastic-diff-workflow.test.ts +0 -86
  44. package/tests/plastic-dynamic-tool-loading.test.ts +0 -224
  45. package/tests/plastic-extension-registration.test.ts +0 -34
  46. package/tests/plastic-live-smoke.test.ts +0 -37
  47. package/tests/plastic-mutation-execution.test.ts +0 -61
  48. package/tests/plastic-patch-command.test.ts +0 -118
  49. package/tests/plastic-process-lifecycle.test.ts +0 -148
  50. package/tests/plastic-tool-rendering.test.ts +0 -16
  51. package/tests/plastic-tool-validation.ts +0 -153
  52. package/tests/plastic-workspace.test.ts +0 -62
  53. package/tests/repository-search-provider-registration.test.ts +0 -37
  54. package/tests/repository-search-provider.test.ts +0 -27
@@ -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 a safe diff executable, or add diff to PATH.`);
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 a file diff, or `plastic_diffRevisions` for two explicit revision specs.";
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
- const statusLinePattern = /^([A-Z+]+)\s+(.+?)\s+(True|False)\s+(.*)$/;
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
- const match = line.match(statusLinePattern);
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 remainder = match[4] ?? "";
617
- const revisionId = remainder.match(/^\s*(\d+)\b/)?.[1];
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
- undefined,
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
- const cwd = args.workdir ?? process.cwd();
2700
- const workspacePath = isAbsolute(args.path) ? args.path : join(cwd, args.path);
2701
- const workspaceStat = await fs.stat(workspacePath).catch(() => null);
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
- 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
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
- await fs.writeFile(basePath, "");
2796
+ throw new Error(`Workspace file '${args.path}' does not exist or is not a regular file.`);
2730
2797
  }
2731
- else
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(materializeSpec, basePath, args.workdir);
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.writeFile(emptyWorkspacePath, "");
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
- finally
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
- await fs.rm(tempDir, { recursive: true, force: true });
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