@aefree/pi-plastic 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/CONTRIBUTING.md +88 -0
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/SECURITY.md +31 -0
- package/evals/tool-loading/README.md +38 -0
- package/evals/tool-loading/RESULTS.md +57 -0
- package/evals/tool-loading/baseline.json +34 -0
- package/evals/tool-loading/cases.json +70 -0
- package/evals/tool-loading/config.json +19 -0
- package/evals/tool-loading/mutation-guard.ts +30 -0
- package/evals/tool-loading/provider-capture.ts +22 -0
- package/evals/tool-loading/results/.gitignore +3 -0
- package/evals/tool-loading/run-eval.ts +372 -0
- package/extensions/bash-cm-diff-guard.ts +49 -0
- package/extensions/bash-cm-merge-guard.ts +53 -0
- package/extensions/plastic-branch-status.ts +285 -0
- package/extensions/repository-search-provider.ts +66 -0
- package/extensions/shared/bash-command-guards.ts +67 -0
- package/index.ts +777 -0
- package/package.json +76 -0
- package/skills/_shared/references/conventional-commits.md +198 -0
- package/skills/using-plastic/SKILL.md +50 -0
- package/skills/using-plastic/references/branch-management.md +78 -0
- package/skills/using-plastic/references/changeset-operations.md +96 -0
- package/skills/using-plastic/references/code-review-creation.md +30 -0
- package/skills/using-plastic/references/integration.md +47 -0
- package/skills/using-plastic/references/quick-reference.md +32 -0
- package/skills/using-plastic/references/resources.md +7 -0
- package/skills/using-plastic/references/reviewing-changes.md +71 -0
- package/skills/using-plastic/references/shelving.md +25 -0
- package/skills/using-plastic/references/troubleshooting.md +115 -0
- package/skills/using-plastic/references/workspaces.md +33 -0
- package/src/pi-tool-compat.ts +56 -0
- package/src/plastic-core.ts +4204 -0
- package/src/plastic-tool-loading.ts +234 -0
- package/src/plastic-workspace.ts +176 -0
- package/src/repository-search-provider.ts +64 -0
- package/tests/bash-cm-diff-guard-validation.ts +55 -0
- package/tests/fixtures/plastic.selector +3 -0
- package/tests/fixtures/status-compact.txt +1 -0
- package/tests/fixtures/status-normal.txt +1 -0
- package/tests/packed-copy-owner-conflict.test.ts +28 -0
- package/tests/pi-tool-harness.ts +116 -0
- package/tests/plastic-branch-create-live.test.ts +78 -0
- package/tests/plastic-branch-query.test.ts +26 -0
- package/tests/plastic-branch-status.test.ts +241 -0
- package/tests/plastic-checkin-path-resolution.ts +219 -0
- package/tests/plastic-constrained-sampling.test.ts +77 -0
- package/tests/plastic-diff-workflow.test.ts +86 -0
- package/tests/plastic-dynamic-tool-loading.test.ts +224 -0
- package/tests/plastic-extension-registration.test.ts +34 -0
- package/tests/plastic-live-smoke.test.ts +37 -0
- package/tests/plastic-mutation-execution.test.ts +61 -0
- package/tests/plastic-patch-command.test.ts +118 -0
- package/tests/plastic-process-lifecycle.test.ts +148 -0
- package/tests/plastic-tool-rendering.test.ts +16 -0
- package/tests/plastic-tool-validation.ts +153 -0
- package/tests/plastic-workspace.test.ts +62 -0
- package/tests/repository-search-provider-registration.test.ts +37 -0
- package/tests/repository-search-provider.test.ts +27 -0
|
@@ -0,0 +1,4204 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { isUtf8 } from "node:buffer";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { tool } from "./pi-tool-compat";
|
|
5
|
+
import { promises as fs, realpathSync, statSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "os";
|
|
7
|
+
import { dirname, extname, isAbsolute, join, relative, resolve, win32 } from "path";
|
|
8
|
+
import { parsePlasticStatusBranch } from "./plastic-workspace";
|
|
9
|
+
|
|
10
|
+
type SpawnResult = {
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
exitCode: number;
|
|
14
|
+
aborted: boolean;
|
|
15
|
+
stdoutTruncated?: boolean;
|
|
16
|
+
stdoutTotalChars?: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const abortSignalStorage = new AsyncLocalStorage<AbortSignal | undefined>();
|
|
20
|
+
const commandExecutionStorage = new AsyncLocalStorage<SpawnAndCollectDependencies | undefined>();
|
|
21
|
+
|
|
22
|
+
type ExecutableEnvironment = Record<string, string | undefined>;
|
|
23
|
+
|
|
24
|
+
const resolveExecutable = (environment: ExecutableEnvironment, overrideVariable: string, fallback: string): string =>
|
|
25
|
+
{
|
|
26
|
+
const override = environment[overrideVariable]?.trim();
|
|
27
|
+
return override || fallback;
|
|
28
|
+
};
|
|
29
|
+
|
|
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
|
+
// `diff -u` has compatible exit semantics on Windows GNU diff and macOS BSD diff.
|
|
33
|
+
// Keep the executable name configurable rather than assuming a .exe suffix or package-manager path.
|
|
34
|
+
const getDiffExecutable = (): string => resolveExecutable(process.env, "PI_PLASTIC_DIFF_EXECUTABLE", "diff");
|
|
35
|
+
|
|
36
|
+
const executableNotFoundError = (command: string, error: Error): Error =>
|
|
37
|
+
{
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT")
|
|
39
|
+
{
|
|
40
|
+
return error;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (command === getCmExecutable())
|
|
44
|
+
{
|
|
45
|
+
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
|
+
}
|
|
47
|
+
|
|
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
|
+
if (command === getDiffExecutable())
|
|
54
|
+
{
|
|
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.`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return new Error(`Unable to launch executable '${command}' (ENOENT). Verify that it is installed and available on PATH.`);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const getActiveAbortSignal = (): AbortSignal | undefined => abortSignalStorage.getStore();
|
|
62
|
+
|
|
63
|
+
export const runWithAbortSignal = async <T>(
|
|
64
|
+
signal: AbortSignal | undefined,
|
|
65
|
+
fn: () => Promise<T>,
|
|
66
|
+
commandExecution?: SpawnAndCollectDependencies,
|
|
67
|
+
): Promise<T> => abortSignalStorage.run(signal, () => commandExecutionStorage.run(commandExecution, fn));
|
|
68
|
+
|
|
69
|
+
const writeInput = async (stdin: NodeJS.WritableStream | null | undefined, input: string): Promise<void> =>
|
|
70
|
+
{
|
|
71
|
+
if (!stdin)
|
|
72
|
+
{
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await new Promise<void>((resolvePromise, rejectPromise) =>
|
|
77
|
+
{
|
|
78
|
+
stdin.write(`${input}\n`, (error) =>
|
|
79
|
+
{
|
|
80
|
+
if (error)
|
|
81
|
+
{
|
|
82
|
+
rejectPromise(error);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
stdin.end();
|
|
87
|
+
resolvePromise();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const readStream = async (stream: NodeJS.ReadableStream | null | undefined, maxChars?: number): Promise<{ output: string; truncated: boolean; totalChars: number }> =>
|
|
93
|
+
{
|
|
94
|
+
if (!stream)
|
|
95
|
+
{
|
|
96
|
+
return { output: "", truncated: false, totalChars: 0 };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let output = "";
|
|
100
|
+
let totalChars = 0;
|
|
101
|
+
for await (const chunk of stream)
|
|
102
|
+
{
|
|
103
|
+
const text = chunk.toString();
|
|
104
|
+
totalChars += text.length;
|
|
105
|
+
if (maxChars === undefined || output.length < maxChars)
|
|
106
|
+
{
|
|
107
|
+
output += maxChars === undefined ? text : text.slice(0, Math.max(0, maxChars - output.length));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { output, truncated: maxChars !== undefined && totalChars > maxChars, totalChars };
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
type SpawnAndCollectDependencies = {
|
|
114
|
+
spawn?: typeof spawn;
|
|
115
|
+
setTimeout?: (callback: () => void, delay: number) => NodeJS.Timeout;
|
|
116
|
+
clearTimeout?: (timeout: NodeJS.Timeout) => void;
|
|
117
|
+
abortKillDelayMs?: number;
|
|
118
|
+
outputLimitChars?: number;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const spawnAndCollect = async (
|
|
122
|
+
command: string,
|
|
123
|
+
args: string[],
|
|
124
|
+
cwd: string,
|
|
125
|
+
input?: string,
|
|
126
|
+
signal?: AbortSignal,
|
|
127
|
+
dependencies: SpawnAndCollectDependencies = {},
|
|
128
|
+
): Promise<SpawnResult> =>
|
|
129
|
+
{
|
|
130
|
+
if (signal?.aborted)
|
|
131
|
+
{
|
|
132
|
+
throw new Error(`cm ${args.join(" ")} aborted before start.`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let proc: ReturnType<typeof spawn>;
|
|
136
|
+
try
|
|
137
|
+
{
|
|
138
|
+
proc = (dependencies.spawn ?? spawn)(command, args, {
|
|
139
|
+
cwd,
|
|
140
|
+
stdio: [input ? "pipe" : "ignore", "pipe", "pipe"],
|
|
141
|
+
shell: false,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
catch (error)
|
|
145
|
+
{
|
|
146
|
+
throw executableNotFoundError(command, error instanceof Error ? error : new Error(String(error)));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const scheduleTimeout = dependencies.setTimeout ?? setTimeout;
|
|
150
|
+
const cancelTimeout = dependencies.clearTimeout ?? clearTimeout;
|
|
151
|
+
const stdoutPromise = readStream(proc.stdout, dependencies.outputLimitChars);
|
|
152
|
+
const stderrPromise = readStream(proc.stderr, dependencies.outputLimitChars);
|
|
153
|
+
let aborted = false;
|
|
154
|
+
let terminalSettled = false;
|
|
155
|
+
let killTimeout: NodeJS.Timeout | undefined;
|
|
156
|
+
|
|
157
|
+
let resolveExit: (exitCode: number) => void;
|
|
158
|
+
let rejectExit: (error: Error) => void;
|
|
159
|
+
const exitPromise = new Promise<number>((resolvePromise, rejectPromise) =>
|
|
160
|
+
{
|
|
161
|
+
resolveExit = resolvePromise;
|
|
162
|
+
rejectExit = rejectPromise;
|
|
163
|
+
});
|
|
164
|
+
const onProcessError = (error: Error) =>
|
|
165
|
+
{
|
|
166
|
+
if (terminalSettled)
|
|
167
|
+
{
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
terminalSettled = true;
|
|
172
|
+
rejectExit(executableNotFoundError(command, error));
|
|
173
|
+
};
|
|
174
|
+
const onProcessClose = (code: number | null) =>
|
|
175
|
+
{
|
|
176
|
+
if (terminalSettled)
|
|
177
|
+
{
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
terminalSettled = true;
|
|
182
|
+
resolveExit(code ?? 1);
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// Register terminal listeners before any asynchronous stdin work can cause an error or close.
|
|
186
|
+
proc.once("error", onProcessError);
|
|
187
|
+
proc.once("close", onProcessClose);
|
|
188
|
+
|
|
189
|
+
const onAbort = () =>
|
|
190
|
+
{
|
|
191
|
+
if (terminalSettled)
|
|
192
|
+
{
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
aborted = true;
|
|
197
|
+
try
|
|
198
|
+
{
|
|
199
|
+
proc.kill("SIGTERM");
|
|
200
|
+
}
|
|
201
|
+
catch
|
|
202
|
+
{
|
|
203
|
+
// The process may have settled between the state check and signal.
|
|
204
|
+
}
|
|
205
|
+
killTimeout = scheduleTimeout(() =>
|
|
206
|
+
{
|
|
207
|
+
// ChildProcess.killed only means a signal was sent. It does not mean the process settled.
|
|
208
|
+
if (!terminalSettled)
|
|
209
|
+
{
|
|
210
|
+
try
|
|
211
|
+
{
|
|
212
|
+
proc.kill("SIGKILL");
|
|
213
|
+
}
|
|
214
|
+
catch
|
|
215
|
+
{
|
|
216
|
+
// The process may have settled between the state check and signal.
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}, dependencies.abortKillDelayMs ?? 5000);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
223
|
+
|
|
224
|
+
try
|
|
225
|
+
{
|
|
226
|
+
const exitCode = input
|
|
227
|
+
? await Promise.race([
|
|
228
|
+
exitPromise,
|
|
229
|
+
writeInput(proc.stdin, input).then(() => exitPromise),
|
|
230
|
+
])
|
|
231
|
+
: await exitPromise;
|
|
232
|
+
const [stdoutResult, stderrResult] = await Promise.all([stdoutPromise, stderrPromise]);
|
|
233
|
+
return {
|
|
234
|
+
stdout: stdoutResult.output,
|
|
235
|
+
stderr: stderrResult.output,
|
|
236
|
+
exitCode,
|
|
237
|
+
aborted,
|
|
238
|
+
stdoutTruncated: stdoutResult.truncated,
|
|
239
|
+
stdoutTotalChars: stdoutResult.totalChars,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
finally
|
|
243
|
+
{
|
|
244
|
+
signal?.removeEventListener("abort", onAbort);
|
|
245
|
+
proc.removeListener("error", onProcessError);
|
|
246
|
+
proc.removeListener("close", onProcessClose);
|
|
247
|
+
if (killTimeout)
|
|
248
|
+
{
|
|
249
|
+
cancelTimeout(killTimeout);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
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.";
|
|
255
|
+
|
|
256
|
+
const ensureCmCommandAllowed = (args: string[]): void =>
|
|
257
|
+
{
|
|
258
|
+
const command = (args[0] ?? "").trim().toLowerCase();
|
|
259
|
+
if (command === "diff")
|
|
260
|
+
{
|
|
261
|
+
throw new Error(BLOCKED_CM_DIFF_MESSAGE);
|
|
262
|
+
}
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
const runCm = async (args: string[], workdir?: string, input?: string, signal: AbortSignal | undefined = getActiveAbortSignal()): Promise<string> =>
|
|
266
|
+
{
|
|
267
|
+
ensureCmCommandAllowed(args);
|
|
268
|
+
const cwd = workdir ?? process.cwd();
|
|
269
|
+
const { stdout, stderr, exitCode, aborted } = await spawnAndCollect(getCmExecutable(), args, cwd, input, signal, commandExecutionStorage.getStore());
|
|
270
|
+
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
271
|
+
|
|
272
|
+
if (aborted)
|
|
273
|
+
{
|
|
274
|
+
throw new Error(output.length > 0 ? `cm ${args.join(" ")} aborted.\n\n${output}` : `cm ${args.join(" ")} aborted.`);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (exitCode !== 0)
|
|
278
|
+
{
|
|
279
|
+
throw new Error(output.length > 0 ? output : `cm ${args.join(" ")} failed with exit code ${exitCode}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return output.length > 0 ? output : "(no output)";
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const runCmRaw = async (args: string[], workdir?: string, input?: string, signal: AbortSignal | undefined = getActiveAbortSignal()): Promise<string> =>
|
|
286
|
+
{
|
|
287
|
+
ensureCmCommandAllowed(args);
|
|
288
|
+
const cwd = workdir ?? process.cwd();
|
|
289
|
+
const { stdout, stderr, exitCode, aborted } = await spawnAndCollect(getCmExecutable(), args, cwd, input, signal, commandExecutionStorage.getStore());
|
|
290
|
+
|
|
291
|
+
if (aborted)
|
|
292
|
+
{
|
|
293
|
+
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
294
|
+
throw new Error(output.length > 0 ? `cm ${args.join(" ")} aborted.\n\n${output}` : `cm ${args.join(" ")} aborted.`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (exitCode !== 0)
|
|
298
|
+
{
|
|
299
|
+
const output = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
300
|
+
throw new Error(output.length > 0 ? output : `cm ${args.join(" ")} failed with exit code ${exitCode}`);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return stdout ?? "";
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
type PendingItemKind = "added" | "changed" | "moved" | "deleted" | "private" | "other";
|
|
307
|
+
|
|
308
|
+
type PendingItem = {
|
|
309
|
+
statusCode: string;
|
|
310
|
+
workspacePath: string;
|
|
311
|
+
normalizedPath: string;
|
|
312
|
+
comparisonKey: string;
|
|
313
|
+
isDirectory: boolean;
|
|
314
|
+
kind: PendingItemKind;
|
|
315
|
+
revisionId?: string;
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
type PendingItemSummary = {
|
|
319
|
+
totalPending: number;
|
|
320
|
+
added: number;
|
|
321
|
+
changed: number;
|
|
322
|
+
moved: number;
|
|
323
|
+
deleted: number;
|
|
324
|
+
private: number;
|
|
325
|
+
other: number;
|
|
326
|
+
tracked: number;
|
|
327
|
+
privatePaths: string[];
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
type PrivateAutoAddSelection = {
|
|
331
|
+
candidatePaths: string[];
|
|
332
|
+
blockedPaths: Array<{ path: string; reason: string }>;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
type ResolvedCheckinPaths = {
|
|
336
|
+
requestedPaths: string[];
|
|
337
|
+
includedPaths: string[];
|
|
338
|
+
includedAbsolutePaths: string[];
|
|
339
|
+
fallbackPaths: string[];
|
|
340
|
+
fallbackAbsolutePaths: string[];
|
|
341
|
+
rewrittenPaths: string[];
|
|
342
|
+
excludedPaths: Array<{ path: string; reason: string }>;
|
|
343
|
+
shouldApplyChanged: boolean;
|
|
344
|
+
rewriteReason?: string;
|
|
345
|
+
matchedPendingCount: number;
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const isWindowsStyleAbsolutePath = (absolutePath: string): boolean => /^[A-Za-z]:\//.test(absolutePath) || absolutePath.startsWith("//");
|
|
349
|
+
|
|
350
|
+
const toNormalizedAbsolutePath = (pathValue: string, cwd: string): string =>
|
|
351
|
+
{
|
|
352
|
+
const normalizedPathValue = pathValue.replace(/\\/g, "/");
|
|
353
|
+
const normalizedCwd = cwd.replace(/\\/g, "/");
|
|
354
|
+
if (isWindowsStyleAbsolutePath(normalizedPathValue))
|
|
355
|
+
{
|
|
356
|
+
return win32.normalize(normalizedPathValue).replace(/\\/g, "/");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (isWindowsStyleAbsolutePath(normalizedCwd))
|
|
360
|
+
{
|
|
361
|
+
return win32.resolve(normalizedCwd, normalizedPathValue).replace(/\\/g, "/");
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const absolutePath = isAbsolute(pathValue) ? resolve(pathValue) : resolve(cwd, pathValue);
|
|
365
|
+
return absolutePath.replace(/\\/g, "/");
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const darwinVolumeCaseSensitivity = new Map<string, boolean>();
|
|
369
|
+
const isSameFilesystemDevice = (volumeDevice: number | bigint, candidateDevice: number | bigint): boolean =>
|
|
370
|
+
volumeDevice === candidateDevice;
|
|
371
|
+
|
|
372
|
+
const findCanonicalExistingAncestor = (absolutePath: string): string | null =>
|
|
373
|
+
{
|
|
374
|
+
let candidate = absolutePath;
|
|
375
|
+
while (true)
|
|
376
|
+
{
|
|
377
|
+
try
|
|
378
|
+
{
|
|
379
|
+
return realpathSync.native(candidate).replace(/\\/g, "/");
|
|
380
|
+
}
|
|
381
|
+
catch
|
|
382
|
+
{
|
|
383
|
+
const parent = dirname(candidate);
|
|
384
|
+
if (parent === candidate)
|
|
385
|
+
{
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
candidate = parent;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const toggleFirstAsciiLetterCase = (value: string): string | null =>
|
|
395
|
+
{
|
|
396
|
+
const index = value.search(/[A-Za-z]/);
|
|
397
|
+
if (index < 0)
|
|
398
|
+
{
|
|
399
|
+
return null;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const letter = value[index];
|
|
403
|
+
const toggled = letter === letter.toLowerCase() ? letter.toUpperCase() : letter.toLowerCase();
|
|
404
|
+
return `${value.slice(0, index)}${toggled}${value.slice(index + 1)}`;
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
// APFS may be formatted case-sensitive or case-insensitive, so Darwin must probe the
|
|
408
|
+
// canonical existing ancestor on the target volume instead of assuming a platform policy.
|
|
409
|
+
const isDarwinCaseInsensitiveVolume = (absolutePath: string): boolean =>
|
|
410
|
+
{
|
|
411
|
+
const canonicalAncestor = findCanonicalExistingAncestor(absolutePath);
|
|
412
|
+
if (!canonicalAncestor)
|
|
413
|
+
{
|
|
414
|
+
return false;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
try
|
|
418
|
+
{
|
|
419
|
+
const volumeDevice = statSync(canonicalAncestor).dev;
|
|
420
|
+
const volumeKey = String(volumeDevice);
|
|
421
|
+
const cached = darwinVolumeCaseSensitivity.get(volumeKey);
|
|
422
|
+
if (cached !== undefined)
|
|
423
|
+
{
|
|
424
|
+
return cached;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
let probePath = canonicalAncestor;
|
|
428
|
+
while (true)
|
|
429
|
+
{
|
|
430
|
+
const parent = dirname(probePath);
|
|
431
|
+
if (parent === probePath)
|
|
432
|
+
{
|
|
433
|
+
darwinVolumeCaseSensitivity.set(volumeKey, false);
|
|
434
|
+
return false;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// A mount point's parent belongs to a different filesystem. Do not
|
|
438
|
+
// infer the mounted volume's case policy from the parent/root volume.
|
|
439
|
+
if (!isSameFilesystemDevice(volumeDevice, statSync(parent).dev))
|
|
440
|
+
{
|
|
441
|
+
darwinVolumeCaseSensitivity.set(volumeKey, false);
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const alternateName = toggleFirstAsciiLetterCase(probePath.slice(parent.length + 1));
|
|
446
|
+
if (alternateName)
|
|
447
|
+
{
|
|
448
|
+
try
|
|
449
|
+
{
|
|
450
|
+
const canonicalAlternate = realpathSync.native(join(parent, alternateName)).replace(/\\/g, "/");
|
|
451
|
+
const isCaseInsensitive = canonicalAlternate === probePath;
|
|
452
|
+
darwinVolumeCaseSensitivity.set(volumeKey, isCaseInsensitive);
|
|
453
|
+
return isCaseInsensitive;
|
|
454
|
+
}
|
|
455
|
+
catch
|
|
456
|
+
{
|
|
457
|
+
darwinVolumeCaseSensitivity.set(volumeKey, false);
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
probePath = parent;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
catch
|
|
466
|
+
{
|
|
467
|
+
// A failed probe must preserve distinct paths rather than risk conflating files.
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
type PathComparisonPolicy = {
|
|
473
|
+
platform?: NodeJS.Platform;
|
|
474
|
+
isDarwinCaseInsensitiveVolume?: (absolutePath: string) => boolean;
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const toPathComparisonKeyFromAbsolutePath = (absolutePath: string, policy: PathComparisonPolicy = {}): string =>
|
|
478
|
+
{
|
|
479
|
+
const normalizedPath = absolutePath.replace(/\\/g, "/");
|
|
480
|
+
// Keep Windows and UNC behavior regardless of the host platform, including when parsing
|
|
481
|
+
// status captured from a Windows workspace on another machine.
|
|
482
|
+
if (isWindowsStyleAbsolutePath(normalizedPath))
|
|
483
|
+
{
|
|
484
|
+
return normalizedPath.toLowerCase();
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if ((policy.platform ?? process.platform) === "darwin"
|
|
488
|
+
&& (policy.isDarwinCaseInsensitiveVolume ?? isDarwinCaseInsensitiveVolume)(normalizedPath))
|
|
489
|
+
{
|
|
490
|
+
return normalizedPath.toLowerCase();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return normalizedPath;
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const toPathComparisonKey = (pathValue: string, cwd: string): string => toPathComparisonKeyFromAbsolutePath(toNormalizedAbsolutePath(pathValue, cwd));
|
|
497
|
+
|
|
498
|
+
const toCommandPath = (absolutePath: string, cwd: string): string =>
|
|
499
|
+
{
|
|
500
|
+
const relativePath = isWindowsStyleAbsolutePath(absolutePath.replace(/\\/g, "/"))
|
|
501
|
+
&& isWindowsStyleAbsolutePath(cwd.replace(/\\/g, "/"))
|
|
502
|
+
? win32.relative(cwd, absolutePath)
|
|
503
|
+
: relative(cwd, absolutePath);
|
|
504
|
+
if (relativePath.length === 0)
|
|
505
|
+
{
|
|
506
|
+
return ".";
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
if (relativePath.startsWith(".."))
|
|
510
|
+
{
|
|
511
|
+
return absolutePath;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return relativePath;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const isWithinPathScope = (candidatePath: string, scopePath: string): boolean =>
|
|
518
|
+
{
|
|
519
|
+
if (candidatePath === scopePath)
|
|
520
|
+
{
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return candidatePath.startsWith(`${scopePath}/`);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const dedupeAndMinimizeAbsolutePaths = (absolutePaths: string[]): string[] =>
|
|
528
|
+
{
|
|
529
|
+
const uniquePaths: string[] = [];
|
|
530
|
+
const seenComparisonKeys = new Set<string>();
|
|
531
|
+
for (const path of absolutePaths)
|
|
532
|
+
{
|
|
533
|
+
const comparisonKey = toPathComparisonKeyFromAbsolutePath(path);
|
|
534
|
+
if (seenComparisonKeys.has(comparisonKey))
|
|
535
|
+
{
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
seenComparisonKeys.add(comparisonKey);
|
|
540
|
+
uniquePaths.push(path);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const sortedPaths = uniquePaths.sort((left, right) => left.length - right.length);
|
|
544
|
+
const minimizedPaths: string[] = [];
|
|
545
|
+
const minimizedComparisonKeys: string[] = [];
|
|
546
|
+
|
|
547
|
+
for (const path of sortedPaths)
|
|
548
|
+
{
|
|
549
|
+
const comparisonKey = toPathComparisonKeyFromAbsolutePath(path);
|
|
550
|
+
if (minimizedComparisonKeys.some((scopePath) => isWithinPathScope(comparisonKey, scopePath)))
|
|
551
|
+
{
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
minimizedPaths.push(path);
|
|
556
|
+
minimizedComparisonKeys.push(comparisonKey);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return minimizedPaths;
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
const inferPendingItemKind = (statusCode: string): PendingItemKind =>
|
|
563
|
+
{
|
|
564
|
+
const normalizedStatus = statusCode.toUpperCase();
|
|
565
|
+
|
|
566
|
+
if (normalizedStatus.includes("MV"))
|
|
567
|
+
{
|
|
568
|
+
return "moved";
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (normalizedStatus.includes("LD") || normalizedStatus.includes("RD") || normalizedStatus.includes("DE") || normalizedStatus.includes("RM"))
|
|
572
|
+
{
|
|
573
|
+
return "deleted";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (normalizedStatus.includes("AD"))
|
|
577
|
+
{
|
|
578
|
+
return "added";
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (normalizedStatus.includes("PR"))
|
|
582
|
+
{
|
|
583
|
+
return "private";
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (normalizedStatus.includes("CH") || normalizedStatus.includes("CO") || normalizedStatus.includes("RP"))
|
|
587
|
+
{
|
|
588
|
+
return "changed";
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
return "other";
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
const parseMachineReadablePendingItems = (output: string, cwd: string): PendingItem[] =>
|
|
595
|
+
{
|
|
596
|
+
const lines = normalizeFindOutputLines(output);
|
|
597
|
+
const statusLinePattern = /^([A-Z+]+)\s+(.+?)\s+(True|False)\s+(.*)$/;
|
|
598
|
+
const pendingItems: PendingItem[] = [];
|
|
599
|
+
|
|
600
|
+
for (const line of lines)
|
|
601
|
+
{
|
|
602
|
+
if (line.startsWith("STATUS "))
|
|
603
|
+
{
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const match = line.match(statusLinePattern);
|
|
608
|
+
if (!match)
|
|
609
|
+
{
|
|
610
|
+
continue;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const statusCode = match[1];
|
|
614
|
+
const workspacePath = match[2];
|
|
615
|
+
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
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return pendingItems;
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const getMachineReadablePendingItems = async (workdir?: string): Promise<PendingItem[]> =>
|
|
634
|
+
{
|
|
635
|
+
const cwd = workdir ?? process.cwd();
|
|
636
|
+
const output = await runCmRaw(["status", "--machinereadable", "--includeRevId"], workdir);
|
|
637
|
+
return parseMachineReadablePendingItems(output, cwd);
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const summarizePendingItems = (pendingItems: PendingItem[], cwd?: string): PendingItemSummary =>
|
|
641
|
+
{
|
|
642
|
+
const summary: PendingItemSummary = {
|
|
643
|
+
totalPending: pendingItems.length,
|
|
644
|
+
added: 0,
|
|
645
|
+
changed: 0,
|
|
646
|
+
moved: 0,
|
|
647
|
+
deleted: 0,
|
|
648
|
+
private: 0,
|
|
649
|
+
other: 0,
|
|
650
|
+
tracked: 0,
|
|
651
|
+
privatePaths: [],
|
|
652
|
+
};
|
|
653
|
+
|
|
654
|
+
for (const item of pendingItems)
|
|
655
|
+
{
|
|
656
|
+
switch (item.kind)
|
|
657
|
+
{
|
|
658
|
+
case "added":
|
|
659
|
+
summary.added += 1;
|
|
660
|
+
break;
|
|
661
|
+
case "changed":
|
|
662
|
+
summary.changed += 1;
|
|
663
|
+
break;
|
|
664
|
+
case "moved":
|
|
665
|
+
summary.moved += 1;
|
|
666
|
+
break;
|
|
667
|
+
case "deleted":
|
|
668
|
+
summary.deleted += 1;
|
|
669
|
+
break;
|
|
670
|
+
case "private":
|
|
671
|
+
summary.private += 1;
|
|
672
|
+
summary.privatePaths.push(cwd ? toCommandPath(item.normalizedPath, cwd) : item.workspacePath);
|
|
673
|
+
break;
|
|
674
|
+
default:
|
|
675
|
+
summary.other += 1;
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
summary.tracked = summary.totalPending - summary.private;
|
|
681
|
+
return summary;
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const formatPendingPathPreview = (pendingItems: PendingItem[], cwd: string, limit = 5): string =>
|
|
685
|
+
{
|
|
686
|
+
if (pendingItems.length === 0)
|
|
687
|
+
{
|
|
688
|
+
return "(none)";
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
const preview = pendingItems
|
|
692
|
+
.slice(0, limit)
|
|
693
|
+
.map((item) => toCommandPath(item.normalizedPath, cwd));
|
|
694
|
+
|
|
695
|
+
return pendingItems.length > limit ? `${preview.join(", ")}, ...` : preview.join(", ");
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
const SENSITIVE_PRIVATE_PATH_PATTERNS: Array<{ label: string; pattern: RegExp }> = [
|
|
699
|
+
{ label: "dotenv", pattern: /(^|\/)\.env(\.|$)/i },
|
|
700
|
+
{ label: "private-key", pattern: /\.(pem|key|pfx|p12|jks|keystore|ppk)$/i },
|
|
701
|
+
{ label: "ssh-key", pattern: /(^|\/)id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i },
|
|
702
|
+
{ label: "credentials-json", pattern: /(^|\/)(credentials|secrets?)\.json$/i },
|
|
703
|
+
{ label: "npmrc", pattern: /(^|\/)\.npmrc$/i },
|
|
704
|
+
];
|
|
705
|
+
|
|
706
|
+
const getSensitivePrivatePathReason = (pathValue: string): string | null =>
|
|
707
|
+
{
|
|
708
|
+
const normalizedPath = pathValue.replace(/\\/g, "/");
|
|
709
|
+
for (const definition of SENSITIVE_PRIVATE_PATH_PATTERNS)
|
|
710
|
+
{
|
|
711
|
+
if (definition.pattern.test(normalizedPath))
|
|
712
|
+
{
|
|
713
|
+
return `sensitive_path:${definition.label}`;
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return null;
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const selectPrivatePathsForAutoAdd = (
|
|
721
|
+
pendingItems: PendingItem[],
|
|
722
|
+
scopedAbsolutePaths: string[],
|
|
723
|
+
cwd: string,
|
|
724
|
+
): PrivateAutoAddSelection =>
|
|
725
|
+
{
|
|
726
|
+
const privateItems = pendingItems.filter((item) => item.kind === "private");
|
|
727
|
+
const scopedComparisonKeys = scopedAbsolutePaths.map((path) => toPathComparisonKeyFromAbsolutePath(path));
|
|
728
|
+
const scopedItems = scopedAbsolutePaths.length > 0
|
|
729
|
+
? privateItems.filter((item) => scopedComparisonKeys.some((scopePath) => isWithinPathScope(item.comparisonKey, scopePath)))
|
|
730
|
+
: privateItems;
|
|
731
|
+
|
|
732
|
+
const blockedPaths: Array<{ path: string; reason: string }> = [];
|
|
733
|
+
const candidatePaths: string[] = [];
|
|
734
|
+
|
|
735
|
+
for (const item of scopedItems)
|
|
736
|
+
{
|
|
737
|
+
const commandPath = toCommandPath(item.normalizedPath, cwd);
|
|
738
|
+
if (commandPath === ".")
|
|
739
|
+
{
|
|
740
|
+
continue;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
const sensitiveReason = getSensitivePrivatePathReason(commandPath);
|
|
744
|
+
if (sensitiveReason)
|
|
745
|
+
{
|
|
746
|
+
blockedPaths.push({
|
|
747
|
+
path: commandPath,
|
|
748
|
+
reason: sensitiveReason,
|
|
749
|
+
});
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
candidatePaths.push(commandPath);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
return {
|
|
757
|
+
candidatePaths: Array.from(new Set(candidatePaths)),
|
|
758
|
+
blockedPaths,
|
|
759
|
+
};
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const filterPendingItemsByScope = (pendingItems: PendingItem[], scopedAbsolutePaths: string[]): PendingItem[] =>
|
|
763
|
+
{
|
|
764
|
+
if (scopedAbsolutePaths.length === 0)
|
|
765
|
+
{
|
|
766
|
+
return pendingItems;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const scopedComparisonKeys = scopedAbsolutePaths.map((path) => toPathComparisonKeyFromAbsolutePath(path));
|
|
770
|
+
return pendingItems.filter((item) => scopedComparisonKeys.some((scopePath) => isWithinPathScope(item.comparisonKey, scopePath)));
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
const resolveCheckinPaths = (requestedPaths: string[], pendingItems: PendingItem[], cwd: string): ResolvedCheckinPaths =>
|
|
774
|
+
{
|
|
775
|
+
const includedAbsolutePaths: string[] = [];
|
|
776
|
+
const fallbackAbsolutePaths: string[] = [];
|
|
777
|
+
const excludedPaths: Array<{ path: string; reason: string }> = [];
|
|
778
|
+
const matchedPendingItems = new Set<string>();
|
|
779
|
+
let shouldApplyChanged = false;
|
|
780
|
+
|
|
781
|
+
for (const requestedPath of requestedPaths)
|
|
782
|
+
{
|
|
783
|
+
const requestedAbsolutePath = toNormalizedAbsolutePath(requestedPath, cwd);
|
|
784
|
+
const requestedComparisonKey = toPathComparisonKey(requestedPath, cwd);
|
|
785
|
+
const matchedItems = pendingItems.filter((item) => isWithinPathScope(item.comparisonKey, requestedComparisonKey));
|
|
786
|
+
|
|
787
|
+
if (matchedItems.length === 0)
|
|
788
|
+
{
|
|
789
|
+
excludedPaths.push({
|
|
790
|
+
path: requestedPath,
|
|
791
|
+
reason: "no_pending_changes",
|
|
792
|
+
});
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
for (const item of matchedItems)
|
|
797
|
+
{
|
|
798
|
+
matchedPendingItems.add(`${item.statusCode}:${item.comparisonKey}`);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const includesDeletedOrMoved = matchedItems.some((item) => item.kind === "deleted" || item.kind === "moved");
|
|
802
|
+
let fallbackAbsolutePath = requestedAbsolutePath;
|
|
803
|
+
if (includesDeletedOrMoved)
|
|
804
|
+
{
|
|
805
|
+
shouldApplyChanged = true;
|
|
806
|
+
const hasChildMatches = matchedItems.some((item) => item.comparisonKey.length > requestedComparisonKey.length && item.comparisonKey.startsWith(`${requestedComparisonKey}/`));
|
|
807
|
+
const hasDirectDirectoryMatch = matchedItems.some((item) => item.isDirectory && item.comparisonKey === requestedComparisonKey);
|
|
808
|
+
fallbackAbsolutePath = hasChildMatches || hasDirectDirectoryMatch
|
|
809
|
+
? requestedAbsolutePath
|
|
810
|
+
: toNormalizedAbsolutePath(dirname(requestedAbsolutePath), cwd);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
includedAbsolutePaths.push(requestedAbsolutePath);
|
|
814
|
+
fallbackAbsolutePaths.push(fallbackAbsolutePath);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const resolvedAbsolutePaths = dedupeAndMinimizeAbsolutePaths(includedAbsolutePaths);
|
|
818
|
+
const resolvedFallbackAbsolutePaths = dedupeAndMinimizeAbsolutePaths(fallbackAbsolutePaths);
|
|
819
|
+
const includedPaths = resolvedAbsolutePaths.map((path) => toCommandPath(path, cwd));
|
|
820
|
+
const fallbackPaths = resolvedFallbackAbsolutePaths.map((path) => toCommandPath(path, cwd));
|
|
821
|
+
const rewrittenPaths: string[] = [];
|
|
822
|
+
|
|
823
|
+
return {
|
|
824
|
+
requestedPaths,
|
|
825
|
+
includedPaths,
|
|
826
|
+
includedAbsolutePaths: resolvedAbsolutePaths,
|
|
827
|
+
fallbackPaths,
|
|
828
|
+
fallbackAbsolutePaths: resolvedFallbackAbsolutePaths,
|
|
829
|
+
rewrittenPaths,
|
|
830
|
+
excludedPaths,
|
|
831
|
+
shouldApplyChanged,
|
|
832
|
+
rewriteReason: shouldApplyChanged ? "Detected moved/deleted pending items; enabled --applychanged and will retry with parent-directory scope only if Plastic rejects the path-scoped command." : undefined,
|
|
833
|
+
matchedPendingCount: matchedPendingItems.size,
|
|
834
|
+
};
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
const shouldRetryCheckinWithFallbackScope = (errorMessage: string): boolean =>
|
|
838
|
+
{
|
|
839
|
+
const normalized = errorMessage.toLowerCase();
|
|
840
|
+
return normalized.includes("is not changed in current workspace")
|
|
841
|
+
|| normalized.includes("none of the provided checkin paths have pending changes")
|
|
842
|
+
|| normalized.includes("is not changed in current workspace.")
|
|
843
|
+
|| normalized.includes("none of the provided checkin paths");
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
const isNoChangesWorkspaceCheckinError = (errorMessage: string): boolean =>
|
|
847
|
+
{
|
|
848
|
+
const normalized = errorMessage.toLowerCase();
|
|
849
|
+
return normalized.includes("there are no changes in the workspace")
|
|
850
|
+
|| normalized.includes("no changes in the workspace");
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
const buildFallbackScopePaths = (absolutePaths: string[], cwd: string): string[] =>
|
|
854
|
+
{
|
|
855
|
+
const fallbackAbsolutePaths = absolutePaths.map((path) => toNormalizedAbsolutePath(dirname(path), cwd));
|
|
856
|
+
return dedupeAndMinimizeAbsolutePaths(fallbackAbsolutePaths).map((path) => toCommandPath(path, cwd));
|
|
857
|
+
};
|
|
858
|
+
|
|
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
|
+
const isRevisionNotFoundError = (message: string): boolean =>
|
|
868
|
+
{
|
|
869
|
+
const normalized = message.toLowerCase();
|
|
870
|
+
return normalized.includes("the specified revision was not found");
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
const isGlobalRevisionSpec = (revision: string): boolean =>
|
|
874
|
+
{
|
|
875
|
+
const normalized = revision.toLowerCase();
|
|
876
|
+
return normalized.startsWith("rev:")
|
|
877
|
+
|| normalized.startsWith("revid:")
|
|
878
|
+
|| normalized.startsWith("itemid:")
|
|
879
|
+
|| normalized.startsWith("serverpath:");
|
|
880
|
+
};
|
|
881
|
+
|
|
882
|
+
const isItemSelectorRevisionSpec = (revision: string): boolean =>
|
|
883
|
+
{
|
|
884
|
+
const normalized = revision.toLowerCase();
|
|
885
|
+
return /^\d+$/.test(revision)
|
|
886
|
+
|| normalized.startsWith("cs:")
|
|
887
|
+
|| normalized.startsWith("br:")
|
|
888
|
+
|| normalized.startsWith("lb:");
|
|
889
|
+
};
|
|
890
|
+
|
|
891
|
+
type DiffFileRevisionResolution = {
|
|
892
|
+
supplied: string | null;
|
|
893
|
+
kind: "workspace-base" | "changeset" | "branch" | "label" | "global-revision" | "file-qualified";
|
|
894
|
+
resolved: string;
|
|
895
|
+
};
|
|
896
|
+
|
|
897
|
+
const resolveDiffFileRevision = (pathForRevision: string, revision?: string): DiffFileRevisionResolution =>
|
|
898
|
+
{
|
|
899
|
+
const normalizedPath = pathForRevision.trim();
|
|
900
|
+
const normalizedRevision = revision?.trim() ?? "";
|
|
901
|
+
if (!normalizedPath)
|
|
902
|
+
{
|
|
903
|
+
throw new Error("path must identify a workspace file.");
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// A bare item path is the unambiguous common case: cm cat resolves the
|
|
907
|
+
// workspace's loaded/base revision without asking agents to guess a revspec.
|
|
908
|
+
if (!normalizedRevision)
|
|
909
|
+
{
|
|
910
|
+
return { supplied: null, kind: "workspace-base", resolved: normalizedPath };
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const lowerRevision = normalizedRevision.toLowerCase();
|
|
914
|
+
if (lowerRevision === "base" || lowerRevision === "head" || lowerRevision === "cs:head" || /^cs:br:/i.test(normalizedRevision))
|
|
915
|
+
{
|
|
916
|
+
throw new Error(
|
|
917
|
+
`Unsupported revision '${revision}'. Omit revision for the workspace base, or use a changeset number/cs:<number>, br:/<branch>, lb:<label>, a file-qualified '<path>#<selector>', or a global revid:/rev: spec.`,
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
if (normalizedRevision.includes("#"))
|
|
922
|
+
{
|
|
923
|
+
return { supplied: normalizedRevision, kind: "file-qualified", resolved: normalizedRevision };
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
if (isGlobalRevisionSpec(normalizedRevision))
|
|
927
|
+
{
|
|
928
|
+
return { supplied: normalizedRevision, kind: "global-revision", resolved: normalizedRevision };
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (/^\d+$/.test(normalizedRevision) || /^cs:\d+$/i.test(normalizedRevision))
|
|
932
|
+
{
|
|
933
|
+
const selector = /^\d+$/.test(normalizedRevision) ? `cs:${normalizedRevision}` : normalizedRevision;
|
|
934
|
+
return { supplied: normalizedRevision, kind: "changeset", resolved: `${normalizedPath}#${selector}` };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (/^br:\/.+/i.test(normalizedRevision))
|
|
938
|
+
{
|
|
939
|
+
return { supplied: normalizedRevision, kind: "branch", resolved: `${normalizedPath}#${normalizedRevision}` };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
if (/^lb:[^\s#]+/i.test(normalizedRevision))
|
|
943
|
+
{
|
|
944
|
+
return { supplied: normalizedRevision, kind: "label", resolved: `${normalizedPath}#${normalizedRevision}` };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
throw new Error(
|
|
948
|
+
`Unsupported revision '${revision}'. Omit revision for the workspace base, or use a changeset number/cs:<number>, br:/<branch>, lb:<label>, a file-qualified '<path>#<selector>', or a global revid:/rev: spec.`,
|
|
949
|
+
);
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
// Kept as a small compatibility seam for older focused tests and consumers.
|
|
953
|
+
const normalizeDiffFileRevisionSpec = (pathForRevision: string, revision: string): string =>
|
|
954
|
+
resolveDiffFileRevision(pathForRevision, revision).resolved;
|
|
955
|
+
|
|
956
|
+
const isValidGlobalRevisionSpec = (revision: string): boolean =>
|
|
957
|
+
/^(?:rev|revid|itemid|serverpath):\S+$/i.test(revision.trim());
|
|
958
|
+
|
|
959
|
+
const isValidFileQualifiedRevisionSpec = (revision: string): boolean =>
|
|
960
|
+
{
|
|
961
|
+
const normalized = revision.trim();
|
|
962
|
+
const separator = normalized.lastIndexOf("#");
|
|
963
|
+
if (separator <= 0 || separator === normalized.length - 1)
|
|
964
|
+
{
|
|
965
|
+
return false;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const selector = normalized.slice(separator + 1);
|
|
969
|
+
return /^cs:\d+$/i.test(selector)
|
|
970
|
+
|| /^br:\/.+/i.test(selector)
|
|
971
|
+
|| /^lb:[^\s#]+$/i.test(selector)
|
|
972
|
+
|| isValidGlobalRevisionSpec(selector);
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
const isValidDiffRevisionSpec = (revision: string): boolean =>
|
|
976
|
+
isValidGlobalRevisionSpec(revision) || isValidFileQualifiedRevisionSpec(revision);
|
|
977
|
+
|
|
978
|
+
const isUnscopedDiffRevisionSpec = (revision: string): boolean => !isValidDiffRevisionSpec(revision);
|
|
979
|
+
|
|
980
|
+
const extractBranchSelectorFromRevision = (revision: string): string | null =>
|
|
981
|
+
{
|
|
982
|
+
const match = revision.match(/(?:^|#)(br:[^\s#]+)/i);
|
|
983
|
+
if (!match)
|
|
984
|
+
{
|
|
985
|
+
return null;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
const selector = (match[1] ?? "").trim();
|
|
989
|
+
return selector.length > 0 ? selector : null;
|
|
990
|
+
};
|
|
991
|
+
|
|
992
|
+
const extractBranchNameFromSelector = (branchSelector: string): string | null =>
|
|
993
|
+
{
|
|
994
|
+
const normalized = branchSelector.trim().replace(/^br:/i, "");
|
|
995
|
+
if (!normalized)
|
|
996
|
+
{
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const atIndex = normalized.indexOf("@");
|
|
1001
|
+
const branchName = atIndex >= 0 ? normalized.slice(0, atIndex).trim() : normalized.trim();
|
|
1002
|
+
return branchName.length > 0 ? branchName : null;
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
type SwitchPendingProfile = {
|
|
1006
|
+
hasPendingChanges: boolean;
|
|
1007
|
+
hasTrackedPendingChanges: boolean;
|
|
1008
|
+
hasPrivatePendingChanges: boolean;
|
|
1009
|
+
hasPrivateOnlyPendingChanges: boolean;
|
|
1010
|
+
};
|
|
1011
|
+
|
|
1012
|
+
const toLegacyPendingSummary = (summary: PendingItemSummary): PendingSummary =>
|
|
1013
|
+
{
|
|
1014
|
+
return {
|
|
1015
|
+
totalPending: summary.totalPending,
|
|
1016
|
+
added: summary.added,
|
|
1017
|
+
changed: summary.changed,
|
|
1018
|
+
moved: summary.moved,
|
|
1019
|
+
deleted: summary.deleted,
|
|
1020
|
+
other: summary.other + summary.private,
|
|
1021
|
+
};
|
|
1022
|
+
};
|
|
1023
|
+
|
|
1024
|
+
const normalizeBranchSpecForComparison = (branch: string): string =>
|
|
1025
|
+
{
|
|
1026
|
+
const trimmed = branch.trim();
|
|
1027
|
+
const withoutPrefix = trimmed.replace(/^br:/i, "");
|
|
1028
|
+
const withoutRepository = withoutPrefix.split("@")[0] ?? withoutPrefix;
|
|
1029
|
+
return withoutRepository.replace(/\\/g, "/");
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
const isSameBranchSpec = (left: string, right: string): boolean =>
|
|
1033
|
+
{
|
|
1034
|
+
return normalizeBranchSpecForComparison(left) === normalizeBranchSpecForComparison(right);
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
const getBranchLeafName = (branch: string): string =>
|
|
1038
|
+
{
|
|
1039
|
+
const normalized = normalizeBranchSpecForComparison(branch);
|
|
1040
|
+
const segments = normalized.split("/").filter((segment) => segment.length > 0);
|
|
1041
|
+
return segments.at(-1) ?? normalized;
|
|
1042
|
+
};
|
|
1043
|
+
|
|
1044
|
+
const resolveBranchCreationTarget = (
|
|
1045
|
+
requestedBranch: string,
|
|
1046
|
+
parentBranch?: string,
|
|
1047
|
+
allowRootBranch = false,
|
|
1048
|
+
): string =>
|
|
1049
|
+
{
|
|
1050
|
+
const requested = requestedBranch.trim();
|
|
1051
|
+
const normalizedRequested = normalizeBranchSpecForComparison(requested);
|
|
1052
|
+
|
|
1053
|
+
if (normalizedRequested.length === 0)
|
|
1054
|
+
{
|
|
1055
|
+
throw new Error("Branch must be non-empty.");
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (!normalizedRequested.startsWith("/"))
|
|
1059
|
+
{
|
|
1060
|
+
if (!parentBranch)
|
|
1061
|
+
{
|
|
1062
|
+
throw new Error("A relative branch name requires a parent branch.");
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const normalizedParent = normalizeBranchSpecForComparison(parentBranch).replace(/\/$/, "");
|
|
1066
|
+
if (!normalizedParent.startsWith("/") || normalizedParent === "/")
|
|
1067
|
+
{
|
|
1068
|
+
throw new Error(`Parent branch must be a hierarchical branch path, received ${parentBranch}.`);
|
|
1069
|
+
}
|
|
1070
|
+
return `${normalizedParent}/${normalizedRequested}`;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const segments = normalizedRequested.split("/").filter((segment) => segment.length > 0);
|
|
1074
|
+
if (segments.length === 1 && !allowRootBranch)
|
|
1075
|
+
{
|
|
1076
|
+
throw new Error(
|
|
1077
|
+
`Refusing to create top-level branch ${requested}. ` +
|
|
1078
|
+
"Create <parent-branch>/<new-branch>, pass parent with a relative branch name, " +
|
|
1079
|
+
"or set allowRootBranch=true when top-level branch creation is intentional.",
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
return requested;
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
const buildSwitchPendingProfile = (summary: PendingItemSummary): SwitchPendingProfile =>
|
|
1087
|
+
{
|
|
1088
|
+
return {
|
|
1089
|
+
hasPendingChanges: summary.totalPending > 0,
|
|
1090
|
+
hasTrackedPendingChanges: summary.tracked > 0,
|
|
1091
|
+
hasPrivatePendingChanges: summary.private > 0,
|
|
1092
|
+
hasPrivateOnlyPendingChanges: summary.private > 0 && summary.tracked === 0,
|
|
1093
|
+
};
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
const isSwitchBringBlockedForUnattended = (pendingChoice: "shelve" | "bring" | "cancel", profile: SwitchPendingProfile): boolean =>
|
|
1097
|
+
{
|
|
1098
|
+
return pendingChoice === "bring" && profile.hasTrackedPendingChanges;
|
|
1099
|
+
};
|
|
1100
|
+
|
|
1101
|
+
const canSwitchDirectWithPrivateOnlyPending = (
|
|
1102
|
+
pendingChoice: "shelve" | "bring" | "cancel",
|
|
1103
|
+
defaultedPolicy: boolean,
|
|
1104
|
+
profile: SwitchPendingProfile,
|
|
1105
|
+
): boolean =>
|
|
1106
|
+
{
|
|
1107
|
+
if (!profile.hasPrivateOnlyPendingChanges)
|
|
1108
|
+
{
|
|
1109
|
+
return false;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
if (pendingChoice === "shelve" || pendingChoice === "bring")
|
|
1113
|
+
{
|
|
1114
|
+
return true;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
return defaultedPolicy;
|
|
1118
|
+
};
|
|
1119
|
+
|
|
1120
|
+
type PatchCommandArgs = {
|
|
1121
|
+
source: string;
|
|
1122
|
+
destination?: string;
|
|
1123
|
+
output?: string;
|
|
1124
|
+
toolPath?: string;
|
|
1125
|
+
clean?: boolean;
|
|
1126
|
+
integration?: boolean;
|
|
1127
|
+
};
|
|
1128
|
+
|
|
1129
|
+
const assertRequiredPatchValue = (name: keyof PatchCommandArgs, value: string | undefined): void =>
|
|
1130
|
+
{
|
|
1131
|
+
if (value === undefined || value.trim().length === 0)
|
|
1132
|
+
{
|
|
1133
|
+
throw new Error(`${name} must be non-empty.`);
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
const assertNonBlankPatchValue = (name: keyof PatchCommandArgs, value: string | undefined): void =>
|
|
1138
|
+
{
|
|
1139
|
+
if (value !== undefined && value.trim().length === 0)
|
|
1140
|
+
{
|
|
1141
|
+
throw new Error(`${name} must be non-empty when provided.`);
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
|
|
1145
|
+
const resolvePatchToolPath = (toolPath?: string): string =>
|
|
1146
|
+
{
|
|
1147
|
+
if (toolPath?.trim())
|
|
1148
|
+
{
|
|
1149
|
+
return toolPath.trim();
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// Keep cm patch on the same configurable portable diff executable used by
|
|
1153
|
+
// text-only file diffs. `diff` remains a PATH lookup on both platforms.
|
|
1154
|
+
return getDiffExecutable();
|
|
1155
|
+
};
|
|
1156
|
+
|
|
1157
|
+
const buildPatchCommandArgs = (args: PatchCommandArgs): string[] =>
|
|
1158
|
+
{
|
|
1159
|
+
assertRequiredPatchValue("source", args.source);
|
|
1160
|
+
assertNonBlankPatchValue("destination", args.destination);
|
|
1161
|
+
assertNonBlankPatchValue("output", args.output);
|
|
1162
|
+
assertNonBlankPatchValue("toolPath", args.toolPath);
|
|
1163
|
+
|
|
1164
|
+
const cmdArgs: string[] = ["patch", args.source];
|
|
1165
|
+
|
|
1166
|
+
if (args.destination)
|
|
1167
|
+
{
|
|
1168
|
+
cmdArgs.push(args.destination);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
if (args.output)
|
|
1172
|
+
{
|
|
1173
|
+
cmdArgs.push(`--output=${args.output}`);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (args.toolPath)
|
|
1177
|
+
{
|
|
1178
|
+
cmdArgs.push(`--tool=${args.toolPath}`);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
if (args.clean)
|
|
1182
|
+
{
|
|
1183
|
+
cmdArgs.push("--clean");
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
if (args.integration)
|
|
1187
|
+
{
|
|
1188
|
+
cmdArgs.push("--integration");
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
return cmdArgs;
|
|
1192
|
+
};
|
|
1193
|
+
|
|
1194
|
+
export const __plasticProcessInternals = {
|
|
1195
|
+
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
|
+
resolveDiffExecutable: (environment: ExecutableEnvironment = process.env): string => resolveExecutable(environment, "PI_PLASTIC_DIFF_EXECUTABLE", "diff"),
|
|
1198
|
+
spawnAndCollect,
|
|
1199
|
+
runCm,
|
|
1200
|
+
runCmRaw,
|
|
1201
|
+
};
|
|
1202
|
+
|
|
1203
|
+
export const __plasticPatchInternals = {
|
|
1204
|
+
buildPatchCommandArgs,
|
|
1205
|
+
resolvePatchToolPath,
|
|
1206
|
+
};
|
|
1207
|
+
|
|
1208
|
+
export const __plasticCheckinInternals = {
|
|
1209
|
+
// Optional policy injection makes path-case behavior deterministic in focused tests.
|
|
1210
|
+
toPathComparisonKeyFromAbsolutePath,
|
|
1211
|
+
isSameFilesystemDevice,
|
|
1212
|
+
inferPendingItemKind,
|
|
1213
|
+
parseMachineReadablePendingItems,
|
|
1214
|
+
summarizePendingItems,
|
|
1215
|
+
getSensitivePrivatePathReason,
|
|
1216
|
+
selectPrivatePathsForAutoAdd,
|
|
1217
|
+
filterPendingItemsByScope,
|
|
1218
|
+
resolveCheckinPaths,
|
|
1219
|
+
buildFallbackScopePaths,
|
|
1220
|
+
isNoChangesWorkspaceCheckinError,
|
|
1221
|
+
isGitUnknownOptionLabelError,
|
|
1222
|
+
isRevisionNotFoundError,
|
|
1223
|
+
normalizeDiffFileRevisionSpec,
|
|
1224
|
+
resolveDiffFileRevision,
|
|
1225
|
+
isUnscopedDiffRevisionSpec,
|
|
1226
|
+
extractBranchSelectorFromRevision,
|
|
1227
|
+
extractBranchNameFromSelector,
|
|
1228
|
+
};
|
|
1229
|
+
|
|
1230
|
+
export const __plasticSwitchInternals = {
|
|
1231
|
+
toLegacyPendingSummary,
|
|
1232
|
+
normalizeBranchSpecForComparison,
|
|
1233
|
+
isSameBranchSpec,
|
|
1234
|
+
buildSwitchPendingProfile,
|
|
1235
|
+
isSwitchBringBlockedForUnattended,
|
|
1236
|
+
canSwitchDirectWithPrivateOnlyPending,
|
|
1237
|
+
};
|
|
1238
|
+
|
|
1239
|
+
export const __plasticBranchInternals = {
|
|
1240
|
+
getBranchLeafName,
|
|
1241
|
+
parsePlasticStatusBranch,
|
|
1242
|
+
resolveBranchCreationTarget,
|
|
1243
|
+
};
|
|
1244
|
+
|
|
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
|
+
const DIFF_OUTPUT_MAX_CHARS = 60_000;
|
|
1323
|
+
|
|
1324
|
+
type TextDiffResult = {
|
|
1325
|
+
backend: "diff";
|
|
1326
|
+
changed: boolean;
|
|
1327
|
+
binary: boolean;
|
|
1328
|
+
output: string;
|
|
1329
|
+
truncated: boolean;
|
|
1330
|
+
totalChars: number;
|
|
1331
|
+
};
|
|
1332
|
+
|
|
1333
|
+
// Do not classify serialized Unity YAML by its extension or importer metadata:
|
|
1334
|
+
// valid UTF-8 YAML is text, while NUL-containing or invalid UTF-8 bytes are binary.
|
|
1335
|
+
const isBinaryContent = (content: Buffer): boolean => content.includes(0) || !isUtf8(content);
|
|
1336
|
+
|
|
1337
|
+
const stableDiffHeaders = (output: string, leftLabel: string, rightLabel: string): string =>
|
|
1338
|
+
{
|
|
1339
|
+
const lines = output.split(/\r?\n/);
|
|
1340
|
+
// Both GNU and BSD `diff -u` put the source paths in the first two header
|
|
1341
|
+
// lines. Replacing only those lines keeps hunk content verbatim while never
|
|
1342
|
+
// leaking package-owned temporary paths or host-specific timestamps.
|
|
1343
|
+
if (lines[0]?.startsWith("--- "))
|
|
1344
|
+
{
|
|
1345
|
+
lines[0] = `--- ${leftLabel}`;
|
|
1346
|
+
}
|
|
1347
|
+
if (lines[1]?.startsWith("+++ "))
|
|
1348
|
+
{
|
|
1349
|
+
lines[1] = `+++ ${rightLabel}`;
|
|
1350
|
+
}
|
|
1351
|
+
return lines.join("\n").replace(/\n+$/, "");
|
|
1352
|
+
};
|
|
1353
|
+
|
|
1354
|
+
const boundDiffOutput = (output: string): Pick<TextDiffResult, "output" | "truncated" | "totalChars"> =>
|
|
1355
|
+
{
|
|
1356
|
+
const totalChars = output.length;
|
|
1357
|
+
if (totalChars <= DIFF_OUTPUT_MAX_CHARS)
|
|
1358
|
+
{
|
|
1359
|
+
return { output, truncated: false, totalChars };
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
return {
|
|
1363
|
+
output: `${output.slice(0, DIFF_OUTPUT_MAX_CHARS)}\n\n[Diff output truncated at ${DIFF_OUTPUT_MAX_CHARS} characters; inspect a narrower file or use a review patch for the complete change.]`,
|
|
1364
|
+
truncated: true,
|
|
1365
|
+
totalChars,
|
|
1366
|
+
};
|
|
1367
|
+
};
|
|
1368
|
+
|
|
1369
|
+
const runPortableTextDiff = async (
|
|
1370
|
+
leftPath: string,
|
|
1371
|
+
rightPath: string,
|
|
1372
|
+
cwd: string,
|
|
1373
|
+
leftLabel: string,
|
|
1374
|
+
rightLabel: string,
|
|
1375
|
+
): Promise<TextDiffResult> =>
|
|
1376
|
+
{
|
|
1377
|
+
const [leftContent, rightContent] = await Promise.all([fs.readFile(leftPath), fs.readFile(rightPath)]);
|
|
1378
|
+
if (isBinaryContent(leftContent) || isBinaryContent(rightContent))
|
|
1379
|
+
{
|
|
1380
|
+
return { backend: "diff", changed: !leftContent.equals(rightContent), binary: true, output: "", truncated: false, totalChars: 0 };
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
const { stdout, stderr, exitCode, aborted, stdoutTruncated, stdoutTotalChars } = await spawnAndCollect(
|
|
1384
|
+
getDiffExecutable(),
|
|
1385
|
+
["-u", leftPath, rightPath],
|
|
1386
|
+
cwd,
|
|
1387
|
+
undefined,
|
|
1388
|
+
undefined,
|
|
1389
|
+
{ outputLimitChars: DIFF_OUTPUT_MAX_CHARS },
|
|
1390
|
+
);
|
|
1391
|
+
if (aborted)
|
|
1392
|
+
{
|
|
1393
|
+
throw new Error("Text diff was aborted.");
|
|
1394
|
+
}
|
|
1395
|
+
if (exitCode > 1)
|
|
1396
|
+
{
|
|
1397
|
+
const diagnostic = [stdout, stderr].filter(Boolean).join("\n").trim();
|
|
1398
|
+
const sanitized = diagnostic
|
|
1399
|
+
.split(leftPath).join(leftLabel)
|
|
1400
|
+
.split(rightPath).join(rightLabel);
|
|
1401
|
+
throw new Error(sanitized || `Text diff failed with exit code ${exitCode}.`);
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
const normalized = stableDiffHeaders(stdout, leftLabel, rightLabel);
|
|
1405
|
+
const bounded = stdoutTruncated
|
|
1406
|
+
? {
|
|
1407
|
+
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.]`,
|
|
1408
|
+
truncated: true,
|
|
1409
|
+
totalChars: stdoutTotalChars ?? normalized.length,
|
|
1410
|
+
}
|
|
1411
|
+
: boundDiffOutput(normalized);
|
|
1412
|
+
return { backend: "diff", changed: exitCode === 1, binary: false, ...bounded };
|
|
1413
|
+
};
|
|
1414
|
+
|
|
1415
|
+
const safeTempExtension = (pathValue: string): string =>
|
|
1416
|
+
{
|
|
1417
|
+
const extension = extname(pathValue);
|
|
1418
|
+
return /^\.[A-Za-z0-9]{1,12}$/.test(extension) ? extension : ".tmp";
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
const materializeRevision = async (revision: string, destination: string, workdir?: string): Promise<void> =>
|
|
1422
|
+
{
|
|
1423
|
+
// --file keeps historical bytes out of the decoded stdout path. This is
|
|
1424
|
+
// required for both reliable binary detection and Unity YAML text handling.
|
|
1425
|
+
try
|
|
1426
|
+
{
|
|
1427
|
+
await runCm(["cat", revision, `--file=${destination}`], workdir);
|
|
1428
|
+
}
|
|
1429
|
+
catch (error)
|
|
1430
|
+
{
|
|
1431
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1432
|
+
throw new Error(message.split(destination).join("<package-owned-temp-file>"));
|
|
1433
|
+
}
|
|
1434
|
+
const destinationStat = await fs.stat(destination).catch(() => null);
|
|
1435
|
+
if (!destinationStat?.isFile())
|
|
1436
|
+
{
|
|
1437
|
+
throw new Error("Plastic did not materialize the requested historical file content.");
|
|
1438
|
+
}
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
const formatTextDiff = async (
|
|
1442
|
+
action: "diffFile" | "diffRevisions",
|
|
1443
|
+
format: OutputFormat | undefined,
|
|
1444
|
+
comparisonKind: string,
|
|
1445
|
+
result: TextDiffResult,
|
|
1446
|
+
workdir: string | undefined,
|
|
1447
|
+
): Promise<string> =>
|
|
1448
|
+
{
|
|
1449
|
+
const status = result.binary ? (result.changed ? "binary-different" : "unchanged") : (result.changed ? "changed" : "unchanged");
|
|
1450
|
+
const text = result.binary
|
|
1451
|
+
? (result.changed ? "Binary content differs; a text diff is unavailable." : "No differences.")
|
|
1452
|
+
: (result.changed ? result.output : "No differences.");
|
|
1453
|
+
return toStructuredResult(
|
|
1454
|
+
action,
|
|
1455
|
+
format ?? "text",
|
|
1456
|
+
text,
|
|
1457
|
+
{
|
|
1458
|
+
comparisonKind,
|
|
1459
|
+
backend: result.backend,
|
|
1460
|
+
status,
|
|
1461
|
+
changed: result.changed,
|
|
1462
|
+
binary: result.binary,
|
|
1463
|
+
truncated: result.truncated,
|
|
1464
|
+
totalChars: result.totalChars,
|
|
1465
|
+
diff: result.changed && !result.binary ? result.output : undefined,
|
|
1466
|
+
},
|
|
1467
|
+
workdir,
|
|
1468
|
+
result.truncated ? ["Diff output was truncated; use a narrower file or generate a review patch for the complete result."] : undefined,
|
|
1469
|
+
);
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
export const __plasticDiffInternals = {
|
|
1473
|
+
DIFF_OUTPUT_MAX_CHARS,
|
|
1474
|
+
isBinaryContent,
|
|
1475
|
+
stableDiffHeaders,
|
|
1476
|
+
boundDiffOutput,
|
|
1477
|
+
runPortableTextDiff,
|
|
1478
|
+
safeTempExtension,
|
|
1479
|
+
materializeRevision,
|
|
1480
|
+
resolveDiffFileRevision,
|
|
1481
|
+
isUnscopedDiffRevisionSpec,
|
|
1482
|
+
};
|
|
1483
|
+
|
|
1484
|
+
const workdirArg = tool.schema.string().optional().describe("Working directory for the workspace.");
|
|
1485
|
+
|
|
1486
|
+
const escapeCmWhereValue = (value: string): string => value.replace(/\\/g, "\\\\").replace(/'/g, "''");
|
|
1487
|
+
const cmWhereEquals = (field: string, value: string): string => `${field} = '${escapeCmWhereValue(value)}'`;
|
|
1488
|
+
const cmWhereLike = (field: string, value: string): string => `${field} like '${escapeCmWhereValue(value)}'`;
|
|
1489
|
+
const normalizeFindOutputLines = (output: string): string[] => output
|
|
1490
|
+
.split(/\r?\n/)
|
|
1491
|
+
.map((line) => line.trim())
|
|
1492
|
+
.filter((line) => line.length > 0 && line !== "(no output)");
|
|
1493
|
+
|
|
1494
|
+
const listRecentBranchNames = async (workdir?: string, limit = 200): Promise<string[]> =>
|
|
1495
|
+
{
|
|
1496
|
+
const output = await runCmRaw([
|
|
1497
|
+
"find",
|
|
1498
|
+
"branch",
|
|
1499
|
+
`order by date desc limit ${limit}`,
|
|
1500
|
+
"--format={name}",
|
|
1501
|
+
"--nototal",
|
|
1502
|
+
], workdir);
|
|
1503
|
+
return normalizeFindOutputLines(output);
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
const buildRevisionNotFoundGuidance = async (resolvedRevision: string, path: string, workdir?: string): Promise<string | null> =>
|
|
1507
|
+
{
|
|
1508
|
+
const branchSelector = extractBranchSelectorFromRevision(resolvedRevision);
|
|
1509
|
+
if (!branchSelector)
|
|
1510
|
+
{
|
|
1511
|
+
return null;
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
const branchName = extractBranchNameFromSelector(branchSelector);
|
|
1515
|
+
if (!branchName)
|
|
1516
|
+
{
|
|
1517
|
+
return null;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
try
|
|
1521
|
+
{
|
|
1522
|
+
const recentBranchNames = await listRecentBranchNames(workdir, 300);
|
|
1523
|
+
const exactMatchExists = recentBranchNames.includes(branchName);
|
|
1524
|
+
if (exactMatchExists)
|
|
1525
|
+
{
|
|
1526
|
+
return `Branch '${branchName}' exists, but '${path}' was not found at '${branchSelector}'. The file likely does not exist on that branch. Use a branch or changeset where the file exists, following the current repository's branch and changeset conventions.`;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
const childMatches = recentBranchNames
|
|
1530
|
+
.filter((name) => name.startsWith(`${branchName}/`))
|
|
1531
|
+
.slice(0, 5);
|
|
1532
|
+
if (childMatches.length > 0)
|
|
1533
|
+
{
|
|
1534
|
+
const suggestions = childMatches.map((name) => `br:${name}`).join(", ");
|
|
1535
|
+
return `Branch '${branchName}' was not found. Did you mean one of: ${suggestions}?`;
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
return `Branch '${branchName}' was not found in the recent branch list. Use 'cm find branch "order by date desc limit 200" --format="{name}" --nototal' to discover valid branch specs.`;
|
|
1539
|
+
}
|
|
1540
|
+
catch
|
|
1541
|
+
{
|
|
1542
|
+
return `Revision '${branchSelector}' could not be resolved for '${path}'. Use a concrete file-qualified revision such as '<workspace-path>#<revision-spec>'.`;
|
|
1543
|
+
}
|
|
1544
|
+
};
|
|
1545
|
+
|
|
1546
|
+
const TOOL_VERSION = "v2.0.0";
|
|
1547
|
+
type OutputFormat = "text" | "json";
|
|
1548
|
+
const outputFormatArg = tool.schema.enum(["text", "json"]).optional().describe("Output format. Defaults to text.");
|
|
1549
|
+
|
|
1550
|
+
let cachedCmVersion: string | null = null;
|
|
1551
|
+
let cmVersionPromise: Promise<string> | null = null;
|
|
1552
|
+
|
|
1553
|
+
const getCmVersion = async (workdir?: string): Promise<string> =>
|
|
1554
|
+
{
|
|
1555
|
+
if (cachedCmVersion)
|
|
1556
|
+
{
|
|
1557
|
+
return cachedCmVersion;
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
if (!cmVersionPromise)
|
|
1561
|
+
{
|
|
1562
|
+
cmVersionPromise = runCmRaw(["version"], workdir)
|
|
1563
|
+
.then((value) =>
|
|
1564
|
+
{
|
|
1565
|
+
const parsed = normalizeFindOutputLines(value)[0] ?? "unknown";
|
|
1566
|
+
cachedCmVersion = parsed;
|
|
1567
|
+
cmVersionPromise = null;
|
|
1568
|
+
return parsed;
|
|
1569
|
+
})
|
|
1570
|
+
.catch(() =>
|
|
1571
|
+
{
|
|
1572
|
+
cmVersionPromise = null;
|
|
1573
|
+
return "unknown";
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
return cmVersionPromise;
|
|
1578
|
+
};
|
|
1579
|
+
|
|
1580
|
+
type PendingSummary = {
|
|
1581
|
+
totalPending: number;
|
|
1582
|
+
added: number;
|
|
1583
|
+
changed: number;
|
|
1584
|
+
moved: number;
|
|
1585
|
+
deleted: number;
|
|
1586
|
+
other: number;
|
|
1587
|
+
};
|
|
1588
|
+
|
|
1589
|
+
type MergeStatusSummary = {
|
|
1590
|
+
hasPendingMergeLinks: boolean;
|
|
1591
|
+
pendingMergeLinks: string[];
|
|
1592
|
+
mergeInProgressHints: string[];
|
|
1593
|
+
hasMergeInProgress: boolean;
|
|
1594
|
+
};
|
|
1595
|
+
|
|
1596
|
+
const summarizeShortStatus = (output: string): PendingSummary =>
|
|
1597
|
+
{
|
|
1598
|
+
const lines = normalizeFindOutputLines(output);
|
|
1599
|
+
const summary: PendingSummary = {
|
|
1600
|
+
totalPending: lines.length,
|
|
1601
|
+
added: 0,
|
|
1602
|
+
changed: 0,
|
|
1603
|
+
moved: 0,
|
|
1604
|
+
deleted: 0,
|
|
1605
|
+
other: 0,
|
|
1606
|
+
};
|
|
1607
|
+
|
|
1608
|
+
for (const line of lines)
|
|
1609
|
+
{
|
|
1610
|
+
const normalized = line.toLowerCase();
|
|
1611
|
+
if (normalized.includes("added") || /^a\s/.test(normalized))
|
|
1612
|
+
{
|
|
1613
|
+
summary.added += 1;
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
if (normalized.includes("changed") || normalized.includes("modified") || /^c\s/.test(normalized) || /^m\s/.test(normalized))
|
|
1618
|
+
{
|
|
1619
|
+
summary.changed += 1;
|
|
1620
|
+
continue;
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
if (normalized.includes("moved") || normalized.includes("renamed"))
|
|
1624
|
+
{
|
|
1625
|
+
summary.moved += 1;
|
|
1626
|
+
continue;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
if (normalized.includes("deleted") || /^d\s/.test(normalized))
|
|
1630
|
+
{
|
|
1631
|
+
summary.deleted += 1;
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
summary.other += 1;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
return summary;
|
|
1639
|
+
};
|
|
1640
|
+
|
|
1641
|
+
const analyzeMergeStatusOutput = (output: string): MergeStatusSummary =>
|
|
1642
|
+
{
|
|
1643
|
+
const lines = normalizeFindOutputLines(output);
|
|
1644
|
+
const pendingMergeLinks: string[] = [];
|
|
1645
|
+
const mergeInProgressHints: string[] = [];
|
|
1646
|
+
let inPendingMergeLinksSection = false;
|
|
1647
|
+
|
|
1648
|
+
for (const line of lines)
|
|
1649
|
+
{
|
|
1650
|
+
const trimmed = line.trim();
|
|
1651
|
+
const normalized = trimmed.toLowerCase();
|
|
1652
|
+
|
|
1653
|
+
if (normalized === "pending merge links")
|
|
1654
|
+
{
|
|
1655
|
+
inPendingMergeLinksSection = true;
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
if (inPendingMergeLinksSection)
|
|
1660
|
+
{
|
|
1661
|
+
if (trimmed.length === 0)
|
|
1662
|
+
{
|
|
1663
|
+
continue;
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
if (/^(changed|added|deleted|moved|private|controlled changes|items changed|local changes)\b/i.test(trimmed))
|
|
1667
|
+
{
|
|
1668
|
+
inPendingMergeLinksSection = false;
|
|
1669
|
+
}
|
|
1670
|
+
else if (/^merge\s+from\b/i.test(trimmed))
|
|
1671
|
+
{
|
|
1672
|
+
pendingMergeLinks.push(trimmed);
|
|
1673
|
+
continue;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
if (/merge\s+in\s+progress|finish\s+it\s+before\s+checkin|unresolved\s+merge|pending\s+conflicts/i.test(trimmed))
|
|
1678
|
+
{
|
|
1679
|
+
mergeInProgressHints.push(trimmed);
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
return {
|
|
1684
|
+
hasPendingMergeLinks: pendingMergeLinks.length > 0,
|
|
1685
|
+
pendingMergeLinks,
|
|
1686
|
+
mergeInProgressHints,
|
|
1687
|
+
hasMergeInProgress: mergeInProgressHints.length > 0,
|
|
1688
|
+
};
|
|
1689
|
+
};
|
|
1690
|
+
|
|
1691
|
+
const formatPreflightText = (title: string, lines: string[]): string =>
|
|
1692
|
+
{
|
|
1693
|
+
return [title, "", ...lines].join("\n");
|
|
1694
|
+
};
|
|
1695
|
+
|
|
1696
|
+
const toStructuredResult = async (
|
|
1697
|
+
action: string,
|
|
1698
|
+
format: OutputFormat,
|
|
1699
|
+
text: string,
|
|
1700
|
+
data: Record<string, unknown>,
|
|
1701
|
+
workdir?: string,
|
|
1702
|
+
warnings?: string[],
|
|
1703
|
+
nextSuggestedAction?: string,
|
|
1704
|
+
): Promise<string> =>
|
|
1705
|
+
{
|
|
1706
|
+
if (format !== "json")
|
|
1707
|
+
{
|
|
1708
|
+
return text;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
const payload: Record<string, unknown> = {
|
|
1712
|
+
ok: true,
|
|
1713
|
+
action,
|
|
1714
|
+
toolVersion: TOOL_VERSION,
|
|
1715
|
+
cliVersion: await getCmVersion(workdir),
|
|
1716
|
+
data,
|
|
1717
|
+
};
|
|
1718
|
+
|
|
1719
|
+
if (warnings && warnings.length > 0)
|
|
1720
|
+
{
|
|
1721
|
+
payload.warnings = warnings;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
if (nextSuggestedAction)
|
|
1725
|
+
{
|
|
1726
|
+
payload.nextSuggestedAction = nextSuggestedAction;
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
return `## ${action}\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
|
|
1730
|
+
};
|
|
1731
|
+
|
|
1732
|
+
type MergeConflictStrategy = "auto" | "source" | "destination";
|
|
1733
|
+
|
|
1734
|
+
type MergeMachineReadableRecord = {
|
|
1735
|
+
raw: string;
|
|
1736
|
+
fields: string[];
|
|
1737
|
+
operation: string;
|
|
1738
|
+
};
|
|
1739
|
+
|
|
1740
|
+
type MergeOutputSummary = {
|
|
1741
|
+
recordCount: number;
|
|
1742
|
+
conflictSignals: string[];
|
|
1743
|
+
unresolvedSignals: string[];
|
|
1744
|
+
warningSignals: string[];
|
|
1745
|
+
fileConflictSignals: string[];
|
|
1746
|
+
fileConflictPaths: string[];
|
|
1747
|
+
};
|
|
1748
|
+
|
|
1749
|
+
const MERGE_START_LINE_SEPARATOR = "__OC_MR_START__";
|
|
1750
|
+
const MERGE_END_LINE_SEPARATOR = "__OC_MR_END__";
|
|
1751
|
+
const MERGE_FIELD_SEPARATOR = "__OC_MR_FIELD__";
|
|
1752
|
+
|
|
1753
|
+
const mergeConflictSignalPattern = /\b(conflict|eviltwin|changedelete|deletechange|movedelete|deletemove|loadedtwice|addmove|moveadd|divergentmove|cyclemove|movedeviltwin)\b/i;
|
|
1754
|
+
const mergeUnresolvedSignalPattern = /\b(unresolved|cannot\s+resolve|can't\s+resolve|manual\s+conflict|not\s+solved|failed\s+to\s+resolve)\b/i;
|
|
1755
|
+
const mergeWarningSignalPattern = /\bwarn(?:ing)?\b/i;
|
|
1756
|
+
const mergeFileConflictOperation = "FILE_CONFLICT";
|
|
1757
|
+
|
|
1758
|
+
const escapeRegex = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1759
|
+
|
|
1760
|
+
const normalizeErrorMessage = (error: unknown): string =>
|
|
1761
|
+
{
|
|
1762
|
+
if (error instanceof Error)
|
|
1763
|
+
{
|
|
1764
|
+
return error.message;
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
return String(error ?? "Unknown error");
|
|
1768
|
+
};
|
|
1769
|
+
|
|
1770
|
+
const parseMergeMachineReadableRecords = (output: string): MergeMachineReadableRecord[] =>
|
|
1771
|
+
{
|
|
1772
|
+
const pattern = new RegExp(`${escapeRegex(MERGE_START_LINE_SEPARATOR)}([\\s\\S]*?)${escapeRegex(MERGE_END_LINE_SEPARATOR)}`, "g");
|
|
1773
|
+
const records: MergeMachineReadableRecord[] = [];
|
|
1774
|
+
|
|
1775
|
+
for (const match of output.matchAll(pattern))
|
|
1776
|
+
{
|
|
1777
|
+
const raw = String(match[1] ?? "").trim();
|
|
1778
|
+
if (!raw)
|
|
1779
|
+
{
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
const fields = raw
|
|
1784
|
+
.split(MERGE_FIELD_SEPARATOR)
|
|
1785
|
+
.map((field) => field.trim())
|
|
1786
|
+
.filter((field) => field.length > 0);
|
|
1787
|
+
const operation = fields[0] ?? "";
|
|
1788
|
+
records.push({
|
|
1789
|
+
raw,
|
|
1790
|
+
fields,
|
|
1791
|
+
operation,
|
|
1792
|
+
});
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
return records;
|
|
1796
|
+
};
|
|
1797
|
+
|
|
1798
|
+
const summarizeMergeOutput = (output: string): MergeOutputSummary =>
|
|
1799
|
+
{
|
|
1800
|
+
const records = parseMergeMachineReadableRecords(output);
|
|
1801
|
+
const textLines = normalizeFindOutputLines(output);
|
|
1802
|
+
const conflictSignals = new Set<string>();
|
|
1803
|
+
const unresolvedSignals = new Set<string>();
|
|
1804
|
+
const warningSignals = new Set<string>();
|
|
1805
|
+
const fileConflictSignals = new Set<string>();
|
|
1806
|
+
const fileConflictPaths = new Set<string>();
|
|
1807
|
+
|
|
1808
|
+
for (const record of records)
|
|
1809
|
+
{
|
|
1810
|
+
const mergedText = record.fields.join(" ");
|
|
1811
|
+
const isFileConflict = record.operation.toUpperCase() === mergeFileConflictOperation;
|
|
1812
|
+
|
|
1813
|
+
if (isFileConflict)
|
|
1814
|
+
{
|
|
1815
|
+
conflictSignals.add(record.raw);
|
|
1816
|
+
unresolvedSignals.add(record.raw);
|
|
1817
|
+
fileConflictSignals.add(record.raw);
|
|
1818
|
+
if (record.fields[1])
|
|
1819
|
+
{
|
|
1820
|
+
fileConflictPaths.add(record.fields[1]);
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
else if (mergeConflictSignalPattern.test(mergedText))
|
|
1824
|
+
{
|
|
1825
|
+
conflictSignals.add(record.raw);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
if (mergeUnresolvedSignalPattern.test(mergedText))
|
|
1829
|
+
{
|
|
1830
|
+
unresolvedSignals.add(record.raw);
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
if (record.operation.toUpperCase().includes("WARN") || mergeWarningSignalPattern.test(mergedText))
|
|
1834
|
+
{
|
|
1835
|
+
warningSignals.add(record.raw);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
for (const line of textLines)
|
|
1840
|
+
{
|
|
1841
|
+
if (/\bFILE_CONFLICT\b/i.test(line))
|
|
1842
|
+
{
|
|
1843
|
+
conflictSignals.add(line);
|
|
1844
|
+
unresolvedSignals.add(line);
|
|
1845
|
+
fileConflictSignals.add(line);
|
|
1846
|
+
}
|
|
1847
|
+
else if (mergeConflictSignalPattern.test(line))
|
|
1848
|
+
{
|
|
1849
|
+
conflictSignals.add(line);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
if (mergeUnresolvedSignalPattern.test(line))
|
|
1853
|
+
{
|
|
1854
|
+
unresolvedSignals.add(line);
|
|
1855
|
+
}
|
|
1856
|
+
|
|
1857
|
+
if (/DIS_OP_WARN|\bwarn(?:ing)?\b/i.test(line))
|
|
1858
|
+
{
|
|
1859
|
+
warningSignals.add(line);
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
return {
|
|
1864
|
+
recordCount: records.length,
|
|
1865
|
+
conflictSignals: Array.from(conflictSignals),
|
|
1866
|
+
unresolvedSignals: Array.from(unresolvedSignals),
|
|
1867
|
+
warningSignals: Array.from(warningSignals),
|
|
1868
|
+
fileConflictSignals: Array.from(fileConflictSignals),
|
|
1869
|
+
fileConflictPaths: Array.from(fileConflictPaths),
|
|
1870
|
+
};
|
|
1871
|
+
};
|
|
1872
|
+
|
|
1873
|
+
const buildMergeAbortMessage = (
|
|
1874
|
+
source: string,
|
|
1875
|
+
strategy: MergeConflictStrategy,
|
|
1876
|
+
output: string,
|
|
1877
|
+
summary: MergeOutputSummary,
|
|
1878
|
+
): string =>
|
|
1879
|
+
{
|
|
1880
|
+
const lines: string[] = [
|
|
1881
|
+
"Merge requires resolution/finalization before checkin.",
|
|
1882
|
+
`Source: ${source}`,
|
|
1883
|
+
`Strategy: ${strategy}`,
|
|
1884
|
+
`Conflict signals: ${summary.conflictSignals.length}`,
|
|
1885
|
+
`File conflicts: ${summary.fileConflictPaths.length}`,
|
|
1886
|
+
`Unresolved signals: ${summary.unresolvedSignals.length}`,
|
|
1887
|
+
];
|
|
1888
|
+
|
|
1889
|
+
if (summary.fileConflictPaths.length > 0)
|
|
1890
|
+
{
|
|
1891
|
+
lines.push("File conflict paths:");
|
|
1892
|
+
for (const conflictPath of summary.fileConflictPaths.slice(0, 20))
|
|
1893
|
+
{
|
|
1894
|
+
lines.push(`- ${conflictPath}`);
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
if (summary.conflictSignals.length > 0)
|
|
1899
|
+
{
|
|
1900
|
+
lines.push("Conflict details:");
|
|
1901
|
+
for (const signal of summary.conflictSignals.slice(0, 20))
|
|
1902
|
+
{
|
|
1903
|
+
lines.push(`- ${signal}`);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
if (summary.unresolvedSignals.length > 0)
|
|
1908
|
+
{
|
|
1909
|
+
lines.push("Unresolved details:");
|
|
1910
|
+
for (const signal of summary.unresolvedSignals.slice(0, 20))
|
|
1911
|
+
{
|
|
1912
|
+
lines.push(`- ${signal}`);
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
if (output.trim().length > 0)
|
|
1917
|
+
{
|
|
1918
|
+
lines.push("Raw merge output:");
|
|
1919
|
+
lines.push(output.trim());
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
lines.push("Review and resolve the listed files, run validation, then run plastic_finalizeMerge(source=..., strategy=destination) or rerun plastic_merge(...) with an explicit source/destination strategy when that policy is intentional.");
|
|
1923
|
+
return lines.join("\n");
|
|
1924
|
+
};
|
|
1925
|
+
|
|
1926
|
+
const isMergeInProgressCheckinError = (message: string): boolean =>
|
|
1927
|
+
{
|
|
1928
|
+
return /checkin operation cannot be started because there is a merge in progress|finish it before checkin|in progress merge/i.test(message);
|
|
1929
|
+
};
|
|
1930
|
+
|
|
1931
|
+
const buildMergeInProgressCheckinMessage = async (originalMessage: string, workdir?: string): Promise<string> =>
|
|
1932
|
+
{
|
|
1933
|
+
const statusOutput = await runCmRaw(["status"], workdir).catch(() => "");
|
|
1934
|
+
const mergeState = analyzeMergeStatusOutput(statusOutput);
|
|
1935
|
+
const lines: string[] = [
|
|
1936
|
+
"Checkin blocked: Plastic still has a merge in progress.",
|
|
1937
|
+
originalMessage.trim(),
|
|
1938
|
+
];
|
|
1939
|
+
|
|
1940
|
+
if (mergeState.pendingMergeLinks.length > 0)
|
|
1941
|
+
{
|
|
1942
|
+
lines.push("", "Pending merge links:");
|
|
1943
|
+
for (const link of mergeState.pendingMergeLinks.slice(0, 10))
|
|
1944
|
+
{
|
|
1945
|
+
lines.push(`- ${link}`);
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
lines.push(
|
|
1950
|
+
"",
|
|
1951
|
+
"Next steps:",
|
|
1952
|
+
"- If files still need manual work, resolve and validate them first.",
|
|
1953
|
+
"- If files are already resolved and validated, run plastic_finalizeMerge(source=<original source>, strategy=destination) to finalize Plastic merge metadata, then retry plastic_checkin.",
|
|
1954
|
+
"- Use strategy=source/destination only when that conflict policy is intentional.",
|
|
1955
|
+
);
|
|
1956
|
+
|
|
1957
|
+
return lines.join("\n");
|
|
1958
|
+
};
|
|
1959
|
+
|
|
1960
|
+
const resolveBranchParentName = async (branch: string, workdir?: string): Promise<string | undefined> =>
|
|
1961
|
+
{
|
|
1962
|
+
const normalizedBranch = normalizeBranchSpecForComparison(branch);
|
|
1963
|
+
const candidates = Array.from(new Set([branch.trim(), normalizedBranch, `br:${normalizedBranch}`]
|
|
1964
|
+
.filter((candidate) => candidate.length > 0)));
|
|
1965
|
+
|
|
1966
|
+
for (const candidate of candidates)
|
|
1967
|
+
{
|
|
1968
|
+
const output = await runCmRaw([
|
|
1969
|
+
"find",
|
|
1970
|
+
"branch",
|
|
1971
|
+
`where ${cmWhereEquals("name", candidate)}`,
|
|
1972
|
+
"--format={parent}",
|
|
1973
|
+
"--nototal",
|
|
1974
|
+
], workdir).catch(() => "");
|
|
1975
|
+
const lines = normalizeFindOutputLines(output);
|
|
1976
|
+
const parent = lines[0]?.trim();
|
|
1977
|
+
if (parent)
|
|
1978
|
+
{
|
|
1979
|
+
return parent;
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
return undefined;
|
|
1984
|
+
};
|
|
1985
|
+
|
|
1986
|
+
const resolveCurrentBranchName = async (workdir?: string): Promise<string> =>
|
|
1987
|
+
{
|
|
1988
|
+
const statusOutput = await runCmRaw(["status"], workdir);
|
|
1989
|
+
const statusBranch = parsePlasticStatusBranch(statusOutput);
|
|
1990
|
+
if (statusBranch.kind === "found" && statusBranch.value.branch)
|
|
1991
|
+
{
|
|
1992
|
+
return statusBranch.value.branch;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const compactOutput = await runCmRaw(["status", "--compact"], workdir);
|
|
1996
|
+
const compactBranch = parsePlasticStatusBranch(compactOutput);
|
|
1997
|
+
if (compactBranch.kind === "found" && compactBranch.value.branch)
|
|
1998
|
+
{
|
|
1999
|
+
return compactBranch.value.branch;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
if (compactBranch.kind === "found" && compactBranch.value.changesetId)
|
|
2003
|
+
{
|
|
2004
|
+
const changesetBranchOutput = await runCmRaw([
|
|
2005
|
+
"find",
|
|
2006
|
+
"changeset",
|
|
2007
|
+
`where changesetid=${compactBranch.value.changesetId}`,
|
|
2008
|
+
"--format={branch}",
|
|
2009
|
+
"--nototal",
|
|
2010
|
+
], workdir);
|
|
2011
|
+
const branchLines = normalizeFindOutputLines(changesetBranchOutput);
|
|
2012
|
+
if (branchLines.length > 0)
|
|
2013
|
+
{
|
|
2014
|
+
return branchLines[0];
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
const trimmed = compactOutput.trim();
|
|
2019
|
+
if (trimmed.length === 0)
|
|
2020
|
+
{
|
|
2021
|
+
throw new Error("Unable to determine current branch from empty status output.");
|
|
2022
|
+
}
|
|
2023
|
+
|
|
2024
|
+
throw new Error(`Unable to parse current branch from status output: ${trimmed}`);
|
|
2025
|
+
};
|
|
2026
|
+
|
|
2027
|
+
export const status = tool({
|
|
2028
|
+
description: "Show Plastic SCM workspace status (cm status).",
|
|
2029
|
+
args: {
|
|
2030
|
+
workdir: workdirArg,
|
|
2031
|
+
includeRevId: tool.schema.boolean().optional().describe("Include revision IDs in the status output when supported."),
|
|
2032
|
+
machineReadable: tool.schema.boolean().optional().describe("Return machine-readable status output when supported."),
|
|
2033
|
+
short: tool.schema.boolean().optional().describe("Use short status output."),
|
|
2034
|
+
format: outputFormatArg,
|
|
2035
|
+
},
|
|
2036
|
+
async execute(args)
|
|
2037
|
+
{
|
|
2038
|
+
const format = args.format ?? "text";
|
|
2039
|
+
const cmdArgs: string[] = ["status"];
|
|
2040
|
+
|
|
2041
|
+
if (args.short)
|
|
2042
|
+
{
|
|
2043
|
+
cmdArgs.push("--short");
|
|
2044
|
+
}
|
|
2045
|
+
|
|
2046
|
+
if (args.includeRevId)
|
|
2047
|
+
{
|
|
2048
|
+
cmdArgs.push("--includeRevId");
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
if (args.machineReadable)
|
|
2052
|
+
{
|
|
2053
|
+
cmdArgs.push("--machinereadable");
|
|
2054
|
+
const machineOutput = await runCmRaw(cmdArgs, args.workdir);
|
|
2055
|
+
return toStructuredResult(
|
|
2056
|
+
"status",
|
|
2057
|
+
format,
|
|
2058
|
+
machineOutput,
|
|
2059
|
+
{
|
|
2060
|
+
rawOutput: machineOutput,
|
|
2061
|
+
machineReadable: true,
|
|
2062
|
+
},
|
|
2063
|
+
args.workdir,
|
|
2064
|
+
);
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
const output = await runCm(cmdArgs, args.workdir);
|
|
2068
|
+
const shortOutput = await runCmRaw(["status", "--short"], args.workdir);
|
|
2069
|
+
const summary = summarizeShortStatus(shortOutput);
|
|
2070
|
+
const mergeState = analyzeMergeStatusOutput(output);
|
|
2071
|
+
const textOutput = mergeState.hasMergeInProgress || mergeState.hasPendingMergeLinks
|
|
2072
|
+
? [
|
|
2073
|
+
output,
|
|
2074
|
+
"",
|
|
2075
|
+
"## Merge State",
|
|
2076
|
+
`- Pending merge links: ${mergeState.pendingMergeLinks.length}`,
|
|
2077
|
+
`- Merge-in-progress hints: ${mergeState.mergeInProgressHints.length}`,
|
|
2078
|
+
...(mergeState.hasMergeInProgress ? ["- Checkin may be blocked until merge metadata is finalized. If files are resolved, run plastic_finalizeMerge(...)."] : []),
|
|
2079
|
+
].join("\n")
|
|
2080
|
+
: output;
|
|
2081
|
+
|
|
2082
|
+
return toStructuredResult(
|
|
2083
|
+
"status",
|
|
2084
|
+
format,
|
|
2085
|
+
textOutput,
|
|
2086
|
+
{
|
|
2087
|
+
rawOutput: output,
|
|
2088
|
+
shortOutput,
|
|
2089
|
+
summary,
|
|
2090
|
+
mergeState,
|
|
2091
|
+
usedShortFlag: args.short ?? false,
|
|
2092
|
+
},
|
|
2093
|
+
args.workdir,
|
|
2094
|
+
);
|
|
2095
|
+
},
|
|
2096
|
+
});
|
|
2097
|
+
|
|
2098
|
+
export const update = tool({
|
|
2099
|
+
description: "Update workspace safely without launching interactive merge (cm update --dontmerge --noinput).",
|
|
2100
|
+
args: {
|
|
2101
|
+
workdir: workdirArg,
|
|
2102
|
+
},
|
|
2103
|
+
async execute(args)
|
|
2104
|
+
{
|
|
2105
|
+
return runCm(["update", "--dontmerge", "--noinput"], args.workdir);
|
|
2106
|
+
},
|
|
2107
|
+
});
|
|
2108
|
+
|
|
2109
|
+
export const add = tool({
|
|
2110
|
+
description: "Add items to Plastic SCM (cm add).",
|
|
2111
|
+
args: {
|
|
2112
|
+
paths: tool.schema.array(tool.schema.string()).min(1).describe("Paths to add."),
|
|
2113
|
+
workdir: workdirArg,
|
|
2114
|
+
},
|
|
2115
|
+
async execute(args)
|
|
2116
|
+
{
|
|
2117
|
+
return runCm(["add", ...args.paths], args.workdir);
|
|
2118
|
+
},
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
export const checkin = tool({
|
|
2122
|
+
description: "Check in pending changes with a required comment (cm checkin -c=...).",
|
|
2123
|
+
args: {
|
|
2124
|
+
message: tool.schema.string().min(1).describe("Changeset comment."),
|
|
2125
|
+
paths: tool.schema.array(tool.schema.string()).optional().describe("Optional paths to check in."),
|
|
2126
|
+
applyChanged: tool.schema.boolean().optional().describe("Include changed items not checked out."),
|
|
2127
|
+
includePrivate: tool.schema.boolean().optional().describe("Include private items."),
|
|
2128
|
+
includeAll: tool.schema.boolean().optional().describe("Include changed, moved, and deleted items."),
|
|
2129
|
+
updateAfter: tool.schema.boolean().optional().describe("Deprecated in unattended mode. Blocked because checkin --update can trigger interactive update-merge."),
|
|
2130
|
+
preflight: tool.schema.boolean().optional().describe("Preview command and included paths without executing checkin."),
|
|
2131
|
+
format: outputFormatArg,
|
|
2132
|
+
workdir: workdirArg,
|
|
2133
|
+
},
|
|
2134
|
+
async execute(args)
|
|
2135
|
+
{
|
|
2136
|
+
const format = args.format ?? "text";
|
|
2137
|
+
const preflight = args.preflight ?? false;
|
|
2138
|
+
|
|
2139
|
+
if (args.updateAfter)
|
|
2140
|
+
{
|
|
2141
|
+
const message = "updateAfter is disabled for unattended safety because checkin --update can trigger interactive update-merge flows. Run plastic_update() first, then plastic_merge(...) with an explicit conflict strategy.";
|
|
2142
|
+
if (preflight)
|
|
2143
|
+
{
|
|
2144
|
+
return toStructuredResult(
|
|
2145
|
+
"checkin-preflight",
|
|
2146
|
+
format,
|
|
2147
|
+
formatPreflightText("## Checkin Preflight", [
|
|
2148
|
+
"- Would run: no",
|
|
2149
|
+
`- Reason: ${message}`,
|
|
2150
|
+
]),
|
|
2151
|
+
{
|
|
2152
|
+
wouldRun: false,
|
|
2153
|
+
blockedOption: "updateAfter",
|
|
2154
|
+
errorCode: "UNATTENDED_UPDATE_AFTER_BLOCKED",
|
|
2155
|
+
},
|
|
2156
|
+
args.workdir,
|
|
2157
|
+
[message],
|
|
2158
|
+
"Run plastic_update() and then plastic_merge(...) using the default strategy (auto), or set strategy=source|destination only when you need an explicit override.",
|
|
2159
|
+
);
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
throw new Error(message);
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2166
|
+
const prePendingItems = await getMachineReadablePendingItems(args.workdir);
|
|
2167
|
+
const prePendingSummary = summarizePendingItems(prePendingItems, cwd);
|
|
2168
|
+
let requestedPaths: string[] = args.paths ?? [];
|
|
2169
|
+
let includedPaths: string[] = [];
|
|
2170
|
+
let includedAbsolutePaths: string[] = [];
|
|
2171
|
+
let fallbackPaths: string[] = [];
|
|
2172
|
+
let rewrittenPaths: string[] = [];
|
|
2173
|
+
let rewriteReason: string | undefined;
|
|
2174
|
+
let matchedPendingCount = args.paths && args.paths.length > 0 ? 0 : prePendingSummary.totalPending;
|
|
2175
|
+
let autoEnabledApplyChanged = false;
|
|
2176
|
+
let usedFallbackRetry = false;
|
|
2177
|
+
let usedNoChangesRecovery = false;
|
|
2178
|
+
let usedPrivateAutoAddRecovery = false;
|
|
2179
|
+
const autoAddedPrivatePaths: string[] = [];
|
|
2180
|
+
const blockedPrivateAutoAddPaths: Array<{ path: string; reason: string }> = [];
|
|
2181
|
+
const decisionSteps: string[] = ["initial-checkin"];
|
|
2182
|
+
const excludedPaths: Array<{ path: string; reason: string }> = [];
|
|
2183
|
+
let useApplyChanged = args.applyChanged ?? false;
|
|
2184
|
+
let pendingItemsInScope: PendingItem[] = prePendingItems;
|
|
2185
|
+
let pendingSummaryInScope: PendingItemSummary = prePendingSummary;
|
|
2186
|
+
|
|
2187
|
+
const buildCommandArgs = (paths: string[], forceApplyChanged: boolean): string[] =>
|
|
2188
|
+
{
|
|
2189
|
+
const cmdArgs: string[] = ["checkin", `-c=${args.message}`];
|
|
2190
|
+
|
|
2191
|
+
if (args.includeAll)
|
|
2192
|
+
{
|
|
2193
|
+
cmdArgs.push("--all");
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
if (forceApplyChanged)
|
|
2197
|
+
{
|
|
2198
|
+
cmdArgs.push("--applychanged");
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
if (args.includePrivate)
|
|
2202
|
+
{
|
|
2203
|
+
cmdArgs.push("--private");
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
if (args.updateAfter)
|
|
2207
|
+
{
|
|
2208
|
+
cmdArgs.push("--update");
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
if (paths.length > 0)
|
|
2212
|
+
{
|
|
2213
|
+
cmdArgs.push(...paths);
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
return cmdArgs;
|
|
2217
|
+
};
|
|
2218
|
+
|
|
2219
|
+
if (args.paths && args.paths.length > 0)
|
|
2220
|
+
{
|
|
2221
|
+
const pathResolution = resolveCheckinPaths(args.paths, prePendingItems, cwd);
|
|
2222
|
+
requestedPaths = pathResolution.requestedPaths;
|
|
2223
|
+
includedPaths = pathResolution.includedPaths;
|
|
2224
|
+
includedAbsolutePaths = pathResolution.includedAbsolutePaths;
|
|
2225
|
+
fallbackPaths = pathResolution.fallbackPaths;
|
|
2226
|
+
rewrittenPaths = pathResolution.rewrittenPaths;
|
|
2227
|
+
rewriteReason = pathResolution.rewriteReason;
|
|
2228
|
+
matchedPendingCount = pathResolution.matchedPendingCount;
|
|
2229
|
+
excludedPaths.push(...pathResolution.excludedPaths);
|
|
2230
|
+
autoEnabledApplyChanged = pathResolution.shouldApplyChanged && !useApplyChanged;
|
|
2231
|
+
useApplyChanged = useApplyChanged || pathResolution.shouldApplyChanged;
|
|
2232
|
+
pendingItemsInScope = filterPendingItemsByScope(prePendingItems, includedAbsolutePaths);
|
|
2233
|
+
pendingSummaryInScope = summarizePendingItems(pendingItemsInScope, cwd);
|
|
2234
|
+
|
|
2235
|
+
if (includedPaths.length === 0)
|
|
2236
|
+
{
|
|
2237
|
+
const pendingPreview = formatPendingPathPreview(prePendingItems, cwd);
|
|
2238
|
+
const message = "None of the provided checkin paths have pending changes. "
|
|
2239
|
+
+ `Workdir: ${cwd}. `
|
|
2240
|
+
+ `Pending sample: ${pendingPreview}. `
|
|
2241
|
+
+ "Run plastic_status(machineReadable=true) or plastic_checkin(preflight=true, ...) to verify path scope before checkin.";
|
|
2242
|
+
const commandPreview = buildCommandArgs(includedPaths, useApplyChanged);
|
|
2243
|
+
if (preflight)
|
|
2244
|
+
{
|
|
2245
|
+
return toStructuredResult(
|
|
2246
|
+
"checkin-preflight",
|
|
2247
|
+
format,
|
|
2248
|
+
formatPreflightText("## Checkin Preflight", [
|
|
2249
|
+
"- Would run: no",
|
|
2250
|
+
`- Reason: ${message}`,
|
|
2251
|
+
`- Excluded paths: ${excludedPaths.length}`,
|
|
2252
|
+
]),
|
|
2253
|
+
{
|
|
2254
|
+
wouldRun: false,
|
|
2255
|
+
command: ["cm", ...commandPreview],
|
|
2256
|
+
requestedPaths,
|
|
2257
|
+
includedPaths,
|
|
2258
|
+
fallbackPaths,
|
|
2259
|
+
excludedPaths,
|
|
2260
|
+
rewrittenPaths,
|
|
2261
|
+
rewriteReason,
|
|
2262
|
+
matchedPendingCount,
|
|
2263
|
+
errorCode: "NO_PENDING_PATHS",
|
|
2264
|
+
},
|
|
2265
|
+
args.workdir,
|
|
2266
|
+
[message],
|
|
2267
|
+
);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
throw new Error(message);
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
|
|
2274
|
+
const cmdArgs = buildCommandArgs(includedPaths, useApplyChanged);
|
|
2275
|
+
|
|
2276
|
+
if (preflight)
|
|
2277
|
+
{
|
|
2278
|
+
const text = formatPreflightText("## Checkin Preflight", [
|
|
2279
|
+
"- Would run: yes",
|
|
2280
|
+
`- Command: cm ${cmdArgs.join(" ")}`,
|
|
2281
|
+
`- Requested paths: ${requestedPaths.length > 0 ? requestedPaths.join(", ") : "(none)"}`,
|
|
2282
|
+
`- Included paths: ${includedPaths.length > 0 ? includedPaths.join(", ") : "(all pending)"}`,
|
|
2283
|
+
`- Pending summary (scope): total=${pendingSummaryInScope.totalPending}, tracked=${pendingSummaryInScope.tracked}, private=${pendingSummaryInScope.private}`,
|
|
2284
|
+
`- Private handling: ${args.includePrivate ? "include via --private" : "excluded unless added"}`,
|
|
2285
|
+
`- Rewritten paths: ${rewrittenPaths.length > 0 ? rewrittenPaths.join(", ") : "(none)"}`,
|
|
2286
|
+
`- Auto-enabled --applychanged: ${autoEnabledApplyChanged ? "yes" : "no"}`,
|
|
2287
|
+
`- Matched pending entries: ${matchedPendingCount}`,
|
|
2288
|
+
`- Excluded paths: ${excludedPaths.length}`,
|
|
2289
|
+
`- Rewrite reason: ${rewriteReason ?? "(none)"}`,
|
|
2290
|
+
]);
|
|
2291
|
+
|
|
2292
|
+
return toStructuredResult(
|
|
2293
|
+
"checkin-preflight",
|
|
2294
|
+
format,
|
|
2295
|
+
text,
|
|
2296
|
+
{
|
|
2297
|
+
wouldRun: true,
|
|
2298
|
+
command: ["cm", ...cmdArgs],
|
|
2299
|
+
requestedPaths,
|
|
2300
|
+
includedPaths,
|
|
2301
|
+
fallbackPaths,
|
|
2302
|
+
rewrittenPaths,
|
|
2303
|
+
excludedPaths,
|
|
2304
|
+
rewriteReason,
|
|
2305
|
+
autoEnabledApplyChanged,
|
|
2306
|
+
matchedPendingCount,
|
|
2307
|
+
prePendingSummary,
|
|
2308
|
+
pendingSummaryInScope,
|
|
2309
|
+
},
|
|
2310
|
+
args.workdir,
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
let output = "";
|
|
2315
|
+
let executedCommandArgs = cmdArgs;
|
|
2316
|
+
const attemptPathScopeFallbackRetry = async (errorMessage: string): Promise<boolean> =>
|
|
2317
|
+
{
|
|
2318
|
+
if (!args.paths || args.paths.length === 0 || !shouldRetryCheckinWithFallbackScope(errorMessage))
|
|
2319
|
+
{
|
|
2320
|
+
return false;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
const resolvedFallbackPaths = fallbackPaths.length > 0 ? fallbackPaths : buildFallbackScopePaths(includedAbsolutePaths, cwd);
|
|
2324
|
+
const fallbackCommandArgs = buildCommandArgs(resolvedFallbackPaths, true);
|
|
2325
|
+
if (resolvedFallbackPaths.length === 0 || fallbackCommandArgs.join("\0") === cmdArgs.join("\0"))
|
|
2326
|
+
{
|
|
2327
|
+
return false;
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
output = await runCm(fallbackCommandArgs, args.workdir);
|
|
2331
|
+
executedCommandArgs = fallbackCommandArgs;
|
|
2332
|
+
usedFallbackRetry = true;
|
|
2333
|
+
includedPaths = resolvedFallbackPaths;
|
|
2334
|
+
fallbackPaths = resolvedFallbackPaths;
|
|
2335
|
+
rewrittenPaths = resolvedFallbackPaths;
|
|
2336
|
+
rewriteReason = "Automatic retry used parent-directory scope with --applychanged after Plastic rejected path-scoped checkin.";
|
|
2337
|
+
useApplyChanged = true;
|
|
2338
|
+
decisionSteps.push("path-scope-fallback-retry");
|
|
2339
|
+
return true;
|
|
2340
|
+
};
|
|
2341
|
+
|
|
2342
|
+
try
|
|
2343
|
+
{
|
|
2344
|
+
output = await runCm(cmdArgs, args.workdir);
|
|
2345
|
+
}
|
|
2346
|
+
catch (error)
|
|
2347
|
+
{
|
|
2348
|
+
let currentError: unknown = error;
|
|
2349
|
+
let currentErrorMessage = normalizeErrorMessage(currentError);
|
|
2350
|
+
|
|
2351
|
+
if (isMergeInProgressCheckinError(currentErrorMessage))
|
|
2352
|
+
{
|
|
2353
|
+
throw new Error(await buildMergeInProgressCheckinMessage(currentErrorMessage, args.workdir));
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
if (isNoChangesWorkspaceCheckinError(currentErrorMessage)
|
|
2357
|
+
&& !args.includePrivate
|
|
2358
|
+
&& pendingSummaryInScope.private > 0
|
|
2359
|
+
&& pendingSummaryInScope.tracked === 0
|
|
2360
|
+
&& (Boolean(args.includeAll) || requestedPaths.length > 0))
|
|
2361
|
+
{
|
|
2362
|
+
const scopedAutoAddSelection = selectPrivatePathsForAutoAdd(
|
|
2363
|
+
pendingItemsInScope,
|
|
2364
|
+
includedAbsolutePaths,
|
|
2365
|
+
cwd,
|
|
2366
|
+
);
|
|
2367
|
+
blockedPrivateAutoAddPaths.push(...scopedAutoAddSelection.blockedPaths);
|
|
2368
|
+
|
|
2369
|
+
if (scopedAutoAddSelection.candidatePaths.length > 0)
|
|
2370
|
+
{
|
|
2371
|
+
await runCm(["add", ...scopedAutoAddSelection.candidatePaths], args.workdir);
|
|
2372
|
+
autoAddedPrivatePaths.push(...scopedAutoAddSelection.candidatePaths);
|
|
2373
|
+
usedPrivateAutoAddRecovery = true;
|
|
2374
|
+
decisionSteps.push("auto-add-private");
|
|
2375
|
+
|
|
2376
|
+
try
|
|
2377
|
+
{
|
|
2378
|
+
output = await runCm(cmdArgs, args.workdir);
|
|
2379
|
+
currentError = null;
|
|
2380
|
+
currentErrorMessage = "";
|
|
2381
|
+
decisionSteps.push("auto-add-private-retry-success");
|
|
2382
|
+
}
|
|
2383
|
+
catch (retryError)
|
|
2384
|
+
{
|
|
2385
|
+
currentError = retryError;
|
|
2386
|
+
currentErrorMessage = normalizeErrorMessage(currentError);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
else if (scopedAutoAddSelection.blockedPaths.length > 0)
|
|
2390
|
+
{
|
|
2391
|
+
const blockedPreview = scopedAutoAddSelection.blockedPaths
|
|
2392
|
+
.slice(0, 8)
|
|
2393
|
+
.map((item) => `${item.path} (${item.reason})`)
|
|
2394
|
+
.join(", ");
|
|
2395
|
+
throw new Error(
|
|
2396
|
+
`Checkin blocked: only private items were pending and all candidates matched sensitive filters: ${blockedPreview}. `
|
|
2397
|
+
+ "Run plastic_add(paths=[...]) with explicit safe paths, or use includePrivate=true only when intentional.",
|
|
2398
|
+
);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
if (currentError
|
|
2403
|
+
&& isNoChangesWorkspaceCheckinError(currentErrorMessage)
|
|
2404
|
+
&& !args.includePrivate
|
|
2405
|
+
&& pendingSummaryInScope.private > 0
|
|
2406
|
+
&& pendingSummaryInScope.tracked === 0
|
|
2407
|
+
&& !Boolean(args.includeAll)
|
|
2408
|
+
&& requestedPaths.length === 0)
|
|
2409
|
+
{
|
|
2410
|
+
throw new Error(
|
|
2411
|
+
"Checkin found only private pending items. includeAll/includePrivate were not set, so nothing was eligible for checkin. "
|
|
2412
|
+
+ "Run plastic_add(paths=[...]) for expected files or rerun with includePrivate=true only when intentional.",
|
|
2413
|
+
);
|
|
2414
|
+
}
|
|
2415
|
+
|
|
2416
|
+
if (currentError && isNoChangesWorkspaceCheckinError(currentErrorMessage) && pendingSummaryInScope.tracked > 0)
|
|
2417
|
+
{
|
|
2418
|
+
const postPendingItems = await getMachineReadablePendingItems(args.workdir).catch(() => []);
|
|
2419
|
+
const postPendingSummaryForRecovery = summarizePendingItems(postPendingItems, cwd);
|
|
2420
|
+
if (postPendingSummaryForRecovery.totalPending === 0)
|
|
2421
|
+
{
|
|
2422
|
+
output = "Checkin completed with clean-workspace recovery: Plastic reported 'no changes in the workspace', but no pending changes remain after the attempt.";
|
|
2423
|
+
currentError = null;
|
|
2424
|
+
currentErrorMessage = "";
|
|
2425
|
+
usedNoChangesRecovery = true;
|
|
2426
|
+
decisionSteps.push("clean-workspace-no-changes-recovery");
|
|
2427
|
+
}
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
if (currentError)
|
|
2431
|
+
{
|
|
2432
|
+
const fallbackHandled = await attemptPathScopeFallbackRetry(currentErrorMessage);
|
|
2433
|
+
if (!fallbackHandled)
|
|
2434
|
+
{
|
|
2435
|
+
throw currentError;
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
const postPendingItems = await getMachineReadablePendingItems(args.workdir).catch(() => []);
|
|
2441
|
+
const postPendingSummary = summarizePendingItems(postPendingItems, cwd);
|
|
2442
|
+
|
|
2443
|
+
return toStructuredResult(
|
|
2444
|
+
"checkin",
|
|
2445
|
+
format,
|
|
2446
|
+
output,
|
|
2447
|
+
{
|
|
2448
|
+
command: ["cm", ...executedCommandArgs],
|
|
2449
|
+
initialCommand: ["cm", ...cmdArgs],
|
|
2450
|
+
requestedPaths,
|
|
2451
|
+
includedPaths,
|
|
2452
|
+
fallbackPaths,
|
|
2453
|
+
rewrittenPaths,
|
|
2454
|
+
excludedPaths,
|
|
2455
|
+
rewriteReason,
|
|
2456
|
+
autoEnabledApplyChanged,
|
|
2457
|
+
usedFallbackRetry,
|
|
2458
|
+
usedNoChangesRecovery,
|
|
2459
|
+
usedPrivateAutoAddRecovery,
|
|
2460
|
+
applyChanged: useApplyChanged,
|
|
2461
|
+
matchedPendingCount,
|
|
2462
|
+
prePendingSummary,
|
|
2463
|
+
pendingSummaryInScope,
|
|
2464
|
+
postPendingSummary,
|
|
2465
|
+
autoAddedPrivatePaths,
|
|
2466
|
+
blockedPrivateAutoAddPaths,
|
|
2467
|
+
decisionPath: decisionSteps.join(" -> "),
|
|
2468
|
+
rawOutput: output,
|
|
2469
|
+
},
|
|
2470
|
+
args.workdir,
|
|
2471
|
+
[
|
|
2472
|
+
...(excludedPaths.length > 0 ? ["Some requested paths were excluded because they had no pending changes."] : []),
|
|
2473
|
+
...(autoEnabledApplyChanged ? ["Enabled --applychanged automatically because requested scope included moved/deleted pending items."] : []),
|
|
2474
|
+
...(usedFallbackRetry ? ["Retried checkin automatically with parent-directory scope after Plastic rejected the original path-scoped request."] : []),
|
|
2475
|
+
...(usedPrivateAutoAddRecovery ? [`Auto-added ${autoAddedPrivatePaths.length} private pending item(s) before retrying checkin.`] : []),
|
|
2476
|
+
...(blockedPrivateAutoAddPaths.length > 0 ? [`Skipped ${blockedPrivateAutoAddPaths.length} private pending item(s) from auto-add due to sensitive-path filters.`] : []),
|
|
2477
|
+
...(usedNoChangesRecovery ? ["Recovered from Plastic no-changes error because workspace was clean after the checkin attempt."] : []),
|
|
2478
|
+
],
|
|
2479
|
+
);
|
|
2480
|
+
},
|
|
2481
|
+
});
|
|
2482
|
+
|
|
2483
|
+
export const undo = tool({
|
|
2484
|
+
description: "Undo changes for specified items (cm undo).",
|
|
2485
|
+
args: {
|
|
2486
|
+
paths: tool.schema.array(tool.schema.string()).min(1).describe("Paths to undo."),
|
|
2487
|
+
workdir: workdirArg,
|
|
2488
|
+
},
|
|
2489
|
+
async execute(args)
|
|
2490
|
+
{
|
|
2491
|
+
return runCm(["undo", ...args.paths], args.workdir);
|
|
2492
|
+
},
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
export const resolveDeleteChangeConflict = tool({
|
|
2496
|
+
description: "Resolve a Plastic SCM delete/change conflict by accepting the source-side deletion with cm remove.",
|
|
2497
|
+
args: {
|
|
2498
|
+
paths: tool.schema.array(tool.schema.string()).min(1).describe("Controlled workspace paths to resolve by accepting the source-side deletion."),
|
|
2499
|
+
keepOnDisk: tool.schema.boolean().optional().describe("Keep removed items on disk as private files via --nodisk. Defaults to true."),
|
|
2500
|
+
preflight: tool.schema.boolean().optional().describe("Preview the resolution command without executing it."),
|
|
2501
|
+
format: outputFormatArg,
|
|
2502
|
+
workdir: workdirArg,
|
|
2503
|
+
},
|
|
2504
|
+
async execute(args)
|
|
2505
|
+
{
|
|
2506
|
+
const format = args.format ?? "text";
|
|
2507
|
+
const keepOnDisk = args.keepOnDisk ?? true;
|
|
2508
|
+
const preflight = args.preflight ?? false;
|
|
2509
|
+
const cmdArgs = ["remove", ...(keepOnDisk ? ["--nodisk"] : []), ...args.paths];
|
|
2510
|
+
|
|
2511
|
+
if (preflight)
|
|
2512
|
+
{
|
|
2513
|
+
return toStructuredResult(
|
|
2514
|
+
"resolve-delete-change-conflict-preflight",
|
|
2515
|
+
format,
|
|
2516
|
+
formatPreflightText("## Delete/Change Conflict Resolution Preflight", [
|
|
2517
|
+
"- Would run: yes",
|
|
2518
|
+
"- Resolution: accept source-side deletion",
|
|
2519
|
+
`- Keep removed items on disk: ${keepOnDisk ? "yes (--nodisk)" : "no"}`,
|
|
2520
|
+
`- Command: cm ${cmdArgs.join(" ")}`,
|
|
2521
|
+
]),
|
|
2522
|
+
{
|
|
2523
|
+
wouldRun: true,
|
|
2524
|
+
resolution: "accept-source-deletion",
|
|
2525
|
+
keepOnDisk,
|
|
2526
|
+
command: ["cm", ...cmdArgs],
|
|
2527
|
+
paths: args.paths,
|
|
2528
|
+
},
|
|
2529
|
+
args.workdir,
|
|
2530
|
+
);
|
|
2531
|
+
}
|
|
2532
|
+
|
|
2533
|
+
const output = await runCm(cmdArgs, args.workdir);
|
|
2534
|
+
const shortStatusAfterResolution = await runCmRaw(["status", "--short"], args.workdir).catch(() => "");
|
|
2535
|
+
const pendingSummaryAfterResolution = summarizeShortStatus(shortStatusAfterResolution);
|
|
2536
|
+
const reportLines = [
|
|
2537
|
+
"## Delete/Change Conflict Resolution Result",
|
|
2538
|
+
"",
|
|
2539
|
+
"- Resolution: accepted source-side deletion",
|
|
2540
|
+
`- Keep removed items on disk: ${keepOnDisk ? "yes (--nodisk)" : "no"}`,
|
|
2541
|
+
`- Paths resolved: ${args.paths.length}`,
|
|
2542
|
+
`- Pending items after resolution: ${pendingSummaryAfterResolution.totalPending}`,
|
|
2543
|
+
];
|
|
2544
|
+
|
|
2545
|
+
if (output.trim().length > 0 && output.trim() !== "(no output)")
|
|
2546
|
+
{
|
|
2547
|
+
reportLines.push("", "Raw command output:", output.trim());
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
return toStructuredResult(
|
|
2551
|
+
"resolve-delete-change-conflict",
|
|
2552
|
+
format,
|
|
2553
|
+
reportLines.join("\n"),
|
|
2554
|
+
{
|
|
2555
|
+
resolution: "accept-source-deletion",
|
|
2556
|
+
keepOnDisk,
|
|
2557
|
+
command: ["cm", ...cmdArgs],
|
|
2558
|
+
paths: args.paths,
|
|
2559
|
+
shortStatusAfterResolution,
|
|
2560
|
+
pendingSummaryAfterResolution,
|
|
2561
|
+
rawOutput: output,
|
|
2562
|
+
},
|
|
2563
|
+
args.workdir,
|
|
2564
|
+
keepOnDisk ? ["Removed items were kept on disk as private files via --nodisk."] : undefined,
|
|
2565
|
+
"Rerun plastic_merge(...) for the original source branch to continue the merge.",
|
|
2566
|
+
);
|
|
2567
|
+
},
|
|
2568
|
+
});
|
|
2569
|
+
|
|
2570
|
+
export const diff = tool({
|
|
2571
|
+
description: "Disabled alias for cm diff; use text-only alternatives.",
|
|
2572
|
+
args: {
|
|
2573
|
+
source: tool.schema.string().min(1).describe("Source changeset/label/shelveset/branch spec (cs:, lb:, sh:, br:, or a numeric changeset)."),
|
|
2574
|
+
destination: tool.schema.string().optional().describe("Optional destination changeset/label/shelveset/branch spec."),
|
|
2575
|
+
added: tool.schema.boolean().optional().describe("Show only added items."),
|
|
2576
|
+
changed: tool.schema.boolean().optional().describe("Show only changed items."),
|
|
2577
|
+
moved: tool.schema.boolean().optional().describe("Show only moved items."),
|
|
2578
|
+
deleted: tool.schema.boolean().optional().describe("Show only deleted items."),
|
|
2579
|
+
repositoryPaths: tool.schema.boolean().optional().describe("Print repository paths instead of workspace paths."),
|
|
2580
|
+
format: tool.schema.string().optional().describe("Format string for CLI output."),
|
|
2581
|
+
dateFormat: tool.schema.string().optional().describe("Date format for output dates."),
|
|
2582
|
+
comparisonMethod: tool.schema.enum([
|
|
2583
|
+
"ignoreeol",
|
|
2584
|
+
"ignorewhitespaces",
|
|
2585
|
+
"ignoreeolandwhitespaces",
|
|
2586
|
+
"recognizeall",
|
|
2587
|
+
]).optional().describe("Comparison method used for diff calculations."),
|
|
2588
|
+
clean: tool.schema.boolean().optional().describe("Exclude changes produced by merges."),
|
|
2589
|
+
integration: tool.schema.boolean().optional().describe("Show pending integration differences."),
|
|
2590
|
+
fullPaths: tool.schema.boolean().optional().describe("Force full workspace paths when possible."),
|
|
2591
|
+
workdir: workdirArg,
|
|
2592
|
+
},
|
|
2593
|
+
async execute(_args)
|
|
2594
|
+
{
|
|
2595
|
+
throw new Error(`plastic_diff is disabled. ${BLOCKED_CM_DIFF_MESSAGE}`);
|
|
2596
|
+
},
|
|
2597
|
+
});
|
|
2598
|
+
|
|
2599
|
+
export const patch = tool({
|
|
2600
|
+
description: "Generate a Plastic SCM patch with the configured portable diff backend and optional clean/integration filtering (cm patch).",
|
|
2601
|
+
args: {
|
|
2602
|
+
source: tool.schema.string().min(1).describe("Source changeset or branch spec, for example a changeset spec or branch spec selected from the current workspace."),
|
|
2603
|
+
destination: tool.schema.string().optional().describe("Optional second changeset or branch spec for two-spec patch generation."),
|
|
2604
|
+
output: tool.schema.string().optional().describe("Optional output file path. If omitted, patch content is printed to stdout."),
|
|
2605
|
+
toolPath: tool.schema.string().optional().describe("Optional path to the diff executable used by cm patch. Defaults to PI_PLASTIC_DIFF_EXECUTABLE or diff on PATH."),
|
|
2606
|
+
clean: tool.schema.boolean().optional().describe("Exclude content that arrived via merges and include only direct checkins."),
|
|
2607
|
+
integration: tool.schema.boolean().optional().describe("Show branch changes pending integration into the parent branch."),
|
|
2608
|
+
workdir: workdirArg,
|
|
2609
|
+
},
|
|
2610
|
+
async execute(args)
|
|
2611
|
+
{
|
|
2612
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2613
|
+
const tempDir = args.output ? null : await fs.mkdtemp(join(tmpdir(), "plastic-patch-"));
|
|
2614
|
+
const requestedOutput = args.output ?? join(tempDir!, "review.patch");
|
|
2615
|
+
const resolvedOutput = isAbsolute(requestedOutput) ? requestedOutput : resolve(cwd, requestedOutput);
|
|
2616
|
+
|
|
2617
|
+
try
|
|
2618
|
+
{
|
|
2619
|
+
const cmdArgs = buildPatchCommandArgs({ ...args, output: requestedOutput, toolPath: resolvePatchToolPath(args.toolPath) });
|
|
2620
|
+
const commandOutput = await runCm(cmdArgs, args.workdir);
|
|
2621
|
+
const patchStat = await fs.stat(resolvedOutput).catch(() => null);
|
|
2622
|
+
if (!patchStat?.isFile())
|
|
2623
|
+
{
|
|
2624
|
+
throw new Error(`Plastic reported patch generation success but did not create '${requestedOutput}'.`);
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
const patchBytes = await fs.readFile(resolvedOutput);
|
|
2628
|
+
const isText = isUtf8(patchBytes);
|
|
2629
|
+
const patchText = isText ? patchBytes.toString("utf8") : "";
|
|
2630
|
+
const bounded = boundDiffOutput(patchText);
|
|
2631
|
+
const binaryLimited = /Binary files? .* differ|cannot diff|binary (?:content|file) (?:is )?not supported/i.test(`${patchText}\n${commandOutput}`);
|
|
2632
|
+
const metadata = {
|
|
2633
|
+
status: patchBytes.length === 0 ? "empty" : (binaryLimited ? "generated-with-binary-warning" : "generated"),
|
|
2634
|
+
output: args.output ? requestedOutput : null,
|
|
2635
|
+
bytes: patchBytes.length,
|
|
2636
|
+
empty: patchBytes.length === 0,
|
|
2637
|
+
binaryLimited: !isText || binaryLimited,
|
|
2638
|
+
truncated: !args.output && bounded.truncated,
|
|
2639
|
+
content: args.output ? null : bounded.output,
|
|
2640
|
+
};
|
|
2641
|
+
return JSON.stringify(metadata, null, 2);
|
|
2642
|
+
}
|
|
2643
|
+
finally
|
|
2644
|
+
{
|
|
2645
|
+
if (tempDir)
|
|
2646
|
+
{
|
|
2647
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
2648
|
+
}
|
|
2649
|
+
}
|
|
2650
|
+
},
|
|
2651
|
+
});
|
|
2652
|
+
|
|
2653
|
+
export const diffRevisions = tool({
|
|
2654
|
+
description: "Show a bounded text-only diff between two file-qualified Plastic revisions using portable diff -u; binary content is reported explicitly.",
|
|
2655
|
+
args: {
|
|
2656
|
+
leftRevision: tool.schema.string().min(1).describe("Left file-qualified Plastic revision spec for cm cat."),
|
|
2657
|
+
rightRevision: tool.schema.string().min(1).describe("Right file-qualified Plastic revision spec for cm cat."),
|
|
2658
|
+
format: outputFormatArg,
|
|
2659
|
+
workdir: workdirArg,
|
|
2660
|
+
},
|
|
2661
|
+
async execute(args)
|
|
2662
|
+
{
|
|
2663
|
+
if (isUnscopedDiffRevisionSpec(args.leftRevision) || isUnscopedDiffRevisionSpec(args.rightRevision))
|
|
2664
|
+
{
|
|
2665
|
+
throw new Error("plastic_diffRevisions requires file-qualified revspecs. For workspace-vs-revision comparisons, use plastic_diffFile(path=..., revision=...).");
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2669
|
+
const leftLabel = args.leftRevision.includes("#") ? args.leftRevision.replace("#", "@") : args.leftRevision;
|
|
2670
|
+
const rightLabel = args.rightRevision.includes("#") ? args.rightRevision.replace("#", "@") : args.rightRevision;
|
|
2671
|
+
const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
|
|
2672
|
+
const leftPath = join(tempDir, `left${safeTempExtension(args.leftRevision)}`);
|
|
2673
|
+
const rightPath = join(tempDir, `right${safeTempExtension(args.rightRevision)}`);
|
|
2674
|
+
|
|
2675
|
+
try
|
|
2676
|
+
{
|
|
2677
|
+
await materializeRevision(args.leftRevision, leftPath, args.workdir);
|
|
2678
|
+
await materializeRevision(args.rightRevision, rightPath, args.workdir);
|
|
2679
|
+
const result = await runPortableTextDiff(leftPath, rightPath, cwd, leftLabel, rightLabel);
|
|
2680
|
+
return formatTextDiff("diffRevisions", args.format, "revision-to-revision", result, args.workdir);
|
|
2681
|
+
}
|
|
2682
|
+
finally
|
|
2683
|
+
{
|
|
2684
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
2685
|
+
}
|
|
2686
|
+
},
|
|
2687
|
+
});
|
|
2688
|
+
|
|
2689
|
+
export const diffFile = tool({
|
|
2690
|
+
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
|
+
args: {
|
|
2692
|
+
path: tool.schema.string().min(1).describe("Workspace file path to diff."),
|
|
2693
|
+
revision: tool.schema.string().optional().describe("Optional revision: changeset number/cs:<number>, br:/<branch>, lb:<label>, file-qualified '<path>#<selector>', or global revid:/rev:. Omit for the workspace base."),
|
|
2694
|
+
format: outputFormatArg,
|
|
2695
|
+
workdir: workdirArg,
|
|
2696
|
+
},
|
|
2697
|
+
async execute(args)
|
|
2698
|
+
{
|
|
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)
|
|
2718
|
+
{
|
|
2719
|
+
throw new Error(`Plastic status identified '${args.path}' as deleted but did not provide its base revision ID.`);
|
|
2720
|
+
}
|
|
2721
|
+
const tempDir = await fs.mkdtemp(join(tmpdir(), "plastic-core-"));
|
|
2722
|
+
const basePath = join(tempDir, `base${safeTempExtension(workspacePath)}`);
|
|
2723
|
+
const emptyWorkspacePath = join(tempDir, `workspace${safeTempExtension(workspacePath)}`);
|
|
2724
|
+
|
|
2725
|
+
try
|
|
2726
|
+
{
|
|
2727
|
+
if (isAdded)
|
|
2728
|
+
{
|
|
2729
|
+
await fs.writeFile(basePath, "");
|
|
2730
|
+
}
|
|
2731
|
+
else
|
|
2732
|
+
{
|
|
2733
|
+
try
|
|
2734
|
+
{
|
|
2735
|
+
await materializeRevision(materializeSpec, basePath, args.workdir);
|
|
2736
|
+
}
|
|
2737
|
+
catch (error)
|
|
2738
|
+
{
|
|
2739
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2740
|
+
if (isRevisionNotFoundError(errorMessage))
|
|
2741
|
+
{
|
|
2742
|
+
const guidance = await buildRevisionNotFoundGuidance(revision.resolved, displayPath || args.path, args.workdir);
|
|
2743
|
+
if (guidance)
|
|
2744
|
+
{
|
|
2745
|
+
throw new Error(`${errorMessage}\n\n${guidance}`);
|
|
2746
|
+
}
|
|
2747
|
+
}
|
|
2748
|
+
throw error;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
if (isDeleted)
|
|
2753
|
+
{
|
|
2754
|
+
await fs.writeFile(emptyWorkspacePath, "");
|
|
2755
|
+
}
|
|
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
|
+
}
|
|
2761
|
+
finally
|
|
2762
|
+
{
|
|
2763
|
+
await fs.rm(tempDir, { recursive: true, force: true });
|
|
2764
|
+
}
|
|
2765
|
+
},
|
|
2766
|
+
});
|
|
2767
|
+
|
|
2768
|
+
export const branchCreate = tool({
|
|
2769
|
+
description: "Create a hierarchical Plastic SCM branch, guarding rare top-level branch creation (cm branch create).",
|
|
2770
|
+
args: {
|
|
2771
|
+
branch: tool.schema.string().min(1).describe("Relative new-branch name or full hierarchical branch path. A relative name uses parent, or the current branch when parent is omitted."),
|
|
2772
|
+
parent: tool.schema.string().optional().describe("Parent branch for a relative branch name. May differ from the workspace branch; Plastic uses the parent's latest changeset by default."),
|
|
2773
|
+
changeset: tool.schema.string().optional().describe("Changeset used as the starting point instead of the parent branch's latest changeset."),
|
|
2774
|
+
label: tool.schema.string().optional().describe("Label used as the starting point."),
|
|
2775
|
+
comment: tool.schema.string().optional().describe("Optional branch comment."),
|
|
2776
|
+
commentsFile: tool.schema.string().optional().describe("File path containing the branch comment."),
|
|
2777
|
+
allowRootBranch: tool.schema.boolean().optional().describe("Allow intentional top-level branch creation such as /<new-branch>. Defaults to false."),
|
|
2778
|
+
workdir: workdirArg,
|
|
2779
|
+
},
|
|
2780
|
+
async execute(args)
|
|
2781
|
+
{
|
|
2782
|
+
if (args.branch.trim().length === 0)
|
|
2783
|
+
{
|
|
2784
|
+
throw new Error("Branch must be non-empty.");
|
|
2785
|
+
}
|
|
2786
|
+
|
|
2787
|
+
if (args.changeset && args.label)
|
|
2788
|
+
{
|
|
2789
|
+
throw new Error("Provide either changeset or label, not both.");
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
if (args.comment && args.commentsFile)
|
|
2793
|
+
{
|
|
2794
|
+
throw new Error("Provide either comment or commentsFile, not both.");
|
|
2795
|
+
}
|
|
2796
|
+
|
|
2797
|
+
if (args.comment !== undefined && args.comment.trim().length === 0)
|
|
2798
|
+
{
|
|
2799
|
+
throw new Error("Comment must be non-empty when provided.");
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
const normalizedRequested = normalizeBranchSpecForComparison(args.branch);
|
|
2803
|
+
const needsParent = !normalizedRequested.startsWith("/");
|
|
2804
|
+
const parentBranch = needsParent
|
|
2805
|
+
? args.parent ?? await resolveCurrentBranchName(args.workdir)
|
|
2806
|
+
: args.parent;
|
|
2807
|
+
const targetBranch = resolveBranchCreationTarget(args.branch, parentBranch, args.allowRootBranch);
|
|
2808
|
+
const cmdArgs: string[] = ["branch", "create", targetBranch];
|
|
2809
|
+
|
|
2810
|
+
if (args.changeset)
|
|
2811
|
+
{
|
|
2812
|
+
cmdArgs.push(`--changeset=${args.changeset}`);
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
if (args.label)
|
|
2816
|
+
{
|
|
2817
|
+
cmdArgs.push(`--label=${args.label}`);
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
if (args.comment)
|
|
2821
|
+
{
|
|
2822
|
+
cmdArgs.push(`-c=${args.comment}`);
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
if (args.commentsFile)
|
|
2826
|
+
{
|
|
2827
|
+
cmdArgs.push(`-commentsfile=${args.commentsFile}`);
|
|
2828
|
+
}
|
|
2829
|
+
|
|
2830
|
+
return runCm(cmdArgs, args.workdir);
|
|
2831
|
+
},
|
|
2832
|
+
});
|
|
2833
|
+
|
|
2834
|
+
export const switchBranch = tool({
|
|
2835
|
+
description: "Switch the Plastic SCM workspace to a branch (cm switch), handling pending changes when needed.",
|
|
2836
|
+
args: {
|
|
2837
|
+
branch: tool.schema.string().min(1).describe("Branch name or spec selected from the current repository convention."),
|
|
2838
|
+
pendingChanges: tool.schema.enum(["shelve", "bring", "cancel"]).optional().describe("How to handle pending changes when switching branches. Defaults to cancel. In unattended mode, bring is blocked only when tracked pending changes exist."),
|
|
2839
|
+
preflight: tool.schema.boolean().optional().describe("Preview switch strategy without executing switch."),
|
|
2840
|
+
format: outputFormatArg,
|
|
2841
|
+
workdir: workdirArg,
|
|
2842
|
+
},
|
|
2843
|
+
async execute(args)
|
|
2844
|
+
{
|
|
2845
|
+
const format = args.format ?? "text";
|
|
2846
|
+
const preflight = args.preflight ?? false;
|
|
2847
|
+
const cwd = args.workdir ?? process.cwd();
|
|
2848
|
+
const branchBefore = await resolveCurrentBranchName(args.workdir);
|
|
2849
|
+
const pendingItems = await getMachineReadablePendingItems(args.workdir);
|
|
2850
|
+
const pendingSummaryDetailed = summarizePendingItems(pendingItems, cwd);
|
|
2851
|
+
const pendingSummary = toLegacyPendingSummary(pendingSummaryDetailed);
|
|
2852
|
+
const pendingProfile = buildSwitchPendingProfile(pendingSummaryDetailed);
|
|
2853
|
+
const targetAlreadyLoaded = isSameBranchSpec(branchBefore, args.branch);
|
|
2854
|
+
const switchCmd = ["switch", "--silent", "--noinput", args.branch];
|
|
2855
|
+
|
|
2856
|
+
if (targetAlreadyLoaded)
|
|
2857
|
+
{
|
|
2858
|
+
const message = "Branch switch skipped: workspace is already on the target branch.";
|
|
2859
|
+
if (preflight)
|
|
2860
|
+
{
|
|
2861
|
+
return toStructuredResult(
|
|
2862
|
+
"switch-branch-preflight",
|
|
2863
|
+
format,
|
|
2864
|
+
formatPreflightText("## Branch Switch Preflight", [
|
|
2865
|
+
"- Would run: no",
|
|
2866
|
+
"- Strategy: already-on-target-branch",
|
|
2867
|
+
`- Branch before: ${branchBefore}`,
|
|
2868
|
+
`- Branch target: ${args.branch}`,
|
|
2869
|
+
`- Reason: ${message}`,
|
|
2870
|
+
]),
|
|
2871
|
+
{
|
|
2872
|
+
wouldRun: false,
|
|
2873
|
+
strategy: "already-on-target-branch",
|
|
2874
|
+
branchBefore,
|
|
2875
|
+
branchAfter: branchBefore,
|
|
2876
|
+
branchTarget: args.branch,
|
|
2877
|
+
pendingSummary,
|
|
2878
|
+
pendingSummaryDetailed,
|
|
2879
|
+
},
|
|
2880
|
+
args.workdir,
|
|
2881
|
+
[message],
|
|
2882
|
+
);
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
return toStructuredResult(
|
|
2886
|
+
"switch-branch",
|
|
2887
|
+
format,
|
|
2888
|
+
message,
|
|
2889
|
+
{
|
|
2890
|
+
strategy: "already-on-target-branch",
|
|
2891
|
+
branchBefore,
|
|
2892
|
+
branchAfter: branchBefore,
|
|
2893
|
+
branchTarget: args.branch,
|
|
2894
|
+
pendingSummary,
|
|
2895
|
+
pendingSummaryDetailed,
|
|
2896
|
+
rawOutput: message,
|
|
2897
|
+
},
|
|
2898
|
+
args.workdir,
|
|
2899
|
+
[message],
|
|
2900
|
+
);
|
|
2901
|
+
}
|
|
2902
|
+
|
|
2903
|
+
if (!pendingProfile.hasPendingChanges)
|
|
2904
|
+
{
|
|
2905
|
+
if (preflight)
|
|
2906
|
+
{
|
|
2907
|
+
return toStructuredResult(
|
|
2908
|
+
"switch-branch-preflight",
|
|
2909
|
+
format,
|
|
2910
|
+
formatPreflightText("## Branch Switch Preflight", [
|
|
2911
|
+
"- Would run: yes",
|
|
2912
|
+
`- Strategy: silent-noinput`,
|
|
2913
|
+
`- Command: cm ${switchCmd.join(" ")}`,
|
|
2914
|
+
`- Branch before: ${branchBefore}`,
|
|
2915
|
+
`- Branch target: ${args.branch}`,
|
|
2916
|
+
]),
|
|
2917
|
+
{
|
|
2918
|
+
wouldRun: true,
|
|
2919
|
+
strategy: "silent-noinput",
|
|
2920
|
+
command: ["cm", ...switchCmd],
|
|
2921
|
+
branchBefore,
|
|
2922
|
+
branchTarget: args.branch,
|
|
2923
|
+
pendingSummary,
|
|
2924
|
+
pendingSummaryDetailed,
|
|
2925
|
+
},
|
|
2926
|
+
args.workdir,
|
|
2927
|
+
);
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
const output = await runCm(switchCmd, args.workdir);
|
|
2931
|
+
const branchAfter = await resolveCurrentBranchName(args.workdir);
|
|
2932
|
+
return toStructuredResult(
|
|
2933
|
+
"switch-branch",
|
|
2934
|
+
format,
|
|
2935
|
+
output,
|
|
2936
|
+
{
|
|
2937
|
+
strategy: "silent-noinput",
|
|
2938
|
+
command: ["cm", ...switchCmd],
|
|
2939
|
+
branchBefore,
|
|
2940
|
+
branchAfter,
|
|
2941
|
+
branchTarget: args.branch,
|
|
2942
|
+
pendingSummary,
|
|
2943
|
+
pendingSummaryDetailed,
|
|
2944
|
+
rawOutput: output,
|
|
2945
|
+
},
|
|
2946
|
+
args.workdir,
|
|
2947
|
+
);
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
const pendingChoice = args.pendingChanges ?? "cancel";
|
|
2951
|
+
const defaulted = args.pendingChanges === undefined;
|
|
2952
|
+
|
|
2953
|
+
if (canSwitchDirectWithPrivateOnlyPending(pendingChoice, defaulted, pendingProfile))
|
|
2954
|
+
{
|
|
2955
|
+
const reason = pendingChoice === "bring"
|
|
2956
|
+
? "pendingChanges=bring requested with private-only pending changes. Running non-interactive switch directly because no tracked changes require interactive bring prompts."
|
|
2957
|
+
: pendingChoice === "shelve"
|
|
2958
|
+
? "pendingChanges=shelve requested with private-only pending changes. Skipping shelveset creation because there are no tracked changes to shelve."
|
|
2959
|
+
: "Pending changes are private-only and pendingChanges was defaulted to cancel. Running non-interactive switch directly for unattended safety.";
|
|
2960
|
+
if (preflight)
|
|
2961
|
+
{
|
|
2962
|
+
return toStructuredResult(
|
|
2963
|
+
"switch-branch-preflight",
|
|
2964
|
+
format,
|
|
2965
|
+
formatPreflightText("## Branch Switch Preflight", [
|
|
2966
|
+
"- Would run: yes",
|
|
2967
|
+
"- Strategy: direct-switch-private-only",
|
|
2968
|
+
`- Command: cm ${switchCmd.join(" ")}`,
|
|
2969
|
+
`- Branch before: ${branchBefore}`,
|
|
2970
|
+
`- Branch target: ${args.branch}`,
|
|
2971
|
+
`- Pending policy: ${pendingChoice}`,
|
|
2972
|
+
`- Reason: ${reason}`,
|
|
2973
|
+
]),
|
|
2974
|
+
{
|
|
2975
|
+
wouldRun: true,
|
|
2976
|
+
strategy: "direct-switch-private-only",
|
|
2977
|
+
command: ["cm", ...switchCmd],
|
|
2978
|
+
branchBefore,
|
|
2979
|
+
branchTarget: args.branch,
|
|
2980
|
+
pendingSummary,
|
|
2981
|
+
pendingSummaryDetailed,
|
|
2982
|
+
pendingPolicy: pendingChoice,
|
|
2983
|
+
defaultedPolicy: defaulted,
|
|
2984
|
+
},
|
|
2985
|
+
args.workdir,
|
|
2986
|
+
[reason],
|
|
2987
|
+
);
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
const output = await runCm(switchCmd, args.workdir);
|
|
2991
|
+
const branchAfter = await resolveCurrentBranchName(args.workdir);
|
|
2992
|
+
return toStructuredResult(
|
|
2993
|
+
"switch-branch",
|
|
2994
|
+
format,
|
|
2995
|
+
output,
|
|
2996
|
+
{
|
|
2997
|
+
strategy: "direct-switch-private-only",
|
|
2998
|
+
command: ["cm", ...switchCmd],
|
|
2999
|
+
branchBefore,
|
|
3000
|
+
branchAfter,
|
|
3001
|
+
branchTarget: args.branch,
|
|
3002
|
+
pendingSummary,
|
|
3003
|
+
pendingSummaryDetailed,
|
|
3004
|
+
pendingPolicy: pendingChoice,
|
|
3005
|
+
defaultedPolicy: defaulted,
|
|
3006
|
+
rawOutput: output,
|
|
3007
|
+
},
|
|
3008
|
+
args.workdir,
|
|
3009
|
+
[reason],
|
|
3010
|
+
);
|
|
3011
|
+
}
|
|
3012
|
+
|
|
3013
|
+
if (isSwitchBringBlockedForUnattended(pendingChoice, pendingProfile))
|
|
3014
|
+
{
|
|
3015
|
+
const reason = "pendingChanges=bring is blocked for unattended runs when tracked pending changes exist because cm switch requires interactive prompts for bring mode. Use pendingChanges=shelve or resolve/shelve tracked changes first.";
|
|
3016
|
+
if (preflight)
|
|
3017
|
+
{
|
|
3018
|
+
return toStructuredResult(
|
|
3019
|
+
"switch-branch-preflight",
|
|
3020
|
+
format,
|
|
3021
|
+
formatPreflightText("## Branch Switch Preflight", [
|
|
3022
|
+
"- Would run: no",
|
|
3023
|
+
"- Strategy: blocked-bring-tracked-pending",
|
|
3024
|
+
`- Branch before: ${branchBefore}`,
|
|
3025
|
+
`- Branch target: ${args.branch}`,
|
|
3026
|
+
`- Reason: ${reason}`,
|
|
3027
|
+
]),
|
|
3028
|
+
{
|
|
3029
|
+
wouldRun: false,
|
|
3030
|
+
strategy: "blocked-bring-tracked-pending",
|
|
3031
|
+
branchBefore,
|
|
3032
|
+
branchTarget: args.branch,
|
|
3033
|
+
pendingSummary,
|
|
3034
|
+
pendingSummaryDetailed,
|
|
3035
|
+
pendingPolicy: pendingChoice,
|
|
3036
|
+
defaultedPolicy: defaulted,
|
|
3037
|
+
},
|
|
3038
|
+
args.workdir,
|
|
3039
|
+
[reason],
|
|
3040
|
+
"Set pendingChanges to shelve, or shelve/clean tracked changes before switching.",
|
|
3041
|
+
);
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
throw new Error(reason);
|
|
3045
|
+
}
|
|
3046
|
+
|
|
3047
|
+
if (pendingChoice === "cancel")
|
|
3048
|
+
{
|
|
3049
|
+
const reason = defaulted
|
|
3050
|
+
? "Switch canceled because pending changes were detected and the default policy is cancel unless pendingChanges is set."
|
|
3051
|
+
: "Switch canceled because pending changes were detected and pendingChanges was set to cancel.";
|
|
3052
|
+
if (preflight)
|
|
3053
|
+
{
|
|
3054
|
+
return toStructuredResult(
|
|
3055
|
+
"switch-branch-preflight",
|
|
3056
|
+
format,
|
|
3057
|
+
formatPreflightText("## Branch Switch Preflight", [
|
|
3058
|
+
"- Would run: no",
|
|
3059
|
+
"- Strategy: cancel-with-pending",
|
|
3060
|
+
`- Branch before: ${branchBefore}`,
|
|
3061
|
+
`- Branch target: ${args.branch}`,
|
|
3062
|
+
`- Reason: ${reason}`,
|
|
3063
|
+
]),
|
|
3064
|
+
{
|
|
3065
|
+
wouldRun: false,
|
|
3066
|
+
strategy: "cancel-with-pending",
|
|
3067
|
+
branchBefore,
|
|
3068
|
+
branchTarget: args.branch,
|
|
3069
|
+
pendingSummary,
|
|
3070
|
+
pendingSummaryDetailed,
|
|
3071
|
+
pendingPolicy: pendingChoice,
|
|
3072
|
+
defaultedPolicy: defaulted,
|
|
3073
|
+
},
|
|
3074
|
+
args.workdir,
|
|
3075
|
+
[reason],
|
|
3076
|
+
"Set pendingChanges to shelve if you want to continue switching with pending changes.",
|
|
3077
|
+
);
|
|
3078
|
+
}
|
|
3079
|
+
|
|
3080
|
+
return toStructuredResult(
|
|
3081
|
+
"switch-branch",
|
|
3082
|
+
format,
|
|
3083
|
+
reason,
|
|
3084
|
+
{
|
|
3085
|
+
strategy: "cancel-with-pending",
|
|
3086
|
+
branchBefore,
|
|
3087
|
+
branchAfter: branchBefore,
|
|
3088
|
+
branchTarget: args.branch,
|
|
3089
|
+
pendingSummary,
|
|
3090
|
+
pendingSummaryDetailed,
|
|
3091
|
+
pendingPolicy: pendingChoice,
|
|
3092
|
+
defaultedPolicy: defaulted,
|
|
3093
|
+
rawOutput: reason,
|
|
3094
|
+
},
|
|
3095
|
+
args.workdir,
|
|
3096
|
+
[reason],
|
|
3097
|
+
"Set pendingChanges to shelve if you want to continue switching with pending changes.",
|
|
3098
|
+
);
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
const shelveComment = `Auto-shelve before switch to ${args.branch}`;
|
|
3102
|
+
const shelveCmd = ["shelveset", "create", "--all", `-c=${shelveComment}`];
|
|
3103
|
+
|
|
3104
|
+
if (preflight)
|
|
3105
|
+
{
|
|
3106
|
+
return toStructuredResult(
|
|
3107
|
+
"switch-branch-preflight",
|
|
3108
|
+
format,
|
|
3109
|
+
formatPreflightText("## Branch Switch Preflight", [
|
|
3110
|
+
"- Would run: yes",
|
|
3111
|
+
"- Strategy: shelve-then-switch-noinput",
|
|
3112
|
+
`- Shelve command: cm ${shelveCmd.join(" ")}`,
|
|
3113
|
+
`- Switch command: cm ${switchCmd.join(" ")}`,
|
|
3114
|
+
`- Branch before: ${branchBefore}`,
|
|
3115
|
+
`- Branch target: ${args.branch}`,
|
|
3116
|
+
]),
|
|
3117
|
+
{
|
|
3118
|
+
wouldRun: true,
|
|
3119
|
+
strategy: "shelve-then-switch-noinput",
|
|
3120
|
+
commands: [
|
|
3121
|
+
["cm", ...shelveCmd],
|
|
3122
|
+
["cm", ...switchCmd],
|
|
3123
|
+
],
|
|
3124
|
+
branchBefore,
|
|
3125
|
+
branchTarget: args.branch,
|
|
3126
|
+
pendingSummary,
|
|
3127
|
+
pendingSummaryDetailed,
|
|
3128
|
+
pendingPolicy: pendingChoice,
|
|
3129
|
+
defaultedPolicy: defaulted,
|
|
3130
|
+
},
|
|
3131
|
+
args.workdir,
|
|
3132
|
+
);
|
|
3133
|
+
}
|
|
3134
|
+
|
|
3135
|
+
let shelveOutput = "";
|
|
3136
|
+
let usedNoChangesShelveRecovery = false;
|
|
3137
|
+
try
|
|
3138
|
+
{
|
|
3139
|
+
shelveOutput = await runCm(shelveCmd, args.workdir);
|
|
3140
|
+
}
|
|
3141
|
+
catch (error)
|
|
3142
|
+
{
|
|
3143
|
+
const errorMessage = normalizeErrorMessage(error);
|
|
3144
|
+
if (!isNoChangesWorkspaceCheckinError(errorMessage))
|
|
3145
|
+
{
|
|
3146
|
+
throw error;
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
const pendingAfterShelveAttempt = await getMachineReadablePendingItems(args.workdir).catch(() => []);
|
|
3150
|
+
const pendingSummaryAfterShelveAttempt = summarizePendingItems(pendingAfterShelveAttempt, cwd);
|
|
3151
|
+
const profileAfterShelveAttempt = buildSwitchPendingProfile(pendingSummaryAfterShelveAttempt);
|
|
3152
|
+
if (profileAfterShelveAttempt.hasTrackedPendingChanges)
|
|
3153
|
+
{
|
|
3154
|
+
throw error;
|
|
3155
|
+
}
|
|
3156
|
+
|
|
3157
|
+
usedNoChangesShelveRecovery = true;
|
|
3158
|
+
shelveOutput = "Skipped shelveset creation because no tracked pending changes were detected after recovery check.";
|
|
3159
|
+
}
|
|
3160
|
+
|
|
3161
|
+
const switchOutput = await runCm(switchCmd, args.workdir);
|
|
3162
|
+
const branchAfter = await resolveCurrentBranchName(args.workdir).catch(() => branchBefore);
|
|
3163
|
+
|
|
3164
|
+
return toStructuredResult(
|
|
3165
|
+
"switch-branch",
|
|
3166
|
+
format,
|
|
3167
|
+
`${shelveOutput}\n${switchOutput}`,
|
|
3168
|
+
{
|
|
3169
|
+
strategy: "shelve-then-switch-noinput",
|
|
3170
|
+
commands: [
|
|
3171
|
+
["cm", ...shelveCmd],
|
|
3172
|
+
["cm", ...switchCmd],
|
|
3173
|
+
],
|
|
3174
|
+
branchBefore,
|
|
3175
|
+
branchAfter,
|
|
3176
|
+
branchTarget: args.branch,
|
|
3177
|
+
pendingSummary,
|
|
3178
|
+
pendingSummaryDetailed,
|
|
3179
|
+
pendingPolicy: pendingChoice,
|
|
3180
|
+
defaultedPolicy: defaulted,
|
|
3181
|
+
usedNoChangesShelveRecovery,
|
|
3182
|
+
rawOutput: {
|
|
3183
|
+
shelve: shelveOutput,
|
|
3184
|
+
switch: switchOutput,
|
|
3185
|
+
},
|
|
3186
|
+
},
|
|
3187
|
+
args.workdir,
|
|
3188
|
+
[
|
|
3189
|
+
...(usedNoChangesShelveRecovery ? ["Recovered from shelveset no-changes error and switched directly because tracked pending changes were no longer present."] : []),
|
|
3190
|
+
],
|
|
3191
|
+
);
|
|
3192
|
+
},
|
|
3193
|
+
});
|
|
3194
|
+
|
|
3195
|
+
export const merge = tool({
|
|
3196
|
+
description: "Merge safely with explicit non-interactive conflict strategy (cm merge --merge --nointeractiveresolution).",
|
|
3197
|
+
args: {
|
|
3198
|
+
source: tool.schema.string().min(1).describe("Source branch/changeset/label/shelveset spec to merge from."),
|
|
3199
|
+
strategy: tool.schema.enum(["auto", "source", "destination"]).optional().describe("Conflict resolution strategy. auto tries non-destructive auto-resolution and aborts if manual conflict remains. source/destination are explicit overrides."),
|
|
3200
|
+
cherrypicking: tool.schema.boolean().optional().describe("Enable cherry-picking mode."),
|
|
3201
|
+
forced: tool.schema.boolean().optional().describe("Skip connected-branch checks when supported by the merge mode."),
|
|
3202
|
+
preflight: tool.schema.boolean().optional().describe("Preview command without executing merge."),
|
|
3203
|
+
format: outputFormatArg,
|
|
3204
|
+
workdir: workdirArg,
|
|
3205
|
+
},
|
|
3206
|
+
async execute(args)
|
|
3207
|
+
{
|
|
3208
|
+
const format = args.format ?? "text";
|
|
3209
|
+
const preflight = args.preflight ?? false;
|
|
3210
|
+
const strategy: MergeConflictStrategy = args.strategy ?? "auto";
|
|
3211
|
+
const cmdArgs: string[] = [
|
|
3212
|
+
"merge",
|
|
3213
|
+
args.source,
|
|
3214
|
+
"--merge",
|
|
3215
|
+
"--nointeractiveresolution",
|
|
3216
|
+
"--machinereadable",
|
|
3217
|
+
`--startlineseparator=${MERGE_START_LINE_SEPARATOR}`,
|
|
3218
|
+
`--endlineseparator=${MERGE_END_LINE_SEPARATOR}`,
|
|
3219
|
+
`--fieldseparator=${MERGE_FIELD_SEPARATOR}`,
|
|
3220
|
+
];
|
|
3221
|
+
|
|
3222
|
+
let conflictPolicyDescription = "auto-try-abort-on-unresolved";
|
|
3223
|
+
|
|
3224
|
+
if (strategy === "auto")
|
|
3225
|
+
{
|
|
3226
|
+
cmdArgs.push("--mergetype=try");
|
|
3227
|
+
}
|
|
3228
|
+
else
|
|
3229
|
+
{
|
|
3230
|
+
const prefersSource = strategy === "source";
|
|
3231
|
+
const strategyFlag = prefersSource ? "--keepsource" : "--keepdestination";
|
|
3232
|
+
const strategySuffix = prefersSource ? "src" : "dst";
|
|
3233
|
+
cmdArgs.push("--mergetype=forced", strategyFlag, `--automaticresolution=all-${strategySuffix}`);
|
|
3234
|
+
conflictPolicyDescription = prefersSource
|
|
3235
|
+
? "forced-prefer-source"
|
|
3236
|
+
: "forced-prefer-destination";
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
if (args.cherrypicking)
|
|
3240
|
+
{
|
|
3241
|
+
cmdArgs.push("--cherrypicking");
|
|
3242
|
+
}
|
|
3243
|
+
|
|
3244
|
+
if (args.forced)
|
|
3245
|
+
{
|
|
3246
|
+
cmdArgs.push("--forced");
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
if (preflight)
|
|
3250
|
+
{
|
|
3251
|
+
return toStructuredResult(
|
|
3252
|
+
"merge-preflight",
|
|
3253
|
+
format,
|
|
3254
|
+
formatPreflightText("## Merge Preflight", [
|
|
3255
|
+
"- Would run: yes",
|
|
3256
|
+
"- Strategy: non-interactive",
|
|
3257
|
+
`- Conflict strategy: ${strategy}`,
|
|
3258
|
+
`- Conflict policy: ${conflictPolicyDescription}`,
|
|
3259
|
+
`- Command: cm ${cmdArgs.join(" ")}`,
|
|
3260
|
+
]),
|
|
3261
|
+
{
|
|
3262
|
+
wouldRun: true,
|
|
3263
|
+
strategy: "non-interactive",
|
|
3264
|
+
conflictStrategy: strategy,
|
|
3265
|
+
conflictPolicy: conflictPolicyDescription,
|
|
3266
|
+
command: ["cm", ...cmdArgs],
|
|
3267
|
+
source: args.source,
|
|
3268
|
+
},
|
|
3269
|
+
args.workdir,
|
|
3270
|
+
);
|
|
3271
|
+
}
|
|
3272
|
+
|
|
3273
|
+
let output: string;
|
|
3274
|
+
try
|
|
3275
|
+
{
|
|
3276
|
+
output = await runCm(cmdArgs, args.workdir);
|
|
3277
|
+
}
|
|
3278
|
+
catch (error)
|
|
3279
|
+
{
|
|
3280
|
+
const errorOutput = normalizeErrorMessage(error);
|
|
3281
|
+
const summary = summarizeMergeOutput(errorOutput);
|
|
3282
|
+
throw new Error(buildMergeAbortMessage(args.source, strategy, errorOutput, summary));
|
|
3283
|
+
}
|
|
3284
|
+
|
|
3285
|
+
const summary = summarizeMergeOutput(output);
|
|
3286
|
+
if (summary.unresolvedSignals.length > 0)
|
|
3287
|
+
{
|
|
3288
|
+
throw new Error(buildMergeAbortMessage(args.source, strategy, output, summary));
|
|
3289
|
+
}
|
|
3290
|
+
|
|
3291
|
+
const shortStatusAfterMerge = await runCmRaw(["status", "--short"], args.workdir).catch(() => "");
|
|
3292
|
+
const fullStatusAfterMerge = await runCmRaw(["status"], args.workdir).catch(() => "");
|
|
3293
|
+
const pendingSummaryAfterMerge = summarizeShortStatus(shortStatusAfterMerge);
|
|
3294
|
+
const mergeStateAfterMerge = analyzeMergeStatusOutput(fullStatusAfterMerge);
|
|
3295
|
+
const reportLines: string[] = [
|
|
3296
|
+
"## Merge Result",
|
|
3297
|
+
"",
|
|
3298
|
+
`- Source: ${args.source}`,
|
|
3299
|
+
`- Conflict strategy: ${strategy}`,
|
|
3300
|
+
`- Conflict policy: ${conflictPolicyDescription}`,
|
|
3301
|
+
`- Merge conflict signals: ${summary.conflictSignals.length}`,
|
|
3302
|
+
`- File conflict paths: ${summary.fileConflictPaths.length}`,
|
|
3303
|
+
`- Merge warning signals: ${summary.warningSignals.length}`,
|
|
3304
|
+
`- Pending items after merge: ${pendingSummaryAfterMerge.totalPending}`,
|
|
3305
|
+
`- Pending merge links after merge: ${mergeStateAfterMerge.pendingMergeLinks.length}`,
|
|
3306
|
+
`- Merge-in-progress hints after merge: ${mergeStateAfterMerge.mergeInProgressHints.length}`,
|
|
3307
|
+
];
|
|
3308
|
+
|
|
3309
|
+
if (summary.conflictSignals.length > 0)
|
|
3310
|
+
{
|
|
3311
|
+
reportLines.push("", "Conflict signal details:");
|
|
3312
|
+
for (const signal of summary.conflictSignals.slice(0, 20))
|
|
3313
|
+
{
|
|
3314
|
+
reportLines.push(`- ${signal}`);
|
|
3315
|
+
}
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
if (summary.warningSignals.length > 0)
|
|
3319
|
+
{
|
|
3320
|
+
reportLines.push("", "Warning signal details:");
|
|
3321
|
+
for (const signal of summary.warningSignals.slice(0, 20))
|
|
3322
|
+
{
|
|
3323
|
+
reportLines.push(`- ${signal}`);
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
|
|
3327
|
+
if (output.trim().length > 0 && output.trim() !== "(no output)")
|
|
3328
|
+
{
|
|
3329
|
+
reportLines.push("", "Raw merge output:", output.trim());
|
|
3330
|
+
}
|
|
3331
|
+
|
|
3332
|
+
const warnings: string[] = [
|
|
3333
|
+
`Merged conflict signals reported by Plastic: ${summary.conflictSignals.length}.`,
|
|
3334
|
+
];
|
|
3335
|
+
|
|
3336
|
+
return toStructuredResult(
|
|
3337
|
+
"merge",
|
|
3338
|
+
format,
|
|
3339
|
+
reportLines.join("\n"),
|
|
3340
|
+
{
|
|
3341
|
+
strategy: "non-interactive",
|
|
3342
|
+
conflictStrategy: strategy,
|
|
3343
|
+
conflictPolicy: conflictPolicyDescription,
|
|
3344
|
+
command: ["cm", ...cmdArgs],
|
|
3345
|
+
source: args.source,
|
|
3346
|
+
mergeSummary: summary,
|
|
3347
|
+
shortStatusAfterMerge,
|
|
3348
|
+
pendingSummaryAfterMerge,
|
|
3349
|
+
fullStatusAfterMerge,
|
|
3350
|
+
mergeStateAfterMerge,
|
|
3351
|
+
rawOutput: output,
|
|
3352
|
+
},
|
|
3353
|
+
args.workdir,
|
|
3354
|
+
warnings,
|
|
3355
|
+
);
|
|
3356
|
+
},
|
|
3357
|
+
});
|
|
3358
|
+
|
|
3359
|
+
export const mergeToBranch = tool({
|
|
3360
|
+
description: "Merge a source branch into a target branch using safe Plastic update, switch, merge, and checkin steps.",
|
|
3361
|
+
args: {
|
|
3362
|
+
source: tool.schema.string().optional().describe("Source branch to merge. Defaults to the currently loaded branch."),
|
|
3363
|
+
target: tool.schema.string().optional().describe("Target branch to merge into. Defaults to the source branch's Plastic parent branch."),
|
|
3364
|
+
cardRef: tool.schema.string().optional().describe("Optional tracker card reference to include in the merge checkin message, for example $4re."),
|
|
3365
|
+
message: tool.schema.string().optional().describe("Optional checkin message. Defaults to Merge <source> into <target>."),
|
|
3366
|
+
strategy: tool.schema.enum(["auto", "source", "destination"]).optional().describe("Merge conflict strategy. Defaults to auto."),
|
|
3367
|
+
updateTarget: tool.schema.boolean().optional().describe("Run plastic_update on the target branch before merging. Defaults to true."),
|
|
3368
|
+
includePrivate: tool.schema.boolean().optional().describe("Include private items in the merge checkin. Defaults to false."),
|
|
3369
|
+
preflight: tool.schema.boolean().optional().describe("Preview the planned closeout merge steps without executing them."),
|
|
3370
|
+
format: outputFormatArg,
|
|
3371
|
+
workdir: workdirArg,
|
|
3372
|
+
},
|
|
3373
|
+
async execute(args)
|
|
3374
|
+
{
|
|
3375
|
+
const format = args.format ?? "text";
|
|
3376
|
+
const startingBranch = await resolveCurrentBranchName(args.workdir);
|
|
3377
|
+
const sourceBranch = args.source ?? startingBranch;
|
|
3378
|
+
const resolvedParentBranch = args.target ? undefined : await resolveBranchParentName(sourceBranch, args.workdir);
|
|
3379
|
+
if (!args.target && !resolvedParentBranch)
|
|
3380
|
+
{
|
|
3381
|
+
throw new Error(`Unable to resolve the parent branch for ${sourceBranch}. Pass target explicitly.`);
|
|
3382
|
+
}
|
|
3383
|
+
const targetBranch = args.target ?? resolvedParentBranch!;
|
|
3384
|
+
const strategy: MergeConflictStrategy = args.strategy ?? "auto";
|
|
3385
|
+
const updateTarget = args.updateTarget ?? true;
|
|
3386
|
+
const includePrivate = args.includePrivate ?? false;
|
|
3387
|
+
const cardLine = args.cardRef?.trim() ? `${args.cardRef.trim()}\n\n` : "";
|
|
3388
|
+
const checkinMessage = args.message?.trim()
|
|
3389
|
+
? args.message.trim()
|
|
3390
|
+
: `Merge ${sourceBranch} into ${targetBranch}\n\n${cardLine}Co-authored-by: Pi <pi@earendil.works>`;
|
|
3391
|
+
|
|
3392
|
+
if (isSameBranchSpec(sourceBranch, targetBranch))
|
|
3393
|
+
{
|
|
3394
|
+
throw new Error(`Refusing to merge ${sourceBranch} into itself.`);
|
|
3395
|
+
}
|
|
3396
|
+
|
|
3397
|
+
const plannedSteps = [
|
|
3398
|
+
`Resolve source branch: ${sourceBranch}`,
|
|
3399
|
+
`Switch to target branch: ${targetBranch}`,
|
|
3400
|
+
...(updateTarget ? [`Update target branch safely: ${targetBranch}`] : []),
|
|
3401
|
+
`Merge source into target with strategy=${strategy}: ${sourceBranch}`,
|
|
3402
|
+
"Inspect merge status for unresolved hints.",
|
|
3403
|
+
"Check in merge result.",
|
|
3404
|
+
];
|
|
3405
|
+
|
|
3406
|
+
if (args.preflight)
|
|
3407
|
+
{
|
|
3408
|
+
const pendingItems = await getMachineReadablePendingItems(args.workdir);
|
|
3409
|
+
const pendingSummary = summarizePendingItems(pendingItems, args.workdir ?? process.cwd());
|
|
3410
|
+
return toStructuredResult(
|
|
3411
|
+
"merge-to-branch-preflight",
|
|
3412
|
+
format,
|
|
3413
|
+
formatPreflightText("## Merge To Branch Preflight", [
|
|
3414
|
+
`- Starting branch: ${startingBranch}`,
|
|
3415
|
+
`- Source branch: ${sourceBranch}`,
|
|
3416
|
+
`- Target branch: ${targetBranch}`,
|
|
3417
|
+
`- Target source: ${args.target ? "explicit argument" : "source branch parent"}`,
|
|
3418
|
+
`- Update target: ${updateTarget ? "yes" : "no"}`,
|
|
3419
|
+
`- Conflict strategy: ${strategy}`,
|
|
3420
|
+
`- Include private: ${includePrivate ? "yes" : "no"}`,
|
|
3421
|
+
`- Pending items before switch: ${pendingSummary.totalPending}`,
|
|
3422
|
+
"",
|
|
3423
|
+
"Planned steps:",
|
|
3424
|
+
...plannedSteps.map((step) => `- ${step}`),
|
|
3425
|
+
"",
|
|
3426
|
+
"Checkin message:",
|
|
3427
|
+
checkinMessage,
|
|
3428
|
+
]),
|
|
3429
|
+
{
|
|
3430
|
+
wouldRun: true,
|
|
3431
|
+
startingBranch,
|
|
3432
|
+
sourceBranch,
|
|
3433
|
+
targetBranch,
|
|
3434
|
+
targetSource: args.target ? "explicit" : "parent",
|
|
3435
|
+
resolvedParentBranch: resolvedParentBranch ?? null,
|
|
3436
|
+
updateTarget,
|
|
3437
|
+
conflictStrategy: strategy,
|
|
3438
|
+
includePrivate,
|
|
3439
|
+
pendingSummary,
|
|
3440
|
+
checkinMessage,
|
|
3441
|
+
plannedSteps,
|
|
3442
|
+
},
|
|
3443
|
+
args.workdir,
|
|
3444
|
+
);
|
|
3445
|
+
}
|
|
3446
|
+
|
|
3447
|
+
const switchResult = await switchBranch.execute({
|
|
3448
|
+
branch: targetBranch,
|
|
3449
|
+
pendingChanges: "cancel",
|
|
3450
|
+
format: "json",
|
|
3451
|
+
workdir: args.workdir,
|
|
3452
|
+
});
|
|
3453
|
+
const updateResult = updateTarget ? await update.execute({ workdir: args.workdir }) : "(skipped)";
|
|
3454
|
+
const mergeResult = await merge.execute({
|
|
3455
|
+
source: sourceBranch,
|
|
3456
|
+
strategy,
|
|
3457
|
+
format: "json",
|
|
3458
|
+
workdir: args.workdir,
|
|
3459
|
+
});
|
|
3460
|
+
|
|
3461
|
+
const fullStatusAfterMerge = await runCmRaw(["status"], args.workdir).catch(() => "");
|
|
3462
|
+
const mergeStateAfterMerge = analyzeMergeStatusOutput(fullStatusAfterMerge);
|
|
3463
|
+
if (mergeStateAfterMerge.hasMergeInProgress)
|
|
3464
|
+
{
|
|
3465
|
+
return toStructuredResult(
|
|
3466
|
+
"merge-to-branch",
|
|
3467
|
+
format,
|
|
3468
|
+
[
|
|
3469
|
+
"## Merge To Branch Paused",
|
|
3470
|
+
"",
|
|
3471
|
+
`- Source branch: ${sourceBranch}`,
|
|
3472
|
+
`- Target branch: ${targetBranch}`,
|
|
3473
|
+
"- Merge completed, but merge-in-progress hints remain.",
|
|
3474
|
+
"- Do not check in until merge metadata is resolved/finalized.",
|
|
3475
|
+
].join("\n"),
|
|
3476
|
+
{
|
|
3477
|
+
sourceBranch,
|
|
3478
|
+
targetBranch,
|
|
3479
|
+
switchResult,
|
|
3480
|
+
updateResult,
|
|
3481
|
+
mergeResult,
|
|
3482
|
+
fullStatusAfterMerge,
|
|
3483
|
+
mergeStateAfterMerge,
|
|
3484
|
+
checkedIn: false,
|
|
3485
|
+
},
|
|
3486
|
+
args.workdir,
|
|
3487
|
+
["Merge-in-progress hints remain after merge; checkin skipped."],
|
|
3488
|
+
"Resolve/finalize the merge metadata, validate, then run plastic_checkin for the merge result.",
|
|
3489
|
+
);
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
const preflightResult = await checkin.execute({
|
|
3493
|
+
message: checkinMessage,
|
|
3494
|
+
includeAll: true,
|
|
3495
|
+
includePrivate,
|
|
3496
|
+
preflight: true,
|
|
3497
|
+
format: "json",
|
|
3498
|
+
workdir: args.workdir,
|
|
3499
|
+
});
|
|
3500
|
+
const checkinResult = await checkin.execute({
|
|
3501
|
+
message: checkinMessage,
|
|
3502
|
+
includeAll: true,
|
|
3503
|
+
includePrivate,
|
|
3504
|
+
format: "json",
|
|
3505
|
+
workdir: args.workdir,
|
|
3506
|
+
});
|
|
3507
|
+
const finalBranch = await resolveCurrentBranchName(args.workdir);
|
|
3508
|
+
const finalShortStatus = await runCmRaw(["status", "--short"], args.workdir).catch(() => "");
|
|
3509
|
+
const finalPendingSummary = summarizeShortStatus(finalShortStatus);
|
|
3510
|
+
|
|
3511
|
+
return toStructuredResult(
|
|
3512
|
+
"merge-to-branch",
|
|
3513
|
+
format,
|
|
3514
|
+
[
|
|
3515
|
+
"## Merge To Branch Complete",
|
|
3516
|
+
"",
|
|
3517
|
+
`- Source branch: ${sourceBranch}`,
|
|
3518
|
+
`- Target branch: ${targetBranch}`,
|
|
3519
|
+
`- Final branch: ${finalBranch}`,
|
|
3520
|
+
`- Update target: ${updateTarget ? "yes" : "no"}`,
|
|
3521
|
+
`- Conflict strategy: ${strategy}`,
|
|
3522
|
+
`- Merge checkin completed: yes`,
|
|
3523
|
+
`- Pending items after checkin: ${finalPendingSummary.totalPending}`,
|
|
3524
|
+
].join("\n"),
|
|
3525
|
+
{
|
|
3526
|
+
sourceBranch,
|
|
3527
|
+
targetBranch,
|
|
3528
|
+
targetSource: args.target ? "explicit" : "parent",
|
|
3529
|
+
resolvedParentBranch: resolvedParentBranch ?? null,
|
|
3530
|
+
finalBranch,
|
|
3531
|
+
updateTarget,
|
|
3532
|
+
conflictStrategy: strategy,
|
|
3533
|
+
includePrivate,
|
|
3534
|
+
checkinMessage,
|
|
3535
|
+
switchResult,
|
|
3536
|
+
updateResult,
|
|
3537
|
+
mergeResult,
|
|
3538
|
+
preflightResult,
|
|
3539
|
+
checkinResult,
|
|
3540
|
+
finalShortStatus,
|
|
3541
|
+
finalPendingSummary,
|
|
3542
|
+
checkedIn: true,
|
|
3543
|
+
},
|
|
3544
|
+
args.workdir,
|
|
3545
|
+
);
|
|
3546
|
+
},
|
|
3547
|
+
});
|
|
3548
|
+
|
|
3549
|
+
export const finalizeMerge = tool({
|
|
3550
|
+
description: "Finalize Plastic merge metadata after manual conflict resolution using an explicit non-interactive source/destination policy.",
|
|
3551
|
+
args: {
|
|
3552
|
+
source: tool.schema.string().min(1).describe("Original source branch/changeset/label/shelveset spec being merged."),
|
|
3553
|
+
strategy: tool.schema.enum(["source", "destination"]).optional().describe("Explicit finalization policy. destination preserves destination/manual workspace resolution in typical post-resolution flows; source accepts source where Plastic still needs a policy."),
|
|
3554
|
+
preflight: tool.schema.boolean().optional().describe("Preview finalization command without executing it."),
|
|
3555
|
+
format: outputFormatArg,
|
|
3556
|
+
workdir: workdirArg,
|
|
3557
|
+
},
|
|
3558
|
+
async execute(args)
|
|
3559
|
+
{
|
|
3560
|
+
const format = args.format ?? "text";
|
|
3561
|
+
const preflight = args.preflight ?? false;
|
|
3562
|
+
const strategy = args.strategy ?? "destination";
|
|
3563
|
+
const prefersSource = strategy === "source";
|
|
3564
|
+
const strategyFlag = prefersSource ? "--keepsource" : "--keepdestination";
|
|
3565
|
+
const strategySuffix = prefersSource ? "src" : "dst";
|
|
3566
|
+
const cmdArgs: string[] = [
|
|
3567
|
+
"merge",
|
|
3568
|
+
args.source,
|
|
3569
|
+
"--merge",
|
|
3570
|
+
"--nointeractiveresolution",
|
|
3571
|
+
"--mergetype=forced",
|
|
3572
|
+
strategyFlag,
|
|
3573
|
+
`--automaticresolution=all-${strategySuffix}`,
|
|
3574
|
+
"--machinereadable",
|
|
3575
|
+
`--startlineseparator=${MERGE_START_LINE_SEPARATOR}`,
|
|
3576
|
+
`--endlineseparator=${MERGE_END_LINE_SEPARATOR}`,
|
|
3577
|
+
`--fieldseparator=${MERGE_FIELD_SEPARATOR}`,
|
|
3578
|
+
];
|
|
3579
|
+
|
|
3580
|
+
if (preflight)
|
|
3581
|
+
{
|
|
3582
|
+
return toStructuredResult(
|
|
3583
|
+
"finalize-merge-preflight",
|
|
3584
|
+
format,
|
|
3585
|
+
formatPreflightText("## Merge Finalization Preflight", [
|
|
3586
|
+
"- Would run: yes",
|
|
3587
|
+
`- Source: ${args.source}`,
|
|
3588
|
+
`- Strategy: ${strategy}`,
|
|
3589
|
+
`- Command: cm ${cmdArgs.join(" ")}`,
|
|
3590
|
+
"- Use this only after reviewing/resolving files and validating the workspace.",
|
|
3591
|
+
]),
|
|
3592
|
+
{
|
|
3593
|
+
wouldRun: true,
|
|
3594
|
+
source: args.source,
|
|
3595
|
+
strategy,
|
|
3596
|
+
command: ["cm", ...cmdArgs],
|
|
3597
|
+
},
|
|
3598
|
+
args.workdir,
|
|
3599
|
+
);
|
|
3600
|
+
}
|
|
3601
|
+
|
|
3602
|
+
const output = await runCm(cmdArgs, args.workdir);
|
|
3603
|
+
const summary = summarizeMergeOutput(output);
|
|
3604
|
+
const shortStatusAfterFinalize = await runCmRaw(["status", "--short"], args.workdir).catch(() => "");
|
|
3605
|
+
const fullStatusAfterFinalize = await runCmRaw(["status"], args.workdir).catch(() => "");
|
|
3606
|
+
const pendingSummaryAfterFinalize = summarizeShortStatus(shortStatusAfterFinalize);
|
|
3607
|
+
const mergeStateAfterFinalize = analyzeMergeStatusOutput(fullStatusAfterFinalize);
|
|
3608
|
+
const reportLines: string[] = [
|
|
3609
|
+
"## Merge Finalization Result",
|
|
3610
|
+
"",
|
|
3611
|
+
`- Source: ${args.source}`,
|
|
3612
|
+
`- Strategy: ${strategy}`,
|
|
3613
|
+
`- Conflict signals from finalization command: ${summary.conflictSignals.length}`,
|
|
3614
|
+
`- File conflict paths from finalization command: ${summary.fileConflictPaths.length}`,
|
|
3615
|
+
`- Pending items after finalization: ${pendingSummaryAfterFinalize.totalPending}`,
|
|
3616
|
+
`- Pending merge links after finalization: ${mergeStateAfterFinalize.pendingMergeLinks.length}`,
|
|
3617
|
+
`- Merge-in-progress hints after finalization: ${mergeStateAfterFinalize.mergeInProgressHints.length}`,
|
|
3618
|
+
];
|
|
3619
|
+
|
|
3620
|
+
if (summary.fileConflictPaths.length > 0)
|
|
3621
|
+
{
|
|
3622
|
+
reportLines.push("", "File conflict paths reported during finalization:");
|
|
3623
|
+
for (const conflictPath of summary.fileConflictPaths.slice(0, 20))
|
|
3624
|
+
{
|
|
3625
|
+
reportLines.push(`- ${conflictPath}`);
|
|
3626
|
+
}
|
|
3627
|
+
}
|
|
3628
|
+
|
|
3629
|
+
if (output.trim().length > 0 && output.trim() !== "(no output)")
|
|
3630
|
+
{
|
|
3631
|
+
reportLines.push("", "Raw finalization output:", output.trim());
|
|
3632
|
+
}
|
|
3633
|
+
|
|
3634
|
+
return toStructuredResult(
|
|
3635
|
+
"finalize-merge",
|
|
3636
|
+
format,
|
|
3637
|
+
reportLines.join("\n"),
|
|
3638
|
+
{
|
|
3639
|
+
source: args.source,
|
|
3640
|
+
strategy,
|
|
3641
|
+
command: ["cm", ...cmdArgs],
|
|
3642
|
+
mergeSummary: summary,
|
|
3643
|
+
shortStatusAfterFinalize,
|
|
3644
|
+
fullStatusAfterFinalize,
|
|
3645
|
+
pendingSummaryAfterFinalize,
|
|
3646
|
+
mergeStateAfterFinalize,
|
|
3647
|
+
rawOutput: output,
|
|
3648
|
+
},
|
|
3649
|
+
args.workdir,
|
|
3650
|
+
[
|
|
3651
|
+
"Review plastic_status() before checkin. Pending merge links are expected until the merge result is checked in; merge-in-progress hints are not.",
|
|
3652
|
+
],
|
|
3653
|
+
mergeStateAfterFinalize.hasMergeInProgress
|
|
3654
|
+
? "Resolve remaining merge-in-progress state before checkin."
|
|
3655
|
+
: "If validation passes, run plastic_checkin(...) to record the merge result.",
|
|
3656
|
+
);
|
|
3657
|
+
},
|
|
3658
|
+
});
|
|
3659
|
+
|
|
3660
|
+
export const currentBranch = tool({
|
|
3661
|
+
description: "Get the current Plastic SCM branch from workspace status output.",
|
|
3662
|
+
args: {
|
|
3663
|
+
format: outputFormatArg,
|
|
3664
|
+
workdir: workdirArg,
|
|
3665
|
+
},
|
|
3666
|
+
async execute(args)
|
|
3667
|
+
{
|
|
3668
|
+
const format = args.format ?? "text";
|
|
3669
|
+
const branch = await resolveCurrentBranchName(args.workdir);
|
|
3670
|
+
return toStructuredResult(
|
|
3671
|
+
"current-branch",
|
|
3672
|
+
format,
|
|
3673
|
+
branch,
|
|
3674
|
+
{
|
|
3675
|
+
branch,
|
|
3676
|
+
},
|
|
3677
|
+
args.workdir,
|
|
3678
|
+
);
|
|
3679
|
+
},
|
|
3680
|
+
});
|
|
3681
|
+
|
|
3682
|
+
export const branchList = tool({
|
|
3683
|
+
description: "List Plastic SCM branches using cm find branch with optional filters.",
|
|
3684
|
+
args: {
|
|
3685
|
+
nameLike: tool.schema.string().optional().describe("Filter by branch name pattern (supports % wildcard)."),
|
|
3686
|
+
parent: tool.schema.string().optional().describe("Filter by parent branch spec."),
|
|
3687
|
+
owner: tool.schema.string().optional().describe("Filter by branch owner."),
|
|
3688
|
+
includeHidden: tool.schema.boolean().optional().describe("Include hidden branches in the query result."),
|
|
3689
|
+
limit: tool.schema.number().int().min(1).optional().describe("Maximum number of branches to return."),
|
|
3690
|
+
orderBy: tool.schema.enum(["date", "branchname"]).optional().describe("Sort field for branch queries."),
|
|
3691
|
+
descending: tool.schema.boolean().optional().describe("Sort descending when true."),
|
|
3692
|
+
workdir: workdirArg,
|
|
3693
|
+
},
|
|
3694
|
+
async execute(args)
|
|
3695
|
+
{
|
|
3696
|
+
const whereClauses: string[] = [];
|
|
3697
|
+
|
|
3698
|
+
if (args.nameLike)
|
|
3699
|
+
{
|
|
3700
|
+
whereClauses.push(cmWhereLike("name", args.nameLike));
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
if (args.parent)
|
|
3704
|
+
{
|
|
3705
|
+
whereClauses.push(cmWhereEquals("parent", args.parent));
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
if (args.owner)
|
|
3709
|
+
{
|
|
3710
|
+
whereClauses.push(cmWhereEquals("owner", args.owner));
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
if (args.includeHidden !== true)
|
|
3714
|
+
{
|
|
3715
|
+
whereClauses.push("hidden = 'false'");
|
|
3716
|
+
}
|
|
3717
|
+
else
|
|
3718
|
+
{
|
|
3719
|
+
whereClauses.push("(hidden = 'true' or hidden = 'false')");
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
const cmdArgs: string[] = ["find", "branch"];
|
|
3723
|
+
if (whereClauses.length > 0)
|
|
3724
|
+
{
|
|
3725
|
+
cmdArgs.push(`where ${whereClauses.join(" and ")}`);
|
|
3726
|
+
}
|
|
3727
|
+
|
|
3728
|
+
if (args.orderBy)
|
|
3729
|
+
{
|
|
3730
|
+
cmdArgs.push(`order by ${args.orderBy}${args.descending ? " desc" : " asc"}`);
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
if (args.limit)
|
|
3734
|
+
{
|
|
3735
|
+
cmdArgs.push(`limit ${args.limit}`);
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
cmdArgs.push("--nototal");
|
|
3739
|
+
return runCm(cmdArgs, args.workdir);
|
|
3740
|
+
},
|
|
3741
|
+
});
|
|
3742
|
+
|
|
3743
|
+
export const branchExists = tool({
|
|
3744
|
+
description: "Check whether a branch exists using cm find branch.",
|
|
3745
|
+
args: {
|
|
3746
|
+
branch: tool.schema.string().min(1).describe("Branch name to check."),
|
|
3747
|
+
workdir: workdirArg,
|
|
3748
|
+
},
|
|
3749
|
+
async execute(args)
|
|
3750
|
+
{
|
|
3751
|
+
// Plastic's `name` query field contains only the leaf segment even though
|
|
3752
|
+
// `{name}` renders the full branch path. Query by leaf, then compare the
|
|
3753
|
+
// returned full paths so identical leaf names under other parents do not
|
|
3754
|
+
// produce a false positive.
|
|
3755
|
+
const output = await runCmRaw([
|
|
3756
|
+
"find",
|
|
3757
|
+
"branch",
|
|
3758
|
+
`where ${cmWhereEquals("name", getBranchLeafName(args.branch))}`,
|
|
3759
|
+
"--format={name}",
|
|
3760
|
+
"--nototal",
|
|
3761
|
+
], args.workdir);
|
|
3762
|
+
const exists = normalizeFindOutputLines(output).some((branch) => isSameBranchSpec(branch, args.branch));
|
|
3763
|
+
return exists ? "true" : "false";
|
|
3764
|
+
},
|
|
3765
|
+
});
|
|
3766
|
+
|
|
3767
|
+
export const branchDelete = tool({
|
|
3768
|
+
description: "Delete a Plastic SCM branch (cm branch delete).",
|
|
3769
|
+
args: {
|
|
3770
|
+
branch: tool.schema.string().min(1).describe("Branch spec to delete."),
|
|
3771
|
+
deleteChangesets: tool.schema.boolean().optional().describe("Delete changesets inside the branch when required."),
|
|
3772
|
+
workdir: workdirArg,
|
|
3773
|
+
},
|
|
3774
|
+
async execute(args)
|
|
3775
|
+
{
|
|
3776
|
+
const cmdArgs: string[] = ["branch", "delete", args.branch];
|
|
3777
|
+
|
|
3778
|
+
if (args.deleteChangesets)
|
|
3779
|
+
{
|
|
3780
|
+
cmdArgs.push("--delete-changesets");
|
|
3781
|
+
}
|
|
3782
|
+
|
|
3783
|
+
return runCm(cmdArgs, args.workdir);
|
|
3784
|
+
},
|
|
3785
|
+
});
|
|
3786
|
+
|
|
3787
|
+
export const shelvesetCreate = tool({
|
|
3788
|
+
description: "Create a shelveset (cm shelveset create).",
|
|
3789
|
+
args: {
|
|
3790
|
+
comment: tool.schema.string().optional().describe("Shelveset comment."),
|
|
3791
|
+
commentsFile: tool.schema.string().optional().describe("File path containing shelveset comment."),
|
|
3792
|
+
paths: tool.schema.array(tool.schema.string()).optional().describe("Optional item paths to shelve."),
|
|
3793
|
+
all: tool.schema.boolean().optional().describe("Include changed, moved, and deleted items."),
|
|
3794
|
+
dependencies: tool.schema.boolean().optional().describe("Include local change dependencies."),
|
|
3795
|
+
summaryFormat: tool.schema.boolean().optional().describe("Print only created shelveset spec for automation."),
|
|
3796
|
+
workdir: workdirArg,
|
|
3797
|
+
},
|
|
3798
|
+
async execute(args)
|
|
3799
|
+
{
|
|
3800
|
+
if (args.comment && args.commentsFile)
|
|
3801
|
+
{
|
|
3802
|
+
throw new Error("Provide either comment or commentsFile, not both.");
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
if (args.comment !== undefined && args.comment.trim().length === 0)
|
|
3806
|
+
{
|
|
3807
|
+
throw new Error("Comment must be non-empty when provided.");
|
|
3808
|
+
}
|
|
3809
|
+
|
|
3810
|
+
const cmdArgs: string[] = ["shelveset", "create"];
|
|
3811
|
+
|
|
3812
|
+
if (args.paths && args.paths.length > 0)
|
|
3813
|
+
{
|
|
3814
|
+
cmdArgs.push(...args.paths);
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
if (args.all)
|
|
3818
|
+
{
|
|
3819
|
+
cmdArgs.push("--all");
|
|
3820
|
+
}
|
|
3821
|
+
|
|
3822
|
+
if (args.dependencies)
|
|
3823
|
+
{
|
|
3824
|
+
cmdArgs.push("--dependencies");
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
if (args.summaryFormat)
|
|
3828
|
+
{
|
|
3829
|
+
cmdArgs.push("--summaryformat");
|
|
3830
|
+
}
|
|
3831
|
+
|
|
3832
|
+
if (args.comment)
|
|
3833
|
+
{
|
|
3834
|
+
cmdArgs.push(`-c=${args.comment}`);
|
|
3835
|
+
}
|
|
3836
|
+
|
|
3837
|
+
if (args.commentsFile)
|
|
3838
|
+
{
|
|
3839
|
+
cmdArgs.push(`-commentsfile=${args.commentsFile}`);
|
|
3840
|
+
}
|
|
3841
|
+
|
|
3842
|
+
return runCm(cmdArgs, args.workdir);
|
|
3843
|
+
},
|
|
3844
|
+
});
|
|
3845
|
+
|
|
3846
|
+
export const shelvesetApply = tool({
|
|
3847
|
+
description: "Apply a shelveset (cm shelveset apply).",
|
|
3848
|
+
args: {
|
|
3849
|
+
shelveset: tool.schema.string().min(1).describe("Shelveset spec to apply (for example, sh:3)."),
|
|
3850
|
+
changePaths: tool.schema.array(tool.schema.string()).optional().describe("Optional shelve server paths to apply."),
|
|
3851
|
+
preview: tool.schema.boolean().optional().describe("Preview changes without applying them."),
|
|
3852
|
+
dontCheckout: tool.schema.boolean().optional().describe("Keep applied changes as local modifications without checkout."),
|
|
3853
|
+
comparisonMethod: tool.schema.enum([
|
|
3854
|
+
"ignoreeol",
|
|
3855
|
+
"ignorewhitespaces",
|
|
3856
|
+
"ignoreeolandwhitespaces",
|
|
3857
|
+
"recognizeall",
|
|
3858
|
+
]).optional().describe("Comparison method used when applying changes."),
|
|
3859
|
+
workdir: workdirArg,
|
|
3860
|
+
},
|
|
3861
|
+
async execute(args)
|
|
3862
|
+
{
|
|
3863
|
+
const cmdArgs: string[] = ["shelveset", "apply", args.shelveset];
|
|
3864
|
+
|
|
3865
|
+
if (args.changePaths && args.changePaths.length > 0)
|
|
3866
|
+
{
|
|
3867
|
+
cmdArgs.push(...args.changePaths);
|
|
3868
|
+
}
|
|
3869
|
+
|
|
3870
|
+
if (args.preview)
|
|
3871
|
+
{
|
|
3872
|
+
cmdArgs.push("--preview");
|
|
3873
|
+
}
|
|
3874
|
+
|
|
3875
|
+
if (args.dontCheckout)
|
|
3876
|
+
{
|
|
3877
|
+
cmdArgs.push("--dontcheckout");
|
|
3878
|
+
}
|
|
3879
|
+
|
|
3880
|
+
if (args.comparisonMethod)
|
|
3881
|
+
{
|
|
3882
|
+
cmdArgs.push(`--comparisonmethod=${args.comparisonMethod}`);
|
|
3883
|
+
}
|
|
3884
|
+
|
|
3885
|
+
return runCm(cmdArgs, args.workdir);
|
|
3886
|
+
},
|
|
3887
|
+
});
|
|
3888
|
+
|
|
3889
|
+
export const shelvesetDelete = tool({
|
|
3890
|
+
description: "Delete a shelveset (cm shelveset delete).",
|
|
3891
|
+
args: {
|
|
3892
|
+
shelveset: tool.schema.string().min(1).describe("Shelveset spec to delete (for example, sh:3)."),
|
|
3893
|
+
workdir: workdirArg,
|
|
3894
|
+
},
|
|
3895
|
+
async execute(args)
|
|
3896
|
+
{
|
|
3897
|
+
return runCm(["shelveset", "delete", args.shelveset], args.workdir);
|
|
3898
|
+
},
|
|
3899
|
+
});
|
|
3900
|
+
|
|
3901
|
+
export const shelvesetList = tool({
|
|
3902
|
+
description: "List shelvesets using cm find shelve.",
|
|
3903
|
+
args: {
|
|
3904
|
+
owner: tool.schema.string().optional().describe("Filter by shelveset owner."),
|
|
3905
|
+
commentLike: tool.schema.string().optional().describe("Filter by shelveset comment pattern (supports % wildcard)."),
|
|
3906
|
+
limit: tool.schema.number().int().min(1).optional().describe("Maximum number of shelvesets to return."),
|
|
3907
|
+
dateFrom: tool.schema.string().optional().describe("Filter shelvesets created on or after this date/date constant."),
|
|
3908
|
+
format: tool.schema.string().optional().describe("Format string for query output."),
|
|
3909
|
+
dateFormat: tool.schema.string().optional().describe("Date format for query output."),
|
|
3910
|
+
workdir: workdirArg,
|
|
3911
|
+
},
|
|
3912
|
+
async execute(args)
|
|
3913
|
+
{
|
|
3914
|
+
const whereClauses: string[] = [];
|
|
3915
|
+
|
|
3916
|
+
if (args.owner)
|
|
3917
|
+
{
|
|
3918
|
+
whereClauses.push(cmWhereEquals("owner", args.owner));
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3921
|
+
if (args.commentLike)
|
|
3922
|
+
{
|
|
3923
|
+
whereClauses.push(cmWhereLike("comment", args.commentLike));
|
|
3924
|
+
}
|
|
3925
|
+
|
|
3926
|
+
if (args.dateFrom)
|
|
3927
|
+
{
|
|
3928
|
+
whereClauses.push(`date >= '${escapeCmWhereValue(args.dateFrom)}'`);
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
const cmdArgs: string[] = ["find", "shelve"];
|
|
3932
|
+
if (whereClauses.length > 0)
|
|
3933
|
+
{
|
|
3934
|
+
cmdArgs.push(`where ${whereClauses.join(" and ")}`);
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3937
|
+
if (args.limit)
|
|
3938
|
+
{
|
|
3939
|
+
cmdArgs.push(`limit ${args.limit}`);
|
|
3940
|
+
}
|
|
3941
|
+
|
|
3942
|
+
if (args.format)
|
|
3943
|
+
{
|
|
3944
|
+
cmdArgs.push(`--format=${args.format}`);
|
|
3945
|
+
}
|
|
3946
|
+
|
|
3947
|
+
if (args.dateFormat)
|
|
3948
|
+
{
|
|
3949
|
+
cmdArgs.push(`--dateformat=${args.dateFormat}`);
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
cmdArgs.push("--nototal");
|
|
3953
|
+
return runCm(cmdArgs, args.workdir);
|
|
3954
|
+
},
|
|
3955
|
+
});
|
|
3956
|
+
|
|
3957
|
+
export const codeReviewCreate = tool({
|
|
3958
|
+
description: "Create a code review (cm codereview).",
|
|
3959
|
+
args: {
|
|
3960
|
+
target: tool.schema.string().min(1).describe("Review target spec (branch, changeset, or shelveset spec)."),
|
|
3961
|
+
title: tool.schema.string().min(1).describe("Code review title."),
|
|
3962
|
+
status: tool.schema.string().optional().describe("Initial review status."),
|
|
3963
|
+
assignee: tool.schema.string().optional().describe("Initial review assignee."),
|
|
3964
|
+
repository: tool.schema.string().optional().describe("Repository specification when no workspace is used."),
|
|
3965
|
+
format: tool.schema.string().optional().describe("Format string for creation output."),
|
|
3966
|
+
workdir: workdirArg,
|
|
3967
|
+
},
|
|
3968
|
+
async execute(args)
|
|
3969
|
+
{
|
|
3970
|
+
const cmdArgs: string[] = ["codereview", args.target, args.title];
|
|
3971
|
+
|
|
3972
|
+
if (args.status)
|
|
3973
|
+
{
|
|
3974
|
+
cmdArgs.push(`--status=${args.status}`);
|
|
3975
|
+
}
|
|
3976
|
+
|
|
3977
|
+
if (args.assignee)
|
|
3978
|
+
{
|
|
3979
|
+
cmdArgs.push(`--assignee=${args.assignee}`);
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3982
|
+
if (args.repository)
|
|
3983
|
+
{
|
|
3984
|
+
cmdArgs.push(`--repository=${args.repository}`);
|
|
3985
|
+
}
|
|
3986
|
+
|
|
3987
|
+
if (args.format)
|
|
3988
|
+
{
|
|
3989
|
+
cmdArgs.push(`--format=${args.format}`);
|
|
3990
|
+
}
|
|
3991
|
+
|
|
3992
|
+
return runCm(cmdArgs, args.workdir);
|
|
3993
|
+
},
|
|
3994
|
+
});
|
|
3995
|
+
|
|
3996
|
+
export const codeReviewUpdate = tool({
|
|
3997
|
+
description: "Update an existing code review (cm codereview -e).",
|
|
3998
|
+
args: {
|
|
3999
|
+
id: tool.schema.string().min(1).describe("Code review id or GUID."),
|
|
4000
|
+
status: tool.schema.string().optional().describe("Updated review status."),
|
|
4001
|
+
assignee: tool.schema.string().optional().describe("Updated review assignee."),
|
|
4002
|
+
repository: tool.schema.string().optional().describe("Repository specification when no workspace is used."),
|
|
4003
|
+
workdir: workdirArg,
|
|
4004
|
+
},
|
|
4005
|
+
async execute(args)
|
|
4006
|
+
{
|
|
4007
|
+
const cmdArgs: string[] = ["codereview", "-e", args.id];
|
|
4008
|
+
|
|
4009
|
+
if (args.status)
|
|
4010
|
+
{
|
|
4011
|
+
cmdArgs.push(`--status=${args.status}`);
|
|
4012
|
+
}
|
|
4013
|
+
|
|
4014
|
+
if (args.assignee)
|
|
4015
|
+
{
|
|
4016
|
+
cmdArgs.push(`--assignee=${args.assignee}`);
|
|
4017
|
+
}
|
|
4018
|
+
|
|
4019
|
+
if (args.repository)
|
|
4020
|
+
{
|
|
4021
|
+
cmdArgs.push(`--repository=${args.repository}`);
|
|
4022
|
+
}
|
|
4023
|
+
|
|
4024
|
+
return runCm(cmdArgs, args.workdir);
|
|
4025
|
+
},
|
|
4026
|
+
});
|
|
4027
|
+
|
|
4028
|
+
export const codeReviewDelete = tool({
|
|
4029
|
+
description: "Delete one or more code reviews (cm codereview -d).",
|
|
4030
|
+
args: {
|
|
4031
|
+
ids: tool.schema.array(tool.schema.string()).min(1).describe("Code review IDs or GUIDs to delete."),
|
|
4032
|
+
repository: tool.schema.string().optional().describe("Repository specification when no workspace is used."),
|
|
4033
|
+
workdir: workdirArg,
|
|
4034
|
+
},
|
|
4035
|
+
async execute(args)
|
|
4036
|
+
{
|
|
4037
|
+
const cmdArgs: string[] = ["codereview", "-d", ...args.ids];
|
|
4038
|
+
|
|
4039
|
+
if (args.repository)
|
|
4040
|
+
{
|
|
4041
|
+
cmdArgs.push(`--repository=${args.repository}`);
|
|
4042
|
+
}
|
|
4043
|
+
|
|
4044
|
+
return runCm(cmdArgs, args.workdir);
|
|
4045
|
+
},
|
|
4046
|
+
});
|
|
4047
|
+
|
|
4048
|
+
export const codeReviewFind = tool({
|
|
4049
|
+
description: "Find code reviews with filters using cm find review.",
|
|
4050
|
+
args: {
|
|
4051
|
+
status: tool.schema.string().optional().describe("Filter by review status."),
|
|
4052
|
+
assignee: tool.schema.string().optional().describe("Filter by review assignee."),
|
|
4053
|
+
owner: tool.schema.string().optional().describe("Filter by review owner."),
|
|
4054
|
+
target: tool.schema.string().optional().describe("Filter by review target branch/changeset spec."),
|
|
4055
|
+
targetType: tool.schema.enum(["branch", "changeset"]).optional().describe("Filter by review target type."),
|
|
4056
|
+
titleLike: tool.schema.string().optional().describe("Filter by title pattern (supports % wildcard)."),
|
|
4057
|
+
limit: tool.schema.number().int().min(1).optional().describe("Maximum number of reviews to return."),
|
|
4058
|
+
orderBy: tool.schema.enum(["date", "modifieddate", "status"]).optional().describe("Sort field for review queries."),
|
|
4059
|
+
descending: tool.schema.boolean().optional().describe("Sort descending when true."),
|
|
4060
|
+
format: tool.schema.string().optional().describe("Format string for query output."),
|
|
4061
|
+
dateFormat: tool.schema.string().optional().describe("Date format for query output."),
|
|
4062
|
+
output: outputFormatArg,
|
|
4063
|
+
workdir: workdirArg,
|
|
4064
|
+
},
|
|
4065
|
+
async execute(args)
|
|
4066
|
+
{
|
|
4067
|
+
const outputFormat = args.output ?? "text";
|
|
4068
|
+
const whereClauses: string[] = [];
|
|
4069
|
+
|
|
4070
|
+
if (args.status)
|
|
4071
|
+
{
|
|
4072
|
+
whereClauses.push(cmWhereEquals("status", args.status));
|
|
4073
|
+
}
|
|
4074
|
+
|
|
4075
|
+
if (args.assignee)
|
|
4076
|
+
{
|
|
4077
|
+
whereClauses.push(cmWhereEquals("assignee", args.assignee));
|
|
4078
|
+
}
|
|
4079
|
+
|
|
4080
|
+
if (args.owner)
|
|
4081
|
+
{
|
|
4082
|
+
whereClauses.push(cmWhereEquals("owner", args.owner));
|
|
4083
|
+
}
|
|
4084
|
+
|
|
4085
|
+
if (args.target)
|
|
4086
|
+
{
|
|
4087
|
+
whereClauses.push(cmWhereEquals("target", args.target));
|
|
4088
|
+
}
|
|
4089
|
+
|
|
4090
|
+
if (args.targetType)
|
|
4091
|
+
{
|
|
4092
|
+
whereClauses.push(cmWhereEquals("targettype", args.targetType));
|
|
4093
|
+
}
|
|
4094
|
+
|
|
4095
|
+
if (args.titleLike)
|
|
4096
|
+
{
|
|
4097
|
+
whereClauses.push(cmWhereLike("title", args.titleLike));
|
|
4098
|
+
}
|
|
4099
|
+
|
|
4100
|
+
const cmdArgs: string[] = ["find", "review"];
|
|
4101
|
+
if (whereClauses.length > 0)
|
|
4102
|
+
{
|
|
4103
|
+
cmdArgs.push(`where ${whereClauses.join(" and ")}`);
|
|
4104
|
+
}
|
|
4105
|
+
|
|
4106
|
+
if (args.orderBy)
|
|
4107
|
+
{
|
|
4108
|
+
cmdArgs.push(`order by ${args.orderBy}${args.descending ? " desc" : " asc"}`);
|
|
4109
|
+
}
|
|
4110
|
+
|
|
4111
|
+
if (args.limit)
|
|
4112
|
+
{
|
|
4113
|
+
cmdArgs.push(`limit ${args.limit}`);
|
|
4114
|
+
}
|
|
4115
|
+
|
|
4116
|
+
if (args.format)
|
|
4117
|
+
{
|
|
4118
|
+
cmdArgs.push(`--format=${args.format}`);
|
|
4119
|
+
}
|
|
4120
|
+
|
|
4121
|
+
if (args.dateFormat)
|
|
4122
|
+
{
|
|
4123
|
+
cmdArgs.push(`--dateformat=${args.dateFormat}`);
|
|
4124
|
+
}
|
|
4125
|
+
|
|
4126
|
+
cmdArgs.push("--nototal");
|
|
4127
|
+
const output = await runCm(cmdArgs, args.workdir);
|
|
4128
|
+
return toStructuredResult(
|
|
4129
|
+
"code-review-find",
|
|
4130
|
+
outputFormat,
|
|
4131
|
+
output,
|
|
4132
|
+
{
|
|
4133
|
+
command: ["cm", ...cmdArgs],
|
|
4134
|
+
rawOutput: output,
|
|
4135
|
+
resultCount: normalizeFindOutputLines(output).length,
|
|
4136
|
+
},
|
|
4137
|
+
args.workdir,
|
|
4138
|
+
);
|
|
4139
|
+
},
|
|
4140
|
+
});
|
|
4141
|
+
|
|
4142
|
+
export const workspaceCreate = tool({
|
|
4143
|
+
description: "Create a Plastic SCM workspace (cm workspace create).",
|
|
4144
|
+
args: {
|
|
4145
|
+
name: tool.schema.string().min(1).describe("Workspace name."),
|
|
4146
|
+
path: tool.schema.string().min(1).describe("Workspace path."),
|
|
4147
|
+
repositorySpec: tool.schema.string().optional().describe("Optional repository specification for the new workspace."),
|
|
4148
|
+
selectorFile: tool.schema.string().optional().describe("Optional selector file path for the new workspace."),
|
|
4149
|
+
workdir: workdirArg,
|
|
4150
|
+
},
|
|
4151
|
+
async execute(args)
|
|
4152
|
+
{
|
|
4153
|
+
if (args.repositorySpec && args.selectorFile)
|
|
4154
|
+
{
|
|
4155
|
+
throw new Error("Provide either repositorySpec or selectorFile, not both.");
|
|
4156
|
+
}
|
|
4157
|
+
|
|
4158
|
+
const cmdArgs: string[] = ["workspace", "create", args.name, args.path];
|
|
4159
|
+
|
|
4160
|
+
if (args.repositorySpec)
|
|
4161
|
+
{
|
|
4162
|
+
cmdArgs.push(args.repositorySpec);
|
|
4163
|
+
}
|
|
4164
|
+
|
|
4165
|
+
if (args.selectorFile)
|
|
4166
|
+
{
|
|
4167
|
+
cmdArgs.push(`--selector=${args.selectorFile}`);
|
|
4168
|
+
}
|
|
4169
|
+
|
|
4170
|
+
return runCm(cmdArgs, args.workdir);
|
|
4171
|
+
},
|
|
4172
|
+
});
|
|
4173
|
+
|
|
4174
|
+
export const workspaceList = tool({
|
|
4175
|
+
description: "List Plastic SCM workspaces (cm workspace list).",
|
|
4176
|
+
args: {
|
|
4177
|
+
format: tool.schema.string().optional().describe("Format string for workspace list output."),
|
|
4178
|
+
output: outputFormatArg,
|
|
4179
|
+
workdir: workdirArg,
|
|
4180
|
+
},
|
|
4181
|
+
async execute(args)
|
|
4182
|
+
{
|
|
4183
|
+
const outputFormat = args.output ?? "text";
|
|
4184
|
+
const cmdArgs: string[] = ["workspace", "list"];
|
|
4185
|
+
|
|
4186
|
+
if (args.format)
|
|
4187
|
+
{
|
|
4188
|
+
cmdArgs.push(`--format=${args.format}`);
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
const output = await runCm(cmdArgs, args.workdir);
|
|
4192
|
+
return toStructuredResult(
|
|
4193
|
+
"workspace-list",
|
|
4194
|
+
outputFormat,
|
|
4195
|
+
output,
|
|
4196
|
+
{
|
|
4197
|
+
command: ["cm", ...cmdArgs],
|
|
4198
|
+
rawOutput: output,
|
|
4199
|
+
resultCount: normalizeFindOutputLines(output).length,
|
|
4200
|
+
},
|
|
4201
|
+
args.workdir,
|
|
4202
|
+
);
|
|
4203
|
+
},
|
|
4204
|
+
});
|