@aefree/pi-plastic 0.5.6 → 0.5.8
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 +13 -0
- package/README.md +4 -3
- package/package.json +1 -1
- package/src/plastic-core.ts +203 -5
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,19 @@ and this project follows semantic versioning for public package releases.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## [0.5.8] - 2026-09-15
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Resolve pending-diff workspace containment and Xlink ownership by filesystem identity when a workspace is accessed through a real filesystem alias, while retaining the caller's lexical path for Plastic commands and displayed paths. Missing deleted or moved ownership ancestors still fail closed.
|
|
15
|
+
- Retry only npm registry E404 availability checks for a bounded period after trusted publishing; a mismatched, malformed, or unauthorized release identity still fails immediately.
|
|
16
|
+
|
|
17
|
+
## [0.5.7] - 2026-09-14
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Bind automatic pending-file diff bases to the owning workspace or Xlink repository/server before materialization, preventing colliding revision IDs from another repository from producing plausible but unrelated diffs. Unresolved ownership is reported as unavailable.
|
|
22
|
+
|
|
10
23
|
## [0.5.6] - 2026-08-27
|
|
11
24
|
|
|
12
25
|
### Fixed
|
package/README.md
CHANGED
|
@@ -73,8 +73,9 @@ 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. Historical and workspace bytes are copied to ASCII-safe package-owned paths before the backend runs, while normalized headers retain the logical Unicode labels.
|
|
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. Automatic pending-file bases bind each status revision ID to its owning workspace or Xlink repository/server, including mixed-Xlink reviews. If ownership cannot be resolved, that comparison is reported unavailable rather than retrieving a bare revision from the caller repository. Historical and workspace bytes are copied to ASCII-safe package-owned paths before the backend runs, while normalized headers retain the logical Unicode labels.
|
|
77
77
|
- `plastic_status(machineReadable=true, format="json")` returns parsed `items`, aggregate `summary`, and `itemCount` (`total`, `returned`, `omitted`) rather than requiring agents to parse Plastic rows. It returns at most 100 items by default; set `maxItems` (up to 500) when more are intentional. `includeRaw=true` adds the original unbounded Plastic output for diagnostics, so omit it for normal agent use to conserve context tokens.
|
|
78
|
+
- Pending-diff ownership containment uses filesystem identity, so a workspace reached through a symlink, junction, or platform filesystem alias still resolves its workspace and nested Xlink bases safely. Plastic commands and returned labels retain the caller's original lexical path; missing deleted or moved ownership ancestors remain unavailable rather than falling back to a parent repository.
|
|
78
79
|
- Preflight is not routine confirmation. Use it for ambiguous or broad mutation scope, moved/deleted path rewriting, compound operations, or explicit preview requests; otherwise rely on exact targets and the tools' runtime guards.
|
|
79
80
|
- `plastic_patch` generates review patches with `cm patch`, including `clean` and `integration` filters for branch review workflows. It does not expose patch apply. Unqualified `br:/...` selectors are qualified only with the current workspace selector's exact repository; otherwise pass `br:/<branch>@<repository>@<server>`.
|
|
80
81
|
- Bash safety rails block `cm diff` and unsafe interactive `cm merge --merge` usage.
|
|
@@ -108,13 +109,13 @@ pi install npm:@aefree/pi-plastic
|
|
|
108
109
|
Install a pinned GitHub release over HTTPS:
|
|
109
110
|
|
|
110
111
|
```bash
|
|
111
|
-
pi install git:github.com/aefreedman/pi-plastic@v0.5.
|
|
112
|
+
pi install git:github.com/aefreedman/pi-plastic@v0.5.7
|
|
112
113
|
```
|
|
113
114
|
|
|
114
115
|
Equivalent SSH install:
|
|
115
116
|
|
|
116
117
|
```bash
|
|
117
|
-
pi install git:git@github.com/aefreedman/pi-plastic@v0.5.
|
|
118
|
+
pi install git:git@github.com/aefreedman/pi-plastic@v0.5.7
|
|
118
119
|
```
|
|
119
120
|
|
|
120
121
|
To intentionally track the moving default branch instead of a release tag:
|
package/package.json
CHANGED
package/src/plastic-core.ts
CHANGED
|
@@ -313,6 +313,7 @@ type PendingItem = {
|
|
|
313
313
|
kind: PendingItemKind;
|
|
314
314
|
revisionId?: string;
|
|
315
315
|
sourceWorkspacePath?: string;
|
|
316
|
+
baseRepository?: string;
|
|
316
317
|
};
|
|
317
318
|
|
|
318
319
|
type PendingItemSummary = {
|
|
@@ -405,6 +406,37 @@ const findCanonicalExistingAncestor = (absolutePath: string): string | null =>
|
|
|
405
406
|
}
|
|
406
407
|
};
|
|
407
408
|
|
|
409
|
+
const toFilesystemIdentityPath = async (absolutePath: string): Promise<string> =>
|
|
410
|
+
{
|
|
411
|
+
let candidate = absolutePath;
|
|
412
|
+
const missingSegments: string[] = [];
|
|
413
|
+
while (true)
|
|
414
|
+
{
|
|
415
|
+
try
|
|
416
|
+
{
|
|
417
|
+
const canonicalAncestor = (await fs.realpath(candidate)).replace(/\\/g, "/");
|
|
418
|
+
return missingSegments.reduce((identityPath, segment) => join(identityPath, segment), canonicalAncestor).replace(/\\/g, "/");
|
|
419
|
+
}
|
|
420
|
+
catch (error)
|
|
421
|
+
{
|
|
422
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
423
|
+
if (code !== "ENOENT" && code !== "ENOTDIR")
|
|
424
|
+
{
|
|
425
|
+
throw error;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const parent = dirname(candidate);
|
|
429
|
+
if (parent === candidate)
|
|
430
|
+
{
|
|
431
|
+
throw error;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
missingSegments.unshift(basename(candidate));
|
|
435
|
+
candidate = parent;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
|
|
408
440
|
const toggleFirstAsciiLetterCase = (value: string): string | null =>
|
|
409
441
|
{
|
|
410
442
|
const index = value.search(/[A-Za-z]/);
|
|
@@ -1743,6 +1775,162 @@ const materializeRevision = async (revision: string, destination: string, workdi
|
|
|
1743
1775
|
}
|
|
1744
1776
|
};
|
|
1745
1777
|
|
|
1778
|
+
type PendingBaseIdentityResolver = {
|
|
1779
|
+
resolve: (item: PendingItem) => Promise<string>;
|
|
1780
|
+
};
|
|
1781
|
+
|
|
1782
|
+
type RepositoryIdentity = {
|
|
1783
|
+
repository: string;
|
|
1784
|
+
server: string;
|
|
1785
|
+
};
|
|
1786
|
+
|
|
1787
|
+
const parseRepositoryIdentity = (selector: string | undefined): RepositoryIdentity | null =>
|
|
1788
|
+
{
|
|
1789
|
+
if (!selector || /[\u0000-\u001f\u007f]/.test(selector))
|
|
1790
|
+
{
|
|
1791
|
+
return null;
|
|
1792
|
+
}
|
|
1793
|
+
// Repository names cannot contain @; cloud server names can (for example,
|
|
1794
|
+
// an organization-qualified cloud endpoint), so split only at the first @.
|
|
1795
|
+
const separator = selector.indexOf("@");
|
|
1796
|
+
if (separator <= 0 || separator === selector.length - 1)
|
|
1797
|
+
{
|
|
1798
|
+
return null;
|
|
1799
|
+
}
|
|
1800
|
+
return { repository: selector.slice(0, separator), server: selector.slice(separator + 1) };
|
|
1801
|
+
};
|
|
1802
|
+
|
|
1803
|
+
const parseRepositorySelector = (output: string): RepositoryIdentity | null =>
|
|
1804
|
+
{
|
|
1805
|
+
const match = output.match(/^\s*repository\s+(?:"([^"]+)"|(\S+))\s*$/mi);
|
|
1806
|
+
return parseRepositoryIdentity(match?.[1] ?? match?.[2]);
|
|
1807
|
+
};
|
|
1808
|
+
|
|
1809
|
+
const parseXlinkRepositorySelector = (output: string): RepositoryIdentity | null =>
|
|
1810
|
+
{
|
|
1811
|
+
// `cm xlink --show` reports wxlink:<serverpath>@<loaded-revision>@<repository>@<server>.
|
|
1812
|
+
const match = output.match(/^.+?\s+-->\s+wxlink:.+?@(?:\d+|cs:\d+)@(.+)\s*$/mi);
|
|
1813
|
+
return parseRepositoryIdentity(match?.[1]?.trim());
|
|
1814
|
+
};
|
|
1815
|
+
|
|
1816
|
+
const isNotXlinkError = (error: unknown): boolean => /\bis not an xlink\.?\s*$/i.test(error instanceof Error ? error.message.trim() : String(error).trim());
|
|
1817
|
+
|
|
1818
|
+
const createPendingBaseIdentityResolver = (cwd: string, workdir?: string): PendingBaseIdentityResolver =>
|
|
1819
|
+
{
|
|
1820
|
+
let workspaceRoot: Promise<string> | undefined;
|
|
1821
|
+
const getWorkspaceRoot = (): Promise<string> =>
|
|
1822
|
+
{
|
|
1823
|
+
workspaceRoot ??= discoverPlasticWorkspace(cwd).then((outcome) =>
|
|
1824
|
+
{
|
|
1825
|
+
if (outcome.kind !== "found")
|
|
1826
|
+
{
|
|
1827
|
+
throw new Error(`Plastic cannot determine the workspace root for pending base resolution: ${outcome.kind === "unavailable" ? outcome.reason : "no workspace marker found"}.`);
|
|
1828
|
+
}
|
|
1829
|
+
return toNormalizedAbsolutePath(outcome.value.root, cwd);
|
|
1830
|
+
});
|
|
1831
|
+
return workspaceRoot;
|
|
1832
|
+
};
|
|
1833
|
+
const xlinkLookups = new Map<string, Promise<RepositoryIdentity | null>>();
|
|
1834
|
+
let workspaceRepository: Promise<RepositoryIdentity> | undefined;
|
|
1835
|
+
const getWorkspaceRepository = (): Promise<RepositoryIdentity> =>
|
|
1836
|
+
{
|
|
1837
|
+
workspaceRepository ??= runCmRaw(["showselector"], workdir).then((output) =>
|
|
1838
|
+
{
|
|
1839
|
+
const repository = parseRepositorySelector(output);
|
|
1840
|
+
if (!repository)
|
|
1841
|
+
{
|
|
1842
|
+
throw new Error("Plastic returned no repository/server identity for this workspace.");
|
|
1843
|
+
}
|
|
1844
|
+
return repository;
|
|
1845
|
+
});
|
|
1846
|
+
return workspaceRepository;
|
|
1847
|
+
};
|
|
1848
|
+
const getXlinkRepository = async (candidate: string): Promise<RepositoryIdentity | null> =>
|
|
1849
|
+
{
|
|
1850
|
+
// Use the physical identity for cache keys, but keep candidate lexical for cm.
|
|
1851
|
+
const key = toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate));
|
|
1852
|
+
let lookup = xlinkLookups.get(key);
|
|
1853
|
+
if (!lookup)
|
|
1854
|
+
{
|
|
1855
|
+
lookup = runCmRaw(["xlink", "--show", candidate], workdir)
|
|
1856
|
+
.then((output) =>
|
|
1857
|
+
{
|
|
1858
|
+
const repository = parseXlinkRepositorySelector(output);
|
|
1859
|
+
if (!repository)
|
|
1860
|
+
{
|
|
1861
|
+
throw new Error(`Plastic returned malformed Xlink ownership for '${candidate}'.`);
|
|
1862
|
+
}
|
|
1863
|
+
return repository;
|
|
1864
|
+
})
|
|
1865
|
+
.catch((error) =>
|
|
1866
|
+
{
|
|
1867
|
+
if (isNotXlinkError(error))
|
|
1868
|
+
{
|
|
1869
|
+
return null;
|
|
1870
|
+
}
|
|
1871
|
+
throw error;
|
|
1872
|
+
});
|
|
1873
|
+
xlinkLookups.set(key, lookup);
|
|
1874
|
+
}
|
|
1875
|
+
return lookup;
|
|
1876
|
+
};
|
|
1877
|
+
|
|
1878
|
+
return {
|
|
1879
|
+
async resolve(item)
|
|
1880
|
+
{
|
|
1881
|
+
if (!item.revisionId)
|
|
1882
|
+
{
|
|
1883
|
+
throw new Error(`Plastic status did not provide a base revision ID for '${item.workspacePath}'.`);
|
|
1884
|
+
}
|
|
1885
|
+
const root = await getWorkspaceRoot();
|
|
1886
|
+
const rootIdentity = await toFilesystemIdentityPath(root);
|
|
1887
|
+
const ownershipPath = item.kind === "moved" && item.sourceWorkspacePath
|
|
1888
|
+
? toNormalizedAbsolutePath(item.sourceWorkspacePath, cwd)
|
|
1889
|
+
: item.normalizedPath;
|
|
1890
|
+
if (!isWithinPathScope(
|
|
1891
|
+
toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(ownershipPath)),
|
|
1892
|
+
toPathComparisonKeyFromAbsolutePath(rootIdentity),
|
|
1893
|
+
))
|
|
1894
|
+
{
|
|
1895
|
+
throw new Error(`Plastic cannot resolve an owning repository for '${item.workspacePath}' outside the requested workspace.`);
|
|
1896
|
+
}
|
|
1897
|
+
let candidate = dirname(ownershipPath);
|
|
1898
|
+
while (isWithinPathScope(
|
|
1899
|
+
toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate)),
|
|
1900
|
+
toPathComparisonKeyFromAbsolutePath(rootIdentity),
|
|
1901
|
+
))
|
|
1902
|
+
{
|
|
1903
|
+
// A missing ancestor can be a removed Xlink mount. Status does not
|
|
1904
|
+
// retain ownership, so parent-repository fallback would be unsafe.
|
|
1905
|
+
if ((item.kind === "deleted" || item.kind === "moved") && !(await fs.stat(candidate).catch(() => null)))
|
|
1906
|
+
{
|
|
1907
|
+
throw new Error(`Plastic cannot resolve the owning repository for '${item.workspacePath}' because an ownership ancestor is missing.`);
|
|
1908
|
+
}
|
|
1909
|
+
const repository = await getXlinkRepository(candidate);
|
|
1910
|
+
if (repository)
|
|
1911
|
+
{
|
|
1912
|
+
item.baseRepository = `${repository.repository}@${repository.server}`;
|
|
1913
|
+
return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
|
|
1914
|
+
}
|
|
1915
|
+
if (toPathComparisonKeyFromAbsolutePath(await toFilesystemIdentityPath(candidate))
|
|
1916
|
+
=== toPathComparisonKeyFromAbsolutePath(rootIdentity))
|
|
1917
|
+
{
|
|
1918
|
+
break;
|
|
1919
|
+
}
|
|
1920
|
+
const parent = dirname(candidate);
|
|
1921
|
+
if (parent === candidate)
|
|
1922
|
+
{
|
|
1923
|
+
break;
|
|
1924
|
+
}
|
|
1925
|
+
candidate = parent;
|
|
1926
|
+
}
|
|
1927
|
+
const repository = await getWorkspaceRepository();
|
|
1928
|
+
item.baseRepository = `${repository.repository}@${repository.server}`;
|
|
1929
|
+
return `revid:${item.revisionId}@rep:${repository.repository}@repserver:${repository.server}`;
|
|
1930
|
+
},
|
|
1931
|
+
};
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1746
1934
|
const normalizeDiffResponseMaxChars = (value: unknown): number =>
|
|
1747
1935
|
{
|
|
1748
1936
|
if (typeof value !== "number" || !Number.isFinite(value))
|
|
@@ -3172,6 +3360,7 @@ type WorkspacePendingDiff = {
|
|
|
3172
3360
|
const diffPendingWorkspaceFile = async (
|
|
3173
3361
|
args: { path: string; workdir?: string },
|
|
3174
3362
|
pendingItem?: PendingItem,
|
|
3363
|
+
baseIdentityResolver?: PendingBaseIdentityResolver,
|
|
3175
3364
|
): Promise<WorkspacePendingDiff> =>
|
|
3176
3365
|
{
|
|
3177
3366
|
const cwd = args.workdir ?? process.cwd();
|
|
@@ -3195,11 +3384,19 @@ const diffPendingWorkspaceFile = async (
|
|
|
3195
3384
|
? `${revisionPath} (empty before private/new file)`
|
|
3196
3385
|
: workspaceBase.resolved.includes("#") ? workspaceBase.resolved.replace("#", "@") : `${revisionPath} (Plastic base)`;
|
|
3197
3386
|
const rightLabel = isDeleted ? `${displayPath} (empty after delete)` : `${displayPath} (workspace)`;
|
|
3198
|
-
const materializeSpec = pendingItem?.revisionId ? `revid:${pendingItem.revisionId}` : workspaceBase.resolved;
|
|
3199
3387
|
if (isDeleted && !pendingItem?.revisionId)
|
|
3200
3388
|
{
|
|
3201
3389
|
throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
|
|
3202
3390
|
}
|
|
3391
|
+
let materializeSpec = workspaceBase.resolved;
|
|
3392
|
+
if (pendingItem?.revisionId && !isAdded && !isPrivate)
|
|
3393
|
+
{
|
|
3394
|
+
if (!baseIdentityResolver)
|
|
3395
|
+
{
|
|
3396
|
+
throw new Error(`Plastic cannot resolve the owning repository for '${args.path}'.`);
|
|
3397
|
+
}
|
|
3398
|
+
materializeSpec = await baseIdentityResolver.resolve(pendingItem);
|
|
3399
|
+
}
|
|
3203
3400
|
|
|
3204
3401
|
const tempDir = await createAsciiTempDirectory("plastic-core-");
|
|
3205
3402
|
const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
|
|
@@ -3304,14 +3501,14 @@ export const diffFile = tool({
|
|
|
3304
3501
|
const workspacePath = toNormalizedAbsolutePath(args.path, cwd);
|
|
3305
3502
|
const pendingItems = await getMachineReadablePendingItems(args.workdir);
|
|
3306
3503
|
const pendingItem = pendingItems.find((item) => item.comparisonKey === toPathComparisonKeyFromAbsolutePath(workspacePath));
|
|
3307
|
-
const compared = await diffPendingWorkspaceFile(args, pendingItem);
|
|
3504
|
+
const compared = await diffPendingWorkspaceFile(args, pendingItem, createPendingBaseIdentityResolver(cwd, args.workdir));
|
|
3308
3505
|
return formatTextDiff(
|
|
3309
3506
|
"diffFile",
|
|
3310
3507
|
args.format,
|
|
3311
3508
|
compared.comparisonKind,
|
|
3312
3509
|
compared.result,
|
|
3313
3510
|
args.workdir,
|
|
3314
|
-
{ pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId },
|
|
3511
|
+
{ pendingKind: compared.pendingKind, isNew: compared.pendingKind === "private" || compared.pendingKind === "added", statusRevisionId: pendingItem?.revisionId, baseRepositoryResolved: Boolean(pendingItem?.baseRepository) },
|
|
3315
3512
|
args.maxChars,
|
|
3316
3513
|
);
|
|
3317
3514
|
},
|
|
@@ -3490,6 +3687,7 @@ export const workspaceDiff = tool({
|
|
|
3490
3687
|
const skippedByLimit = Math.max(0, candidates.length - maxFiles);
|
|
3491
3688
|
candidates = candidates.slice(0, maxFiles);
|
|
3492
3689
|
|
|
3690
|
+
const baseIdentityResolver = createPendingBaseIdentityResolver(cwd, args.workdir);
|
|
3493
3691
|
const outcomes: Array<Record<string, unknown>> = [];
|
|
3494
3692
|
const textSections: string[] = [];
|
|
3495
3693
|
let totalOutputChars = 0;
|
|
@@ -3513,13 +3711,13 @@ export const workspaceDiff = tool({
|
|
|
3513
3711
|
}
|
|
3514
3712
|
try
|
|
3515
3713
|
{
|
|
3516
|
-
const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item);
|
|
3714
|
+
const compared = await diffPendingWorkspaceFile({ path, workdir: args.workdir }, item, baseIdentityResolver);
|
|
3517
3715
|
const remaining = Math.max(0, WORKSPACE_DIFF_CONTENT_MAX_CHARS - totalOutputChars);
|
|
3518
3716
|
const perFile = boundWorkspaceDiffResult(compared.result, maxChars);
|
|
3519
3717
|
const bounded = boundWorkspaceDiffResult(perFile, remaining);
|
|
3520
3718
|
const status = bounded.binary ? (bounded.changed ? "binary-different" : "unchanged") : (bounded.changed ? "changed" : (compared.comparisonKind === "workspace-added" ? "added-empty" : "unchanged"));
|
|
3521
3719
|
const diffOutput = bounded.changed && !bounded.binary ? bounded.output : undefined;
|
|
3522
|
-
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 } : {}) });
|
|
3720
|
+
outcomes.push({ path: displayPath, kind: item.kind, comparisonKind: compared.comparisonKind, status, changed: bounded.changed, binary: bounded.binary, truncated: bounded.truncated, totalChars: bounded.totalChars, baseRepositoryResolved: Boolean(item.baseRepository), ...(diffOutput ? { diff: diffOutput } : {}) });
|
|
3523
3721
|
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."));
|
|
3524
3722
|
const section = `### ${displayPath} (${item.kind})\n${body}`;
|
|
3525
3723
|
textSections.push(section);
|