@aefree/pi-plastic 0.5.2 → 0.5.3
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 +9 -8
- package/package.json +1 -3
- package/skills/using-plastic/SKILL.md +6 -2
- package/skills/using-plastic/references/changeset-operations.md +1 -1
- package/skills/using-plastic/references/integration.md +6 -1
- package/skills/using-plastic/references/quick-reference.md +4 -1
- package/skills/using-plastic/references/reviewing-changes.md +2 -3
- package/skills/using-plastic/references/troubleshooting.md +7 -0
- package/src/plastic-core.ts +371 -62
- package/src/plastic-tool-loading.ts +1 -1
- package/CONTRIBUTING.md +0 -88
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project follows semantic versioning for public package releases.
|
|
7
7
|
|
|
8
|
+
## [0.5.3] - 2026-08-09
|
|
9
|
+
|
|
10
|
+
### Changed
|
|
11
|
+
|
|
12
|
+
- Clarified executable routing: macOS bare `diff` is PATH-dependent and may be GNU Diffutils or Apple BSD `diff`; text-diff selection does not auto-select `gdiff`. Plastic 11 macOS validation confirmed that Apple BSD `/usr/bin/diff` supports text diffs but rejects `cm patch`'s `--binary` argument, so `plastic_patch` now identifies that contract failure and directs callers to the separate GNU Diffutils-compatible patch executable policy.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Made historical and workspace text diffs Unicode-safe for the configured Windows backend: package-owned ASCII materializations are the only operands passed to `diff`, while returned unified headers retain stable logical Unicode labels.
|
|
17
|
+
- Report an added empty pending file as `added-empty` with explicit text/JSON/workspace semantics instead of generic unchanged.
|
|
18
|
+
- Clean package-owned `cm cat --file` outputs after retrieval failures, including failure-created zero-byte files; public guidance now treats `cm cat --raw` as unsupported and names typed retrieval or `cm cat --file` as the byte-preserving fallback.
|
|
19
|
+
- Documented patch moved-item encoding as backend-determined (move-aware or delete/add) rather than claiming unverified runtime behavior.
|
|
20
|
+
- Hardened `plastic_patch` on Windows: it now uses the dedicated `PI_PLASTIC_PATCH_EXECUTABLE` policy (or an explicit `toolPath`) instead of silently reusing the incompatible GnuWin32 text-diff backend, qualifies unqualified branch selectors against the exact current workspace repository, and rejects ambiguous selectors before `cm patch`.
|
|
21
|
+
- Made requested patch output transactional: existing paths are rejected before generation; package-owned sibling staging is validated and atomically published only on success, with failed and zero-byte staging artifacts removed.
|
|
22
|
+
- Reject branch-switch and merge-closeout success when the workspace is not actually on the requested target branch, including checks after target update, before merge checkin, and after checkin.
|
|
23
|
+
- Clarify that Plastic merge checkins belong to the branch loaded in the workspace and cannot be integrated by switching branches afterward like a Git fast-forward.
|
|
24
|
+
|
|
8
25
|
## [0.5.2] - 2026-08-05
|
|
9
26
|
|
|
10
27
|
### Fixed
|
package/README.md
CHANGED
|
@@ -73,12 +73,12 @@ Pi 0.82 and newer use canonical `sourceInfo` provenance to identify this package
|
|
|
73
73
|
The Bash guards run only in Pi processes that actually load this package. A project-local install does not protect a delegated child process whose working directory resolves different project settings. Install `pi-plastic` at user scope (the default `pi install`, without `-l`) when subagents and sessions in arbitrary workspaces must inherit the guards. Restart existing Pi processes after installation or source changes.
|
|
74
74
|
|
|
75
75
|
- `plastic_branchCreate` supports an explicit parent branch independent of the loaded workspace branch, defaults relative names to the current branch when no parent is supplied, and rejects top-level paths unless `allowRootBranch=true` is explicit.
|
|
76
|
-
- `plastic_diff` remains a disabled alias by design. Use `plastic_status` for changed-path listing; do not diff as routine post-edit validation or checkin preflight. When change-boundary evidence is needed, use `plastic_diffFile` for one exact file, `plastic_workspaceDiff` with selected paths or explicit `allPending=true`, or `plastic_diffRevisions` for an explicit historical pair. Diff responses use small defaults and caller-controlled `maxChars` bounds.
|
|
76
|
+
- `plastic_diff` remains a disabled alias by design. Use `plastic_status` for changed-path listing; do not diff as routine post-edit validation or checkin preflight. When change-boundary evidence is needed, use `plastic_diffFile` for one exact file, `plastic_workspaceDiff` with selected paths or explicit `allPending=true`, or `plastic_diffRevisions` for an explicit historical pair. Diff responses use small defaults and caller-controlled `maxChars` bounds. Historical and workspace bytes are copied to ASCII-safe package-owned paths before the backend runs, while normalized headers retain the logical Unicode labels.
|
|
77
77
|
- Preflight is not routine confirmation. Use it for ambiguous or broad mutation scope, moved/deleted path rewriting, compound operations, or explicit preview requests; otherwise rely on exact targets and the tools' runtime guards.
|
|
78
|
-
- `plastic_patch` generates review patches with `cm patch`, including `clean` and `integration` filters for branch review workflows. It does not expose patch apply.
|
|
78
|
+
- `plastic_patch` generates review patches with `cm patch`, including `clean` and `integration` filters for branch review workflows. It does not expose patch apply. Unqualified `br:/...` selectors are qualified only with the current workspace selector's exact repository; otherwise pass `br:/<branch>@<repository>@<server>`.
|
|
79
79
|
- Bash safety rails block `cm diff` and unsafe interactive `cm merge --merge` usage.
|
|
80
80
|
- Merge tooling surfaces Plastic `FILE_CONFLICT` records and merge-state metadata from `cm status`.
|
|
81
|
-
- `plastic_mergeToBranch` performs the common safe closeout flow: resolve the source branch's parent as the default target, switch to the target branch, optionally update, merge a source branch non-interactively, verify merge state, and check in the merge result.
|
|
81
|
+
- `plastic_mergeToBranch` performs the common safe closeout flow: resolve the source branch's parent as the default target, switch to the target branch, optionally update, merge a source branch non-interactively, verify merge state, and check in the merge result. It verifies the loaded target before merge/checkin and rejects final-branch mismatches; Plastic checkins stay on the branch where they were created, so switching afterward is not a Git-style integration.
|
|
82
82
|
- `plastic_finalizeMerge` supports reviewed/manual-resolution flows where Plastic still needs merge metadata finalized before checkin.
|
|
83
83
|
|
|
84
84
|
## Patch generation examples
|
|
@@ -90,7 +90,7 @@ plastic_patch(source="<left-spec>", destination="<right-spec>")
|
|
|
90
90
|
plastic_patch(source="<branch-spec>", toolPath="<path-to-diff-tool>")
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
-
If `output` is omitted,
|
|
93
|
+
If `output` is omitted, the package returns patch content. If `output` is provided, it must be a new path: the package generates into a package-owned sibling staging file, validates it, and atomically publishes it without overwriting an existing path. Plastic/server patch output may encode a moved item either as a move or as delete/add records; `pi-plastic` does not claim a representation without a live backend fixture. Inspect patches before sharing them because they can contain source code, binary content, local paths, or secrets that were present in the changed files.
|
|
94
94
|
|
|
95
95
|
## Included skill
|
|
96
96
|
|
|
@@ -107,13 +107,13 @@ pi install npm:@aefree/pi-plastic
|
|
|
107
107
|
Install a pinned GitHub release over HTTPS:
|
|
108
108
|
|
|
109
109
|
```bash
|
|
110
|
-
pi install git:github.com/aefreedman/pi-plastic@v0.5.
|
|
110
|
+
pi install git:github.com/aefreedman/pi-plastic@v0.5.3
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
Equivalent SSH install:
|
|
114
114
|
|
|
115
115
|
```bash
|
|
116
|
-
pi install git:git@github.com
|
|
116
|
+
pi install git:git@github.com/aefreedman/pi-plastic@v0.5.3
|
|
117
117
|
```
|
|
118
118
|
|
|
119
119
|
To intentionally track the moving default branch instead of a release tag:
|
|
@@ -141,8 +141,9 @@ Project-local installation protects only Pi processes that load those project se
|
|
|
141
141
|
- Node.js 22.19.0 or newer
|
|
142
142
|
- Pi 0.82.0 or newer; the current development and eval baseline is Pi 0.83
|
|
143
143
|
- Plastic SCM / Unity Version Control CLI (`cm`) available on `PATH`, or `PI_PLASTIC_CM_EXECUTABLE` set to its full executable path
|
|
144
|
-
- GNU/POSIX-compatible `diff` available on `PATH`, or `PI_PLASTIC_DIFF_EXECUTABLE` set to its full executable path (including paths containing spaces), for text-only diff
|
|
145
|
-
-
|
|
144
|
+
- A GNU/POSIX-compatible text `diff` available on `PATH`, or `PI_PLASTIC_DIFF_EXECUTABLE` set to its full executable path (including paths containing spaces), for text-only diff tools. Pi invokes that resolved executable as-is and does not discover Git Bash paths automatically. On macOS, bare `diff` is PATH-dependent: it may be GNU Diffutils (often installed as `diff` ahead of system paths) or Apple’s BSD `/usr/bin/diff`; the package does not substitute one for the other. Configure the intended text executable explicitly when that distinction matters.
|
|
145
|
+
- Patch generation has a separate executable policy: `toolPath` is the one-call highest-priority override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, set one to a verified patch-capable non-GUI executable such as Git's `diff.exe`; the package deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32 as a patch default. macOS validation with Plastic 11 found that Apple BSD `/usr/bin/diff` supports text-only diffs but rejects the `--binary` argument supplied by `cm patch`; configure a verified GNU Diffutils-compatible executable explicitly for `plastic_patch`. The non-Windows fallback to `PI_PLASTIC_DIFF_EXECUTABLE` or bare `diff` remains only for environments whose resolved executable satisfies that patch contract.
|
|
146
|
+
- A configured Plastic workspace for workspace-scoped operations. Text diffs require an ASCII-safe temporary directory; if the configured backend cannot accept Unicode paths, set `TEMP` and `TMP` to a writable ASCII-only location.
|
|
146
147
|
- `pi-file-discovery` is an optional independently loaded integration. When its `discover_candidate_files` tool is active, it receives the advisory Plastic ignore/cloak filter through the shared global capability protocol; when absent, `pi-plastic` loads without file-discovery filtering. The tarball does not embed linked sibling workspaces or `node_modules` paths.
|
|
147
148
|
|
|
148
149
|
## Testing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aefree/pi-plastic",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.19.0"
|
|
@@ -15,10 +15,8 @@
|
|
|
15
15
|
"index.ts",
|
|
16
16
|
"extensions/",
|
|
17
17
|
"src/",
|
|
18
|
-
"references/",
|
|
19
18
|
"skills/",
|
|
20
19
|
"CHANGELOG.md",
|
|
21
|
-
"CONTRIBUTING.md",
|
|
22
20
|
"LICENSE",
|
|
23
21
|
"README.md",
|
|
24
22
|
"SECURITY.md"
|
|
@@ -16,9 +16,13 @@ Treat `plastic_merge` success as provisional until `plastic_status` confirms the
|
|
|
16
16
|
|
|
17
17
|
For the common closeout flow of merging a finished source branch into its parent branch, prefer `plastic_mergeToBranch` when available. When `target` is omitted, it resolves the source branch's Plastic parent branch instead of assuming `/dev`. It switches to the target, optionally updates, runs the safe merge, checks merge state, and checks in the merge result. Pass `target` only when the user explicitly wants a different integration branch.
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Plastic merges are workspace- and branch-oriented, not Git fast-forwards: the merge is applied to the branch currently loaded in the workspace, and the merge checkin must be created on that target branch. Switching to the target after a checkin on the source branch does not move or integrate that changeset. Treat any source/target/final-branch mismatch as a failed closeout; inspect target-branch history and rerun the merge from the actual target rather than merely switching branches.
|
|
20
20
|
|
|
21
|
-
`
|
|
21
|
+
`plastic_diff` is intentionally disabled. Route changed-path listing to `plastic_status()`, one intentional workspace-file comparison to `plastic_diffFile(path="<workspace-path>")`, an explicitly scoped pending review to `plastic_workspaceDiff(paths=[...])`, and an explicit historical pair to `plastic_diffRevisions`. Do not use diffs as routine post-edit validation or checkin preflight. Prefer focused tests, `plastic_status` for pending scope, and direct reads for current file content; diff only when change-boundary evidence is needed. `plastic_diffFile` uses machine-readable status: added and explicitly selected private/new files compare against an empty base; changed, moved, and deleted items use their reported base revision when available. An added empty file reports `status="added-empty"`, not generic unchanged. A `--nodata` base failure means Plastic cannot provide historical bytes; refresh/update the workspace or use two known file-qualified revisions. Explicit `revision` behavior remains a separate historical request. Binary content is reported rather than decoded as text. Text backends receive ASCII-safe package materializations while returned headers preserve logical Unicode labels.
|
|
22
|
+
|
|
23
|
+
`plastic_workspaceDiff` requires selected paths or explicit `allPending=true`, runs status once, has small default file/per-file/combined-output bounds, and returns per-file outcomes without aborting when one file is unavailable. Selected private paths are included; whole-workspace private review additionally requires `includePrivate=true`. Keep `maxChars` small unless more output is intentional. Text-diff selection and patch-backend selection are distinct: text tools use `PI_PLASTIC_DIFF_EXECUTABLE` or bare PATH `diff`. On macOS, bare `diff` can resolve either to GNU Diffutils or Apple’s BSD `/usr/bin/diff`; Pi uses the resolved executable and does not auto-select `gdiff`.
|
|
24
|
+
|
|
25
|
+
Use `plastic_patch` for review patch generation with `clean` and `integration` filters; it is generation-only and does not apply patches. `toolPath` is its highest-priority one-call patch-backend override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On Windows, configure one to a verified patch-capable non-GUI executable such as Git's `diff.exe`: patch generation deliberately does not reuse `PI_PLASTIC_DIFF_EXECUTABLE`/GnuWin32. On macOS with Plastic 11, Apple BSD `/usr/bin/diff` works for text-only `plastic_diffFile` but rejects the `--binary` argument used by `cm patch`; set the patch-specific policy to a verified GNU Diffutils-compatible non-GUI executable. The non-Windows fallback to the text setting or bare `diff` is only valid when that executable accepts the local Plastic patch arguments. Unqualified `br:/...` patch selectors are qualified only from the current workspace's exact selector repository; otherwise pass `br:/<branch>@<repository>@<server>`. For a large patch, pass an intentional new `output` path: the package stages and atomically publishes it, never overwriting an existing path. Do not use `cm cat --raw`: it is unsupported for this workflow. Typed retrieval owns cleanup; the low-level byte-preserving fallback is `cm cat --file` only when a typed tool cannot represent the case. Plastic/server output may represent moves as moves or delete/add records, so inspect generated patches rather than assuming a move-aware encoding.
|
|
22
26
|
|
|
23
27
|
Prefer runtime `plastic_*` tools first. Keep `cm` shell commands as manual fallback.
|
|
24
28
|
|
|
@@ -82,7 +82,7 @@ plastic_diffFile(path="<workspace-path>", revision="cs:<number>")
|
|
|
82
82
|
plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevision="<right-file-qualified-revspec>")
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
-
The typed diff tools own historical `cm cat --file` materialization and cleanup; do not create agent-authored temporary files for ordinary diffs.
|
|
85
|
+
The typed diff tools own historical `cm cat --file` materialization and cleanup; do not create agent-authored temporary files for ordinary diffs. `cm cat --raw` is unsupported for this workflow. Use typed retrieval first; `cm cat --file` is the byte-preserving low-level fallback only when a typed tool cannot represent the case.
|
|
86
86
|
|
|
87
87
|
## Commit Message Format
|
|
88
88
|
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
## Autonomous Merge Workflow
|
|
11
11
|
|
|
12
|
+
Plastic does not perform a Git-style fast-forward. It applies the merge into the branch currently loaded in the workspace, and the resulting merge changeset belongs to the branch where checkin runs. Switching branches afterward does not move an incorrectly placed changeset.
|
|
13
|
+
|
|
12
14
|
- Switch to the target branch/workspace and ensure you understand the current pending state.
|
|
13
15
|
- Run `plastic_merge(...)`.
|
|
14
16
|
- If Plastic stops on a delete/change directory conflict and source deletion is the intended resolution, run `plastic_resolveDeleteChangeConflict(...)`, then rerun `plastic_merge(...)`.
|
|
@@ -17,6 +19,7 @@
|
|
|
17
19
|
- If `plastic_status()` still shows `Pending merge links`, merge-in-progress state, or conflict-like pending items, stop and follow troubleshooting before any checkin.
|
|
18
20
|
- After compile/tests, run `plastic_status()` again so Unity-generated private artifacts are not mistaken for merge fallout.
|
|
19
21
|
- Only then create the merge checkin.
|
|
22
|
+
- Verify the merge checkin was created on the target branch. If the workspace or changeset is on the source branch, stop: do not switch to the target and claim completion. Inspect target history and rerun the merge while the target is actually loaded.
|
|
20
23
|
|
|
21
24
|
## Review Workflow
|
|
22
25
|
|
|
@@ -31,9 +34,11 @@ cm find changeset "where branch = '<branch-name>' order by changesetid desc limi
|
|
|
31
34
|
|
|
32
35
|
```text
|
|
33
36
|
plastic_patch(source="<branch-spec>", integration=true)
|
|
34
|
-
plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<patch-file>")
|
|
37
|
+
plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<new-patch-file>")
|
|
35
38
|
```
|
|
36
39
|
|
|
40
|
+
On Windows configure `PI_PLASTIC_PATCH_EXECUTABLE` (or one-call `toolPath`) to a verified patch-capable non-GUI executable; do not rely on the text-diff/GnuWin32 setting. Use a repository-qualified `br:/...@<repository>@<server>` selector when the current workspace selector cannot supply the exact repository. Requested output is staged and atomically published only when successful.
|
|
41
|
+
|
|
37
42
|
- When change-boundary evidence is needed, inspect specific files first and opt into multi-file output explicitly:
|
|
38
43
|
|
|
39
44
|
```text
|
|
@@ -16,7 +16,7 @@ Note: command examples target current `cm` 11.x CLI syntax; legacy aliases may d
|
|
|
16
16
|
| Merge | `plastic_merge(source="<source-branch-spec>")` | `cm merge <source-branch-spec> --merge --nointeractiveresolution --mergetype=try` | Surfaces `FILE_CONFLICT`; run `plastic_status()` before checkin |
|
|
17
17
|
| Finalize merge metadata | `plastic_finalizeMerge(source="<source-branch-spec>", strategy="destination")` | `cm merge <source-branch-spec> --merge --nointeractiveresolution --mergetype=forced --keepdestination` | Use after manual/reviewed resolution when checkin says merge is still in progress |
|
|
18
18
|
| Shelve | `plastic_shelvesetCreate(comment="description")` | `cm shelveset create -c="description"` | Save work temporarily |
|
|
19
|
-
| Patch for review | `plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<patch-file>")` | `cm patch <branch-spec> --clean --integration --output=<patch-file>` |
|
|
19
|
+
| Patch for review | `plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<new-patch-file>")` | `cm patch <branch-spec> --clean --integration --output=<patch-file>` | Uses `toolPath` then `PI_PLASTIC_PATCH_EXECUTABLE`; Windows requires a verified patch-capable non-GUI executable. New output is staged and atomically published without overwrite; use repository-qualified `br:/...@<repository>@<server>` if selector resolution is unavailable |
|
|
20
20
|
| Changed-file listing | `plastic_status(machineReadable=true)` | `cm status --all` | Lists changed, added, moved, deleted, and private items without GUI diff |
|
|
21
21
|
| Diff (one workspace file) | `plastic_diffFile(path="<workspace-path>", maxChars=4000)` | None recommended | Intentional focused comparison only; not routine validation |
|
|
22
22
|
| Diff (pending review) | `plastic_workspaceDiff(paths=["<workspace-path>"], maxChars=3000)` | None recommended | Requires selected paths or explicit `allPending=true`; use status for changed-path listing |
|
|
@@ -25,9 +25,12 @@ Note: command examples target current `cm` 11.x CLI syntax; legacy aliases may d
|
|
|
25
25
|
|
|
26
26
|
## Merge verification checklist
|
|
27
27
|
|
|
28
|
+
Plastic merges into the branch currently loaded in the workspace; switching afterward does not move a source-branch changeset to the target.
|
|
29
|
+
|
|
28
30
|
- Run `plastic_merge(...)`.
|
|
29
31
|
- If `plastic_merge(...)` reports `FILE_CONFLICT`, inspect/resolve the listed paths and validate before finalizing.
|
|
30
32
|
- Immediately run `plastic_status()`.
|
|
31
33
|
- Pending merge links are expected until the merge result is checked in; merge-in-progress hints are blockers.
|
|
32
34
|
- If validation/tests were run after the merge, run `plastic_status()` again before checkin so generated private artifacts do not muddy the result.
|
|
33
35
|
- If `plastic_checkin(...)` says a merge is still in progress after files are resolved and validated, run `plastic_finalizeMerge(source=..., strategy="destination")`, then retry checkin.
|
|
36
|
+
- Confirm the merge changeset and final workspace branch are the intended target. A source/target mismatch is failure, even when the command returned success or the workspace is clean.
|
|
@@ -13,11 +13,10 @@ Use `plastic_patch` when you need a branch-review patch for AI assistance, code
|
|
|
13
13
|
```text
|
|
14
14
|
plastic_patch(source="<branch-spec>", integration=true)
|
|
15
15
|
plastic_patch(source="<branch-spec>", clean=true, integration=true, output="<patch-file>")
|
|
16
|
-
plastic_patch(source="<left-spec>", destination="<right-spec>")
|
|
17
16
|
plastic_patch(source="<branch-spec>", toolPath="<path-to-diff-tool>")
|
|
18
17
|
```
|
|
19
18
|
|
|
20
|
-
`integration=true` shows changes pending merge into the parent branch. `clean=true` strips content that arrived via merges. Use `output` for large patches
|
|
19
|
+
`integration=true` shows changes pending merge into the parent branch. `clean=true` strips content that arrived via merges. Plastic/server output may encode moved items as moves or as delete/add records, so inspect the generated patch rather than assuming a move-aware representation. `toolPath` is the highest-priority patch backend override, followed by `PI_PLASTIC_PATCH_EXECUTABLE`. On macOS with Plastic 11, Apple BSD `/usr/bin/diff` is validated for text-only diffs but rejects the `--binary` argument passed by `cm patch`; configure a verified GNU Diffutils-compatible non-GUI executable through the patch-specific setting or `toolPath`. Text-diff configuration is independent. The non-Windows fallback to `PI_PLASTIC_DIFF_EXECUTABLE` or bare `diff` is suitable only when that executable accepts the local Plastic patch arguments. This macOS observation does not validate a Windows backend; verify an explicit Windows patch backend locally. `destination` passes a second spec to `cm patch`, but its two-spec behavior is not yet an established review workflow. Unqualified `br:/...` selectors are qualified only from the exact current workspace repository, otherwise use `br:/<branch>@<repository>@<server>`. Use `output` for large patches and choose a new path: the package stages, validates, and atomically publishes it without overwriting an existing file. Inspect patch contents before sharing because patches can contain source code, binary data, local paths, or secrets present in changed files.
|
|
21
20
|
|
|
22
21
|
## Text-Only Diff Options
|
|
23
22
|
|
|
@@ -32,7 +31,7 @@ plastic_diffFile(path="<workspace-path>", revision="cs:<number>") # explicit his
|
|
|
32
31
|
plastic_diffRevisions(leftRevision="<left-file-qualified-revspec>", rightRevision="<right-file-qualified-revspec>")
|
|
33
32
|
```
|
|
34
33
|
|
|
35
|
-
The diff tools materialize historical bytes and remove their temporary files internally. Added and explicitly selected private/new files use an empty base;
|
|
34
|
+
The diff tools materialize historical bytes and remove their temporary files internally. Added and explicitly selected private/new files use an empty base; an added empty file is reported as `added-empty`, not generic unchanged. Changed, moved, and deleted records use their status revision ID when available. A `--nodata` item cannot supply historical/base bytes: refresh the workspace or use two known file-qualified revisions. `plastic_workspaceDiff` requires selected paths or `allPending=true` and returns per-file unavailable outcomes instead of aborting the batch. Text-diff tools use bounded GNU/POSIX text diff, materialize backend operands to ASCII-safe package paths while preserving logical Unicode output labels, treat valid Unity YAML as text, and report genuine binary content explicitly. Do not construct `cm cat` temporary-file recipes for ordinary review. `cm cat --raw` is unsupported in this workflow; typed retrieval owns cleanup, and `cm cat --file` is the byte-preserving low-level fallback only when needed.
|
|
36
35
|
|
|
37
36
|
## Metadata Listing (No GUI Diff)
|
|
38
37
|
|
|
@@ -20,6 +20,13 @@ cm switch --silent --noinput <branch-spec>
|
|
|
20
20
|
|
|
21
21
|
Agent preference: use runtime `plastic_*` methods first; keep shell commands as manual fallback.
|
|
22
22
|
|
|
23
|
+
## Patch Backend Rejects `--binary` on macOS
|
|
24
|
+
|
|
25
|
+
- Symptom: `plastic_patch(...)` reports that its diff executable rejected `--binary` and names `PI_PLASTIC_PATCH_EXECUTABLE`.
|
|
26
|
+
- With Plastic 11 on macOS, this is the observed result when `cm patch` uses Apple BSD `/usr/bin/diff`: that executable works for text-only diffs but rejects the patch backend's `--binary` argument.
|
|
27
|
+
- Set `PI_PLASTIC_PATCH_EXECUTABLE` to a verified GNU Diffutils-compatible non-GUI executable, or pass that executable as `toolPath` for one call. Keep `PI_PLASTIC_DIFF_EXECUTABLE` configured separately for text-only review tools.
|
|
28
|
+
- This is a macOS diagnostic, not validation of Windows patch backends or two-spec patch behavior; verify those locally.
|
|
29
|
+
|
|
23
30
|
## Code Review Creation Failed
|
|
24
31
|
|
|
25
32
|
- Use GUI or document changeset in plan/issue tracker.
|
package/src/plastic-core.ts
CHANGED
|
@@ -4,8 +4,8 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { tool } from "./pi-tool-compat";
|
|
5
5
|
import { promises as fs, realpathSync, statSync } from "node:fs";
|
|
6
6
|
import { tmpdir } from "os";
|
|
7
|
-
import { dirname, extname, isAbsolute, join, relative, resolve, win32 } from "path";
|
|
8
|
-
import { parsePlasticStatusBranch } from "./plastic-workspace";
|
|
7
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve, win32 } from "path";
|
|
8
|
+
import { discoverPlasticWorkspace, parsePlasticSelector, parsePlasticStatusBranch } from "./plastic-workspace";
|
|
9
9
|
|
|
10
10
|
type SpawnResult = {
|
|
11
11
|
stdout: string;
|
|
@@ -31,6 +31,10 @@ const getCmExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC
|
|
|
31
31
|
// `diff -u` has compatible exit semantics on Windows GNU diff and macOS BSD diff.
|
|
32
32
|
// Keep the executable name configurable rather than assuming a .exe suffix or package-manager path.
|
|
33
33
|
const getDiffExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
|
|
34
|
+
const PLASTIC_PATCH_EXECUTABLE_ENV = "PI_PLASTIC_PATCH_EXECUTABLE";
|
|
35
|
+
// Plastic's server/backend determines whether a moved item is encoded as a
|
|
36
|
+
// move or as delete/add records. Do not claim either without a live fixture.
|
|
37
|
+
const PATCH_MOVE_REPRESENTATION = "backend-determined" as const;
|
|
34
38
|
|
|
35
39
|
const executableNotFoundError = (command: string, error: Error): Error =>
|
|
36
40
|
{
|
|
@@ -1070,6 +1074,22 @@ const isSameBranchSpec = (left: string, right: string): boolean =>
|
|
|
1070
1074
|
return normalizeBranchSpecForComparison(left) === normalizeBranchSpecForComparison(right);
|
|
1071
1075
|
};
|
|
1072
1076
|
|
|
1077
|
+
const assertWorkspaceOnBranch = (actualBranch: string, expectedBranch: string, phase: string): void =>
|
|
1078
|
+
{
|
|
1079
|
+
if (isSameBranchSpec(actualBranch, expectedBranch))
|
|
1080
|
+
{
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
throw new Error([
|
|
1085
|
+
`Plastic workspace branch mismatch after ${phase}.`,
|
|
1086
|
+
`Expected target: ${expectedBranch}`,
|
|
1087
|
+
`Actual branch: ${actualBranch}`,
|
|
1088
|
+
"The requested merge/checkin was not completed on the target branch.",
|
|
1089
|
+
"Do not switch branches and assume the changeset moved; Plastic changesets remain on the branch where they were created. Inspect branch history and retry the merge from the actual target branch.",
|
|
1090
|
+
].join("\n"));
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1073
1093
|
const getBranchLeafName = (branch: string): string =>
|
|
1074
1094
|
{
|
|
1075
1095
|
const normalized = normalizeBranchSpecForComparison(branch);
|
|
@@ -1178,16 +1198,219 @@ const assertNonBlankPatchValue = (name: keyof PatchCommandArgs, value: string |
|
|
|
1178
1198
|
}
|
|
1179
1199
|
};
|
|
1180
1200
|
|
|
1181
|
-
const resolvePatchToolPath = (
|
|
1201
|
+
const resolvePatchToolPath = (
|
|
1202
|
+
toolPath?: string,
|
|
1203
|
+
environment: ExecutableEnvironment = process.env,
|
|
1204
|
+
platform: NodeJS.Platform = process.platform,
|
|
1205
|
+
): string =>
|
|
1182
1206
|
{
|
|
1207
|
+
// A per-call path is deliberately the strongest override: callers may
|
|
1208
|
+
// select a known patch-capable executable without changing process policy.
|
|
1183
1209
|
if (toolPath?.trim())
|
|
1184
1210
|
{
|
|
1185
1211
|
return toolPath.trim();
|
|
1186
1212
|
}
|
|
1187
1213
|
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1214
|
+
const configuredPatchExecutable = environment[PLASTIC_PATCH_EXECUTABLE_ENV]?.trim();
|
|
1215
|
+
if (configuredPatchExecutable)
|
|
1216
|
+
{
|
|
1217
|
+
return configuredPatchExecutable;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
if (platform === "win32")
|
|
1221
|
+
{
|
|
1222
|
+
// GnuWin32 diff 2.8.7 is known to reject cm patch directory operands.
|
|
1223
|
+
// Do not inherit PI_PLASTIC_DIFF_EXECUTABLE here: that setting remains
|
|
1224
|
+
// for text diffs and may name the incompatible executable.
|
|
1225
|
+
throw new Error(
|
|
1226
|
+
"cm patch requires a patch-capable non-GUI diff executable on Windows. Set PI_PLASTIC_PATCH_EXECUTABLE to a verified tool (for example Git's diff.exe), or pass toolPath for this one call. PI_PLASTIC_DIFF_EXECUTABLE is used only for text diffs and is not a safe Windows patch default.",
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
// Retain the pre-existing non-Windows fallback while keeping the patch-specific
|
|
1231
|
+
// environment variable authoritative. The caller must verify that the resolved
|
|
1232
|
+
// executable supports the local Plastic client's patch argument contract.
|
|
1233
|
+
return resolveExecutable(environment, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
|
|
1234
|
+
};
|
|
1235
|
+
|
|
1236
|
+
type ResolvedPatchBranchSpecs = Pick<PatchCommandArgs, "source" | "destination">;
|
|
1237
|
+
|
|
1238
|
+
// Repository selectors are a single cm argv value, so spaces within a repository
|
|
1239
|
+
// name are not shell separators. Require non-empty @-separated components without
|
|
1240
|
+
// edge whitespace, while retaining Plastic's valid internal spaces and punctuation.
|
|
1241
|
+
const isSafePatchRepositorySelector = (repository: string | undefined): repository is string =>
|
|
1242
|
+
{
|
|
1243
|
+
if (!repository || repository !== repository.trim() || /[\u0000-\u001f\u007f-\u009f]/.test(repository))
|
|
1244
|
+
{
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
return repository.split("@").every((component) => /^[^@\s](?:[^@]*[^@\s])?$/.test(component));
|
|
1249
|
+
};
|
|
1250
|
+
|
|
1251
|
+
const qualifyPatchBranchSpec = (branchSpec: string, repository?: string): string =>
|
|
1252
|
+
{
|
|
1253
|
+
const normalizedBranchSpec = branchSpec.trim();
|
|
1254
|
+
if (!/^br:\/.+/i.test(normalizedBranchSpec) || normalizedBranchSpec.includes("@"))
|
|
1255
|
+
{
|
|
1256
|
+
return normalizedBranchSpec;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
if (!isSafePatchRepositorySelector(repository))
|
|
1260
|
+
{
|
|
1261
|
+
throw new Error(
|
|
1262
|
+
`Cannot safely qualify unqualified branch selector '${normalizedBranchSpec}' against this workspace. Use the exact repository-qualified syntax 'br:/<branch>@<repository>@<server>'.`,
|
|
1263
|
+
);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
return `${normalizedBranchSpec}@${repository}`;
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
const resolvePatchBranchSpecs = async (args: PatchCommandArgs, cwd: string): Promise<ResolvedPatchBranchSpecs> =>
|
|
1270
|
+
{
|
|
1271
|
+
const branchSpecs = [args.source, args.destination].filter((value): value is string => Boolean(value && /^br:\/.+/i.test(value.trim())));
|
|
1272
|
+
const needsQualification = branchSpecs.some((value) => !value.includes("@"));
|
|
1273
|
+
if (!needsQualification)
|
|
1274
|
+
{
|
|
1275
|
+
return { source: args.source.trim(), ...(args.destination ? { destination: args.destination.trim() } : {}) };
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const workspace = await discoverPlasticWorkspace(cwd);
|
|
1279
|
+
if (workspace.kind !== "found")
|
|
1280
|
+
{
|
|
1281
|
+
throw new Error("Cannot safely qualify an unqualified branch selector because the current Plastic workspace repository is unavailable. Use exact repository-qualified syntax 'br:/<branch>@<repository>@<server>'.");
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
const selectorText = await fs.readFile(join(workspace.value.plasticDir, "plastic.selector"), "utf8").catch(() => undefined);
|
|
1285
|
+
const selector = selectorText === undefined ? { kind: "not_found" as const } : parsePlasticSelector(selectorText);
|
|
1286
|
+
const repository = selector.kind === "found" ? selector.value.repository : undefined;
|
|
1287
|
+
return {
|
|
1288
|
+
source: qualifyPatchBranchSpec(args.source, repository),
|
|
1289
|
+
...(args.destination ? { destination: qualifyPatchBranchSpec(args.destination, repository) } : {}),
|
|
1290
|
+
};
|
|
1291
|
+
};
|
|
1292
|
+
|
|
1293
|
+
type PatchOutputStaging = {
|
|
1294
|
+
requestedOutput: string;
|
|
1295
|
+
stagingOutput: string;
|
|
1296
|
+
cleanup: () => Promise<void>;
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
const ensurePatchOutputDoesNotExist = async (requestedOutput: string): Promise<void> =>
|
|
1300
|
+
{
|
|
1301
|
+
const existing = await fs.lstat(requestedOutput).catch((error: unknown) =>
|
|
1302
|
+
{
|
|
1303
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
1304
|
+
throw error;
|
|
1305
|
+
});
|
|
1306
|
+
if (existing)
|
|
1307
|
+
{
|
|
1308
|
+
throw new Error(`Refusing to overwrite existing patch output '${requestedOutput}'. Choose a new output path.`);
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
const createPatchOutputStaging = async (requestedOutput: string): Promise<PatchOutputStaging> =>
|
|
1313
|
+
{
|
|
1314
|
+
await ensurePatchOutputDoesNotExist(requestedOutput);
|
|
1315
|
+
let stagingDirectory: string;
|
|
1316
|
+
try
|
|
1317
|
+
{
|
|
1318
|
+
stagingDirectory = await fs.mkdtemp(join(dirname(requestedOutput), ".pi-plastic-patch-"));
|
|
1319
|
+
}
|
|
1320
|
+
catch (error)
|
|
1321
|
+
{
|
|
1322
|
+
throw new Error(`Cannot create package-owned patch staging beside '${requestedOutput}'. Ensure its parent directory exists and is writable. ${error instanceof Error ? error.message : String(error)}`);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
return {
|
|
1326
|
+
requestedOutput,
|
|
1327
|
+
stagingOutput: join(stagingDirectory, "patch-output"),
|
|
1328
|
+
cleanup: async () => fs.rm(stagingDirectory, { recursive: true, force: true }),
|
|
1329
|
+
};
|
|
1330
|
+
};
|
|
1331
|
+
|
|
1332
|
+
const validatePatchStagingOutput = async (stagingOutput: string, requireContent = false): Promise<void> =>
|
|
1333
|
+
{
|
|
1334
|
+
const patchStat = await fs.lstat(stagingOutput).catch(() => null);
|
|
1335
|
+
if (!patchStat?.isFile())
|
|
1336
|
+
{
|
|
1337
|
+
throw new Error("Plastic reported patch generation success but did not create package-owned staging output.");
|
|
1338
|
+
}
|
|
1339
|
+
if (requireContent && patchStat.size === 0)
|
|
1340
|
+
{
|
|
1341
|
+
// An omitted output may intentionally report an empty patch. A caller
|
|
1342
|
+
// who requested a file, however, must never receive a zero-byte file
|
|
1343
|
+
// that is indistinguishable from a failed/truncated backend outcome.
|
|
1344
|
+
throw new Error("Refusing to publish an empty patch output. Plastic produced no patch bytes; rerun without output to inspect the empty result or verify the patch backend.");
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
// Creating a hard link is atomic and fails if the requested path appeared
|
|
1349
|
+
// after our preflight. Unlike rename(), it can never replace an existing file.
|
|
1350
|
+
const publishPatchOutput = async (stagingOutput: string, requestedOutput: string): Promise<void> =>
|
|
1351
|
+
{
|
|
1352
|
+
await validatePatchStagingOutput(stagingOutput, true);
|
|
1353
|
+
try
|
|
1354
|
+
{
|
|
1355
|
+
await fs.link(stagingOutput, requestedOutput);
|
|
1356
|
+
}
|
|
1357
|
+
catch (error)
|
|
1358
|
+
{
|
|
1359
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
1360
|
+
if (code === "EEXIST")
|
|
1361
|
+
{
|
|
1362
|
+
throw new Error(`Refusing to overwrite existing patch output '${requestedOutput}'. Choose a new output path.`);
|
|
1363
|
+
}
|
|
1364
|
+
throw new Error(`Could not atomically publish patch output '${requestedOutput}'. ${error instanceof Error ? error.message : String(error)}`);
|
|
1365
|
+
}
|
|
1366
|
+
};
|
|
1367
|
+
|
|
1368
|
+
const runPatchOutputTransaction = async (requestedOutput: string, generate: (stagingOutput: string) => Promise<void>): Promise<void> =>
|
|
1369
|
+
{
|
|
1370
|
+
const staging = await createPatchOutputStaging(requestedOutput);
|
|
1371
|
+
try
|
|
1372
|
+
{
|
|
1373
|
+
await generate(staging.stagingOutput);
|
|
1374
|
+
await publishPatchOutput(staging.stagingOutput, staging.requestedOutput);
|
|
1375
|
+
}
|
|
1376
|
+
finally
|
|
1377
|
+
{
|
|
1378
|
+
await staging.cleanup();
|
|
1379
|
+
}
|
|
1380
|
+
};
|
|
1381
|
+
|
|
1382
|
+
const redactPatchStagingOutput = (message: string, stagingOutput: string): string =>
|
|
1383
|
+
{
|
|
1384
|
+
const stagingPaths = new Set([stagingOutput]);
|
|
1385
|
+
try
|
|
1386
|
+
{
|
|
1387
|
+
// macOS commonly exposes /var through the canonical /private/var path.
|
|
1388
|
+
// cm may report the latter even though it received the former.
|
|
1389
|
+
stagingPaths.add(join(realpathSync.native(dirname(stagingOutput)), basename(stagingOutput)));
|
|
1390
|
+
}
|
|
1391
|
+
catch
|
|
1392
|
+
{
|
|
1393
|
+
// The staging parent normally exists; retain raw-path redaction if it does not.
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
return Array.from(stagingPaths)
|
|
1397
|
+
.sort((left, right) => right.length - left.length)
|
|
1398
|
+
.reduce((redacted, path) => redacted.split(path).join("<package-owned-staging-file>"), message);
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
const withPatchBackendContractDiagnostic = (error: unknown, toolPath: string, stagingOutput: string): Error =>
|
|
1402
|
+
{
|
|
1403
|
+
const message = redactPatchStagingOutput(error instanceof Error ? error.message : String(error), stagingOutput);
|
|
1404
|
+
// Plastic 11 on macOS invokes its patch backend with --binary. Apple's BSD
|
|
1405
|
+
// /usr/bin/diff rejects that flag, while GNU diffutils accepts it. Detect
|
|
1406
|
+
// the observed contract failure instead of guessing from a path or OS.
|
|
1407
|
+
if (/unrecognized option [`']?--binary/i.test(message))
|
|
1408
|
+
{
|
|
1409
|
+
return new Error(
|
|
1410
|
+
`${message}\n\nPlastic patch requires a diff executable that accepts --binary. '${toolPath}' rejected that argument. Configure PI_PLASTIC_PATCH_EXECUTABLE (or this call's toolPath) to a verified GNU diffutils-compatible non-GUI diff executable. Text diff configuration is independent; Apple BSD diff may still be used for text-only diffs.`,
|
|
1411
|
+
);
|
|
1412
|
+
}
|
|
1413
|
+
return new Error(message);
|
|
1191
1414
|
};
|
|
1192
1415
|
|
|
1193
1416
|
const buildPatchCommandArgs = (args: PatchCommandArgs): string[] =>
|
|
@@ -1238,6 +1461,13 @@ export const __plasticProcessInternals = {
|
|
|
1238
1461
|
export const __plasticPatchInternals = {
|
|
1239
1462
|
buildPatchCommandArgs,
|
|
1240
1463
|
resolvePatchToolPath,
|
|
1464
|
+
qualifyPatchBranchSpec,
|
|
1465
|
+
resolvePatchBranchSpecs,
|
|
1466
|
+
createPatchOutputStaging,
|
|
1467
|
+
publishPatchOutput,
|
|
1468
|
+
runPatchOutputTransaction,
|
|
1469
|
+
withPatchBackendContractDiagnostic,
|
|
1470
|
+
PATCH_MOVE_REPRESENTATION,
|
|
1241
1471
|
};
|
|
1242
1472
|
|
|
1243
1473
|
export const __plasticCheckinInternals = {
|
|
@@ -1265,6 +1495,7 @@ export const __plasticSwitchInternals = {
|
|
|
1265
1495
|
toLegacyPendingSummary,
|
|
1266
1496
|
normalizeBranchSpecForComparison,
|
|
1267
1497
|
isSameBranchSpec,
|
|
1498
|
+
assertWorkspaceOnBranch,
|
|
1268
1499
|
buildSwitchPendingProfile,
|
|
1269
1500
|
isSwitchBringBlockedForUnattended,
|
|
1270
1501
|
canSwitchDirectWithPrivateOnlyPending,
|
|
@@ -1333,7 +1564,7 @@ const boundDiffOutput = (output: string): Pick<TextDiffResult, "output" | "trunc
|
|
|
1333
1564
|
const runPortableTextDiff = async (
|
|
1334
1565
|
leftPath: string,
|
|
1335
1566
|
rightPath: string,
|
|
1336
|
-
|
|
1567
|
+
_cwd: string,
|
|
1337
1568
|
leftLabel: string,
|
|
1338
1569
|
rightLabel: string,
|
|
1339
1570
|
): Promise<TextDiffResult> =>
|
|
@@ -1344,36 +1575,54 @@ const runPortableTextDiff = async (
|
|
|
1344
1575
|
return { backend: "diff", changed: !leftContent.equals(rightContent), binary: true, output: "", truncated: false, totalChars: 0 };
|
|
1345
1576
|
}
|
|
1346
1577
|
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
if (aborted)
|
|
1578
|
+
// Do not pass either workspace or historical source paths to diff: a
|
|
1579
|
+
// Unicode workspace path reproduces an Invalid argument failure in the
|
|
1580
|
+
// configured Windows GnuWin32 backend. Labels intentionally stay out of
|
|
1581
|
+
// argv and are restored by stableDiffHeaders below.
|
|
1582
|
+
const materializationDir = await createAsciiTempDirectory("pi-plastic-diff-");
|
|
1583
|
+
const materializedLeftPath = join(materializationDir, `left${safeTempExtension(leftPath)}`);
|
|
1584
|
+
const materializedRightPath = join(materializationDir, `right${safeTempExtension(rightPath)}`);
|
|
1585
|
+
try
|
|
1356
1586
|
{
|
|
1357
|
-
|
|
1587
|
+
await Promise.all([
|
|
1588
|
+
fs.writeFile(materializedLeftPath, leftContent),
|
|
1589
|
+
fs.writeFile(materializedRightPath, rightContent),
|
|
1590
|
+
]);
|
|
1591
|
+
const { stdout, stderr, exitCode, aborted, stdoutTruncated, stdoutTotalChars } = await spawnAndCollect(
|
|
1592
|
+
getDiffExecutable(),
|
|
1593
|
+
["-u", materializedLeftPath, materializedRightPath],
|
|
1594
|
+
materializationDir,
|
|
1595
|
+
undefined,
|
|
1596
|
+
getActiveAbortSignal(),
|
|
1597
|
+
{ ...commandExecutionStorage.getStore(), outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
|
|
1598
|
+
);
|
|
1599
|
+
if (aborted)
|
|
1600
|
+
{
|
|
1601
|
+
throw new Error("Text diff was aborted.");
|
|
1602
|
+
}
|
|
1603
|
+
if (exitCode > 1)
|
|
1604
|
+
{
|
|
1605
|
+
const diagnostic = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
1606
|
+
const sanitized = diagnostic
|
|
1607
|
+
.split(materializedLeftPath).join(leftLabel)
|
|
1608
|
+
.split(materializedRightPath).join(rightLabel);
|
|
1609
|
+
throw new Error(sanitized || `Text diff failed with exit code ${exitCode}.`);
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
const normalized = stableDiffHeaders(stdout, leftLabel, rightLabel);
|
|
1613
|
+
const bounded = stdoutTruncated
|
|
1614
|
+
? {
|
|
1615
|
+
output: `${normalized}\n\n[Diff output truncated at ${DIFF_OUTPUT_MAX_CHARS} characters; inspect a narrower file or generate a review patch for the complete change.]`,
|
|
1616
|
+
truncated: true,
|
|
1617
|
+
totalChars: stdoutTotalChars ?? normalized.length,
|
|
1618
|
+
}
|
|
1619
|
+
: boundDiffOutput(normalized);
|
|
1620
|
+
return { backend: "diff", changed: exitCode === 1, binary: false, ...bounded };
|
|
1358
1621
|
}
|
|
1359
|
-
|
|
1622
|
+
finally
|
|
1360
1623
|
{
|
|
1361
|
-
|
|
1362
|
-
const sanitized = diagnostic
|
|
1363
|
-
.split(leftPath).join(leftLabel)
|
|
1364
|
-
.split(rightPath).join(rightLabel);
|
|
1365
|
-
throw new Error(sanitized || `Text diff failed with exit code ${exitCode}.`);
|
|
1624
|
+
await fs.rm(materializationDir, { recursive: true, force: true });
|
|
1366
1625
|
}
|
|
1367
|
-
|
|
1368
|
-
const normalized = stableDiffHeaders(stdout, leftLabel, rightLabel);
|
|
1369
|
-
const bounded = stdoutTruncated
|
|
1370
|
-
? {
|
|
1371
|
-
output: `${normalized}\n\n[Diff output truncated at ${DIFF_OUTPUT_MAX_CHARS} characters; inspect a narrower file or generate a review patch for the complete change.]`,
|
|
1372
|
-
truncated: true,
|
|
1373
|
-
totalChars: stdoutTotalChars ?? normalized.length,
|
|
1374
|
-
}
|
|
1375
|
-
: boundDiffOutput(normalized);
|
|
1376
|
-
return { backend: "diff", changed: exitCode === 1, binary: false, ...bounded };
|
|
1377
1626
|
};
|
|
1378
1627
|
|
|
1379
1628
|
const safeTempExtension = (pathValue: string): string =>
|
|
@@ -1382,6 +1631,20 @@ const safeTempExtension = (pathValue: string): string =>
|
|
|
1382
1631
|
return /^\.[A-Za-z0-9]{1,12}$/.test(extension) ? extension : ".tmp";
|
|
1383
1632
|
};
|
|
1384
1633
|
|
|
1634
|
+
const isAsciiPath = (pathValue: string): boolean => /^[\x20-\x7e]*$/.test(pathValue);
|
|
1635
|
+
|
|
1636
|
+
// GnuWin32 diff cannot reliably accept Unicode operands. Keep every package
|
|
1637
|
+
// materialization path ASCII-only; labels are restored after the backend exits.
|
|
1638
|
+
const createAsciiTempDirectory = async (prefix: string): Promise<string> =>
|
|
1639
|
+
{
|
|
1640
|
+
const temporaryRoot = tmpdir();
|
|
1641
|
+
if (!isAsciiPath(temporaryRoot))
|
|
1642
|
+
{
|
|
1643
|
+
throw new Error("Text diff requires an ASCII-safe temporary directory because the configured backend cannot reliably accept Unicode paths. Set TEMP and TMP to a writable ASCII-only path.");
|
|
1644
|
+
}
|
|
1645
|
+
return fs.mkdtemp(join(temporaryRoot, prefix));
|
|
1646
|
+
};
|
|
1647
|
+
|
|
1385
1648
|
const materializeRevision = async (revision: string, destination: string, workdir?: string): Promise<void> =>
|
|
1386
1649
|
{
|
|
1387
1650
|
// --file keeps historical bytes out of the decoded stdout path. This is
|
|
@@ -1389,17 +1652,20 @@ const materializeRevision = async (revision: string, destination: string, workdi
|
|
|
1389
1652
|
try
|
|
1390
1653
|
{
|
|
1391
1654
|
await runCm(["cat", revision, `--file=${destination}`], workdir);
|
|
1655
|
+
const destinationStat = await fs.stat(destination).catch(() => null);
|
|
1656
|
+
if (!destinationStat?.isFile())
|
|
1657
|
+
{
|
|
1658
|
+
throw new Error("Plastic did not materialize the requested historical file content.");
|
|
1659
|
+
}
|
|
1392
1660
|
}
|
|
1393
1661
|
catch (error)
|
|
1394
1662
|
{
|
|
1663
|
+
// cm cat --file can leave a zero-byte output on failure. The destination
|
|
1664
|
+
// is package-owned, so remove it before surfacing a sanitized diagnostic.
|
|
1665
|
+
await fs.rm(destination, { force: true }).catch(() => undefined);
|
|
1395
1666
|
const message = error instanceof Error ? error.message : String(error);
|
|
1396
1667
|
throw new Error(message.split(destination).join("<package-owned-temp-file>"));
|
|
1397
1668
|
}
|
|
1398
|
-
const destinationStat = await fs.stat(destination).catch(() => null);
|
|
1399
|
-
if (!destinationStat?.isFile())
|
|
1400
|
-
{
|
|
1401
|
-
throw new Error("Plastic did not materialize the requested historical file content.");
|
|
1402
|
-
}
|
|
1403
1669
|
};
|
|
1404
1670
|
|
|
1405
1671
|
const normalizeDiffResponseMaxChars = (value: unknown): number =>
|
|
@@ -1422,10 +1688,10 @@ const formatTextDiff = async (
|
|
|
1422
1688
|
): Promise<string> =>
|
|
1423
1689
|
{
|
|
1424
1690
|
const result = boundTextDiffResult(unboundedResult, normalizeDiffResponseMaxChars(maxChars));
|
|
1425
|
-
const status = result.binary ? (result.changed ? "binary-different" : "unchanged") : (result.changed ? "changed" : "unchanged");
|
|
1691
|
+
const status = result.binary ? (result.changed ? "binary-different" : "unchanged") : (result.changed ? "changed" : (comparisonKind === "workspace-added" ? "added-empty" : "unchanged"));
|
|
1426
1692
|
const text = result.binary
|
|
1427
1693
|
? (result.changed ? "Binary content differs; a text diff is unavailable." : "No differences.")
|
|
1428
|
-
: (result.changed ? result.output : "No differences.");
|
|
1694
|
+
: (result.changed ? result.output : (status === "added-empty" ? "Added file is empty; no text diff hunks." : "No differences."));
|
|
1429
1695
|
const data = {
|
|
1430
1696
|
comparisonKind,
|
|
1431
1697
|
...comparisonMetadata,
|
|
@@ -1497,6 +1763,8 @@ export const __plasticDiffInternals = {
|
|
|
1497
1763
|
boundDiffOutput,
|
|
1498
1764
|
runPortableTextDiff,
|
|
1499
1765
|
safeTempExtension,
|
|
1766
|
+
isAsciiPath,
|
|
1767
|
+
createAsciiTempDirectory,
|
|
1500
1768
|
materializeRevision,
|
|
1501
1769
|
isNoDataError,
|
|
1502
1770
|
withWorkspaceBaseUnavailableDiagnostic,
|
|
@@ -2675,44 +2943,76 @@ export const diff = tool({
|
|
|
2675
2943
|
});
|
|
2676
2944
|
|
|
2677
2945
|
export const patch = tool({
|
|
2678
|
-
description: "Generate a Plastic SCM patch with
|
|
2946
|
+
description: "Generate a Plastic SCM review patch with a patch-specific non-GUI diff backend; branch selectors are qualified to the current workspace repository and requested outputs publish atomically without overwrite (cm patch).",
|
|
2679
2947
|
args: {
|
|
2680
|
-
source: tool.schema.string().min(1).describe("Source changeset or branch spec
|
|
2948
|
+
source: tool.schema.string().min(1).describe("Source changeset or branch spec. Unqualified br:/ selectors are qualified only from the exact current workspace repository."),
|
|
2681
2949
|
destination: tool.schema.string().optional().describe("Optional second changeset or branch spec for two-spec patch generation."),
|
|
2682
|
-
output: tool.schema.string().optional().describe("Optional output file path. If omitted, patch content is
|
|
2683
|
-
toolPath: tool.schema.string().optional().describe("Optional
|
|
2950
|
+
output: tool.schema.string().optional().describe("Optional new output file path. The package stages then atomically publishes it and refuses any existing path. If omitted, patch content is returned."),
|
|
2951
|
+
toolPath: tool.schema.string().optional().describe("Optional patch-capable non-GUI diff executable for this call (highest priority). Otherwise use PI_PLASTIC_PATCH_EXECUTABLE; non-Windows falls back to PI_PLASTIC_DIFF_EXECUTABLE/diff, which must support the local Plastic patch argument contract."),
|
|
2684
2952
|
clean: tool.schema.boolean().optional().describe("Exclude content that arrived via merges and include only direct checkins."),
|
|
2685
2953
|
integration: tool.schema.boolean().optional().describe("Show branch changes pending integration into the parent branch."),
|
|
2686
2954
|
workdir: workdirArg,
|
|
2687
2955
|
},
|
|
2688
2956
|
async execute(args)
|
|
2689
2957
|
{
|
|
2958
|
+
// Validate before staging or launching any process, including direct
|
|
2959
|
+
// callers that bypass the TypeBox schema layer.
|
|
2960
|
+
assertRequiredPatchValue("source", args.source);
|
|
2961
|
+
assertNonBlankPatchValue("destination", args.destination);
|
|
2962
|
+
assertNonBlankPatchValue("output", args.output);
|
|
2963
|
+
assertNonBlankPatchValue("toolPath", args.toolPath);
|
|
2964
|
+
|
|
2690
2965
|
const cwd = args.workdir ?? process.cwd();
|
|
2691
|
-
const
|
|
2692
|
-
const
|
|
2693
|
-
const
|
|
2966
|
+
const toolPath = resolvePatchToolPath(args.toolPath);
|
|
2967
|
+
const branchSpecs = await resolvePatchBranchSpecs(args, cwd);
|
|
2968
|
+
const temporaryOutputDirectory = args.output ? null : await fs.mkdtemp(join(tmpdir(), "plastic-patch-"));
|
|
2969
|
+
const requestedOutput = args.output
|
|
2970
|
+
? (isAbsolute(args.output) ? args.output : resolve(cwd, args.output))
|
|
2971
|
+
: join(temporaryOutputDirectory!, "review.patch");
|
|
2972
|
+
let commandOutput = "";
|
|
2973
|
+
|
|
2974
|
+
const generate = async (stagingOutput: string): Promise<void> =>
|
|
2975
|
+
{
|
|
2976
|
+
const cmdArgs = buildPatchCommandArgs({
|
|
2977
|
+
...args,
|
|
2978
|
+
...branchSpecs,
|
|
2979
|
+
output: stagingOutput,
|
|
2980
|
+
toolPath,
|
|
2981
|
+
});
|
|
2982
|
+
try
|
|
2983
|
+
{
|
|
2984
|
+
commandOutput = await runCm(cmdArgs, args.workdir);
|
|
2985
|
+
}
|
|
2986
|
+
catch (error)
|
|
2987
|
+
{
|
|
2988
|
+
throw withPatchBackendContractDiagnostic(error, toolPath, stagingOutput);
|
|
2989
|
+
}
|
|
2990
|
+
await validatePatchStagingOutput(stagingOutput);
|
|
2991
|
+
};
|
|
2694
2992
|
|
|
2695
2993
|
try
|
|
2696
2994
|
{
|
|
2697
|
-
|
|
2698
|
-
const commandOutput = await runCm(cmdArgs, args.workdir);
|
|
2699
|
-
const patchStat = await fs.stat(resolvedOutput).catch(() => null);
|
|
2700
|
-
if (!patchStat?.isFile())
|
|
2995
|
+
if (args.output)
|
|
2701
2996
|
{
|
|
2702
|
-
|
|
2997
|
+
await runPatchOutputTransaction(requestedOutput, generate);
|
|
2998
|
+
}
|
|
2999
|
+
else
|
|
3000
|
+
{
|
|
3001
|
+
await generate(requestedOutput);
|
|
2703
3002
|
}
|
|
2704
3003
|
|
|
2705
|
-
const patchBytes = await fs.readFile(
|
|
3004
|
+
const patchBytes = await fs.readFile(requestedOutput);
|
|
2706
3005
|
const isText = isUtf8(patchBytes);
|
|
2707
3006
|
const patchText = isText ? patchBytes.toString("utf8") : "";
|
|
2708
3007
|
const bounded = boundDiffOutput(patchText);
|
|
2709
3008
|
const binaryLimited = /Binary files? .* differ|cannot diff|binary (?:content|file) (?:is )?not supported/i.test(`${patchText}\n${commandOutput}`);
|
|
2710
3009
|
const metadata = {
|
|
2711
3010
|
status: patchBytes.length === 0 ? "empty" : (binaryLimited ? "generated-with-binary-warning" : "generated"),
|
|
2712
|
-
output: args.output ?
|
|
3011
|
+
output: args.output ? args.output : null,
|
|
2713
3012
|
bytes: patchBytes.length,
|
|
2714
3013
|
empty: patchBytes.length === 0,
|
|
2715
3014
|
binaryLimited: !isText || binaryLimited,
|
|
3015
|
+
moveRepresentation: PATCH_MOVE_REPRESENTATION,
|
|
2716
3016
|
truncated: !args.output && bounded.truncated,
|
|
2717
3017
|
content: args.output ? null : bounded.output,
|
|
2718
3018
|
};
|
|
@@ -2720,9 +3020,9 @@ export const patch = tool({
|
|
|
2720
3020
|
}
|
|
2721
3021
|
finally
|
|
2722
3022
|
{
|
|
2723
|
-
if (
|
|
3023
|
+
if (temporaryOutputDirectory)
|
|
2724
3024
|
{
|
|
2725
|
-
await fs.rm(
|
|
3025
|
+
await fs.rm(temporaryOutputDirectory, { recursive: true, force: true });
|
|
2726
3026
|
}
|
|
2727
3027
|
}
|
|
2728
3028
|
},
|
|
@@ -2747,7 +3047,7 @@ export const diffRevisions = tool({
|
|
|
2747
3047
|
const cwd = args.workdir ?? process.cwd();
|
|
2748
3048
|
const leftLabel = args.leftRevision.includes("#") ? args.leftRevision.replace("#", "@") : args.leftRevision;
|
|
2749
3049
|
const rightLabel = args.rightRevision.includes("#") ? args.rightRevision.replace("#", "@") : args.rightRevision;
|
|
2750
|
-
const tempDir = await
|
|
3050
|
+
const tempDir = await createAsciiTempDirectory("plastic-core-");
|
|
2751
3051
|
const leftPath = join(tempDir, `left${safeTempExtension(args.leftRevision)}`);
|
|
2752
3052
|
const rightPath = join(tempDir, `right${safeTempExtension(args.rightRevision)}`);
|
|
2753
3053
|
|
|
@@ -2807,7 +3107,7 @@ const diffPendingWorkspaceFile = async (
|
|
|
2807
3107
|
throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
|
|
2808
3108
|
}
|
|
2809
3109
|
|
|
2810
|
-
const tempDir = await
|
|
3110
|
+
const tempDir = await createAsciiTempDirectory("plastic-core-");
|
|
2811
3111
|
const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
|
|
2812
3112
|
const emptyWorkspacePath = join(tempDir, `workspace${safeTempExtension(workspacePath)}`);
|
|
2813
3113
|
try
|
|
@@ -2876,7 +3176,7 @@ export const diffFile = tool({
|
|
|
2876
3176
|
}
|
|
2877
3177
|
const displayPath = isAbsolute(args.path) ? toCommandPath(workspacePath, cwd) : args.path;
|
|
2878
3178
|
const revision = resolveDiffFileRevision(displayPath || args.path, args.revision);
|
|
2879
|
-
const tempDir = await
|
|
3179
|
+
const tempDir = await createAsciiTempDirectory("plastic-core-");
|
|
2880
3180
|
const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
|
|
2881
3181
|
try
|
|
2882
3182
|
{
|
|
@@ -3123,10 +3423,10 @@ export const workspaceDiff = tool({
|
|
|
3123
3423
|
const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
|
|
3124
3424
|
const perFile = boundWorkspaceDiffResult(compared.result, maxChars);
|
|
3125
3425
|
const bounded = boundWorkspaceDiffResult(perFile, remaining);
|
|
3126
|
-
const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : "unchanged");
|
|
3426
|
+
const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : (compared.comparisonKind === "workspace-added" ? "added-empty" : "unchanged"));
|
|
3127
3427
|
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.");
|
|
3428
|
+
outcomes.push({ path: displayPath, kind: item.kind, comparisonKind: compared.comparisonKind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, ...(diffOutput ? { diff: diffOutput } : {}) });
|
|
3429
|
+
const body = bounded.binary ? (bounded.changed ? "Binary content differs; a text diff is unavailable." : "No differences.") : (bounded.changed ? bounded.output : (status === "added-empty" ? "Added file is empty; no text diff hunks." : "No differences."));
|
|
3130
3430
|
const section = `### ${displayPath} (${item.kind})\n${body}`;
|
|
3131
3431
|
textSections.push(section);
|
|
3132
3432
|
totalOutputChars += section.length;
|
|
@@ -3338,6 +3638,7 @@ export const switchBranch = tool({
|
|
|
3338
3638
|
|
|
3339
3639
|
const output = await runCm(switchCmd, args.workdir);
|
|
3340
3640
|
const branchAfter = await resolveCurrentBranchName(args.workdir);
|
|
3641
|
+
assertWorkspaceOnBranch(branchAfter, args.branch, "cm switch");
|
|
3341
3642
|
return toStructuredResult(
|
|
3342
3643
|
"switch-branch",
|
|
3343
3644
|
format,
|
|
@@ -3398,6 +3699,7 @@ export const switchBranch = tool({
|
|
|
3398
3699
|
|
|
3399
3700
|
const output = await runCm(switchCmd, args.workdir);
|
|
3400
3701
|
const branchAfter = await resolveCurrentBranchName(args.workdir);
|
|
3702
|
+
assertWorkspaceOnBranch(branchAfter, args.branch, "cm switch with private-only pending items");
|
|
3401
3703
|
return toStructuredResult(
|
|
3402
3704
|
"switch-branch",
|
|
3403
3705
|
format,
|
|
@@ -3569,6 +3871,7 @@ export const switchBranch = tool({
|
|
|
3569
3871
|
|
|
3570
3872
|
const switchOutput = await runCm(switchCmd, args.workdir);
|
|
3571
3873
|
const branchAfter = await resolveCurrentBranchName(args.workdir).catch(() => branchBefore);
|
|
3874
|
+
assertWorkspaceOnBranch(branchAfter, args.branch, "cm switch after shelving");
|
|
3572
3875
|
|
|
3573
3876
|
return toStructuredResult(
|
|
3574
3877
|
"switch-branch",
|
|
@@ -3860,6 +4163,8 @@ export const mergeToBranch = tool({
|
|
|
3860
4163
|
workdir: args.workdir,
|
|
3861
4164
|
});
|
|
3862
4165
|
const updateResult = updateTarget ? await update.execute({ workdir: args.workdir }) : "(skipped)";
|
|
4166
|
+
const branchBeforeMerge = await resolveCurrentBranchName(args.workdir);
|
|
4167
|
+
assertWorkspaceOnBranch(branchBeforeMerge, targetBranch, "target update and before merge");
|
|
3863
4168
|
const mergeResult = await merge.execute({
|
|
3864
4169
|
source: sourceBranch,
|
|
3865
4170
|
strategy,
|
|
@@ -3898,6 +4203,9 @@ export const mergeToBranch = tool({
|
|
|
3898
4203
|
);
|
|
3899
4204
|
}
|
|
3900
4205
|
|
|
4206
|
+
const branchBeforeCheckin = await resolveCurrentBranchName(args.workdir);
|
|
4207
|
+
assertWorkspaceOnBranch(branchBeforeCheckin, targetBranch, "merge and before checkin");
|
|
4208
|
+
|
|
3901
4209
|
const preflightResult = await checkin.execute({
|
|
3902
4210
|
message: checkinMessage,
|
|
3903
4211
|
includeAll: true,
|
|
@@ -3914,6 +4222,7 @@ export const mergeToBranch = tool({
|
|
|
3914
4222
|
workdir: args.workdir,
|
|
3915
4223
|
});
|
|
3916
4224
|
const finalBranch = await resolveCurrentBranchName(args.workdir);
|
|
4225
|
+
assertWorkspaceOnBranch(finalBranch, targetBranch, "merge checkin");
|
|
3917
4226
|
const finalShortStatus = await runCmRaw(["status", "--short"], args.workdir).catch(() => "");
|
|
3918
4227
|
const finalPendingSummary = summarizeShortStatus(finalShortStatus);
|
|
3919
4228
|
|
|
@@ -33,7 +33,7 @@ export const PLASTIC_SEARCH_CATALOG: readonly PlasticSearchCatalogEntry[] = [
|
|
|
33
33
|
{ name: "plastic_branchCreate", aliases: ["create branch", "new branch"], tags: ["branch", "mutation", "workflow"], guidance: ["Create normal work branches beneath an intended parent, which may differ from the loaded branch. Use allowRootBranch only for intentional top-level branches."] },
|
|
34
34
|
{ name: "plastic_switchBranch", aliases: ["switch branch", "checkout branch", "change branch"], tags: ["branch", "workspace", "mutation"], guidance: ["Check pending changes and choose how to handle them before switching branches."] },
|
|
35
35
|
{ name: "plastic_merge", aliases: ["merge branch", "merge changes"], tags: ["merge", "conflict", "workflow"], guidance: ["Inspect merge state and resolve conflicts before checkin."] },
|
|
36
|
-
{ name: "plastic_mergeToBranch", aliases: ["merge to branch", "close branch", "merge closeout"], tags: ["merge", "branch", "closeout"], guidance: ["
|
|
36
|
+
{ name: "plastic_mergeToBranch", aliases: ["merge to branch", "close branch", "merge closeout"], tags: ["merge", "branch", "closeout"], guidance: ["Plastic merges into the branch loaded in the workspace; confirm source, target, final branch, and target-branch checkin rather than assuming a Git-style fast-forward."] },
|
|
37
37
|
{ name: "plastic_finalizeMerge", aliases: ["finalize merge", "complete merge", "resolve merge"], tags: ["merge", "conflict", "finalize"], guidance: ["Use after reviewed manual conflict resolution when Plastic still needs merge metadata finalized."] },
|
|
38
38
|
{ name: "plastic_currentBranch", aliases: ["current branch", "which branch", "branch name"], tags: ["branch", "workspace", "inspect"], guidance: ["Inspect the current branch before branch, merge, or checkin operations."] },
|
|
39
39
|
{ name: "plastic_branchList", aliases: ["list branches", "find branch", "branches"], tags: ["branch", "inspect", "query"], guidance: ["Use the returned full branch paths when selecting a branch for another operation."] },
|
package/CONTRIBUTING.md
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
# Contributing
|
|
2
|
-
|
|
3
|
-
Thanks for improving Pi Plastic.
|
|
4
|
-
|
|
5
|
-
## Prerequisites
|
|
6
|
-
|
|
7
|
-
- Node.js 22.19.0 or newer
|
|
8
|
-
- npm
|
|
9
|
-
- Plastic SCM / Unity Version Control CLI for opt-in live validation
|
|
10
|
-
- Git for the text-only diff tools
|
|
11
|
-
|
|
12
|
-
## Setup
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
npm ci --ignore-scripts --no-audit --no-fund
|
|
16
|
-
npm test
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
The default suite is credential-free. Keep it that way. Tests that require Plastic access must remain explicitly opt-in and target a dedicated disposable workspace.
|
|
20
|
-
|
|
21
|
-
## Making changes
|
|
22
|
-
|
|
23
|
-
- Keep public tool names and output shapes stable unless the change intentionally documents a breaking release.
|
|
24
|
-
- Preserve built-in and foreign-extension tools when changing active-tool composition.
|
|
25
|
-
- Do not weaken confirmation, preflight, path, process, or mutation safeguards.
|
|
26
|
-
- Add focused regression coverage for behavior changes.
|
|
27
|
-
- Update README documentation when user-facing behavior changes.
|
|
28
|
-
- Keep unreleased user-visible changes under `## Unreleased` in `CHANGELOG.md` during ordinary development.
|
|
29
|
-
- Do not bump the package version for every implementation commit. Convert the accumulated Unreleased section into one dated SemVer release only when preparing a release.
|
|
30
|
-
|
|
31
|
-
## Validation
|
|
32
|
-
|
|
33
|
-
Run:
|
|
34
|
-
|
|
35
|
-
```bash
|
|
36
|
-
npm test
|
|
37
|
-
npm run eval:tool-loading -- --dry-run
|
|
38
|
-
npm pack --dry-run
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
Review the tarball list for machine-specific paths, credentials, raw provider payloads, generated results, and unrelated files.
|
|
42
|
-
|
|
43
|
-
Plastic live tests are intentionally local-only. This project has no shared Plastic Cloud account or CI credentials, and contributors must authenticate `cm` with their own account and use a repository they are authorized to modify. Never add Plastic credentials, tokens, cloud organization details, or private repository data to tests, fixtures, logs, pull requests, or GitHub Actions.
|
|
44
|
-
|
|
45
|
-
For an opt-in read-only Plastic smoke test:
|
|
46
|
-
|
|
47
|
-
```bash
|
|
48
|
-
PI_PLASTIC_TEST_WORKSPACE=/absolute/path/to/dedicated/sandbox npm run test:live
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
For branch-creation behavior, use the opt-in mutation test only against a clean, dedicated disposable repository:
|
|
52
|
-
|
|
53
|
-
```bash
|
|
54
|
-
PI_PLASTIC_TEST_WORKSPACE=/absolute/path/to/dedicated/sandbox \
|
|
55
|
-
PI_PLASTIC_ALLOW_MUTATION_TESTS=true \
|
|
56
|
-
npm run test:live:branch-create
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
The mutation test creates uniquely named hierarchical branches, verifies explicit-parent behavior and the top-level guard, does not switch the workspace, and removes temporary branches in cleanup.
|
|
60
|
-
|
|
61
|
-
### Behavior completion gate
|
|
62
|
-
|
|
63
|
-
When a change modifies how a `plastic_*` tool invokes or interprets real Plastic behavior:
|
|
64
|
-
|
|
65
|
-
1. Add or update credential-free regression coverage for policy, parsing, and command construction.
|
|
66
|
-
2. Locally run the narrowest relevant opt-in live test in a clean disposable Plastic repository using the contributor's own authenticated account.
|
|
67
|
-
3. Verify the workspace state and temporary-resource cleanup afterward.
|
|
68
|
-
4. Report the exact live test and that an authenticated disposable repository was used, without exposing account, organization, server, credential, or private repository details.
|
|
69
|
-
|
|
70
|
-
GitHub Actions is not expected to run Plastic live tests. If local authenticated validation is unavailable, describe the work as implemented with live validation pending; do not claim the behavior is done or verified. Documentation-only and purely internal changes may mark live validation as not applicable with a reason.
|
|
71
|
-
|
|
72
|
-
Follow `evals/tool-loading/README.md` before any live model evaluation. Never use an ordinary production workspace for mutation testing.
|
|
73
|
-
|
|
74
|
-
## Pull requests
|
|
75
|
-
|
|
76
|
-
Keep pull requests focused. Describe:
|
|
77
|
-
|
|
78
|
-
- the behavior changed;
|
|
79
|
-
- safety or compatibility considerations;
|
|
80
|
-
- tests and environments actually run;
|
|
81
|
-
- checks intentionally skipped;
|
|
82
|
-
- relevant before/after metadata or latency measurements for loader changes.
|
|
83
|
-
|
|
84
|
-
Do not include secrets, account data, private repository content, or raw provider captures.
|
|
85
|
-
|
|
86
|
-
## Security reports
|
|
87
|
-
|
|
88
|
-
Follow [SECURITY.md](SECURITY.md) instead of opening a public issue for vulnerabilities.
|