Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions src/commands/batchApply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
presentCliResultInOutput
} from "../logging/outputChannel.js";
import { activeWorkspaceFolder } from "../workspace/readiness.js";
import { serializePatchloomArgs } from "./quickActions.js";

// Batch replace is PATH OLD NEW (not CLI `replace OLD --new NEW path`). See CLI 0.18+ batch --help.
// doc.update / doc.delete_where are the multi-match siblings of doc.set / doc.delete
Expand Down Expand Up @@ -44,9 +45,9 @@ export function isEmptyBatchPlan(plan: string): boolean {
return parseBatchOperationCount(plan) === 0;
}

/** CLI argv for Batch Apply. Global --contain first (CLI 0.10+ path guard). */
/** CLI argv for Batch Apply. Flags come from serializePatchloomArgs, not from scanning operands. */
export function buildBatchApplyArgs(): string[] {
return ["--contain", "batch", "--apply"];
return serializePatchloomArgs({ args: ["batch"], apply: true, contain: true });
}

export async function batchApply(): Promise<void> {
Expand Down
88 changes: 54 additions & 34 deletions src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,31 @@ export interface PlannedQuickAction {
readonly title: string;
readonly targetPath: string;
readonly targetArgIndices: readonly number[];
/** Subcommand and operands. Do not put `--apply` or `--contain` here. */
readonly args: readonly string[];
/** When true, `serializePatchloomArgs` appends `--apply`. */
readonly apply?: boolean;
}

export interface PatchloomInvocation {
readonly args: readonly string[];
readonly apply?: boolean;
/** When true, prefix global `--contain`. `executePatchloom` defaults this to true. */
readonly contain?: boolean;
}

/**
* Build argv from flags plus operands. Never scans `args` for flag names.
*/
export function serializePatchloomArgs(invocation: PatchloomInvocation): string[] {
const argv = [...invocation.args];
if (invocation.contain) {
argv.unshift("--contain");
}
if (invocation.apply) {
argv.push("--apply");
}
return argv;
}

export function presentSearchOutcome(
Expand Down Expand Up @@ -398,7 +422,7 @@ export async function runQuickAction(): Promise<void> {
}

const action = buildSearchQuickAction(folder.uri.fsPath, pattern, glob || undefined);
const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath);
const result = await executePatchloom(binaryPath, action, folder.uri.fsPath);
const log = getPatchloomLog();
const outcome = presentSearchOutcome(log, result);

Expand Down Expand Up @@ -445,7 +469,7 @@ export async function runQuickAction(): Promise<void> {
const action = buildSearchQuickAction(folder.uri.fsPath, pattern, glob || undefined, {
filesWithoutMatch: true
});
const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath);
const result = await executePatchloom(binaryPath, action, folder.uri.fsPath);
const log = getPatchloomLog();
const outcome = presentSearchOutcome(log, result);

Expand Down Expand Up @@ -501,7 +525,7 @@ export async function runQuickAction(): Promise<void> {
}

const action = buildCreateQuickAction(absolutePath, content);
const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath);
const result = await executePatchloom(binaryPath, action, folder.uri.fsPath);

if (result.exitCode !== 0) {
await vscode.window.showErrorMessage(`Patchloom create failed: ${formatCliOutput(result)}`);
Expand Down Expand Up @@ -578,7 +602,7 @@ export async function runQuickAction(): Promise<void> {
}

const action = buildDocGetQuickAction(target.absolutePath, selector);
const result = await executePatchloom(binaryPath, action.args, target.workspaceFolder.uri.fsPath);
const result = await executePatchloom(binaryPath, action, target.workspaceFolder.uri.fsPath);

if (result.exitCode !== 0) {
await vscode.window.showErrorMessage(`Patchloom doc get failed: ${formatCliOutput(result)}`);
Expand Down Expand Up @@ -1061,7 +1085,7 @@ export async function runQuickAction(): Promise<void> {
const staged = await stageExternalPatchInWorkspace(folder.uri.fsPath, patchUri[0].fsPath);
try {
const planned = retargetQuickAction(action, staged.patchPath);
const result = await executePatchloom(binaryPath, planned.args, folder.uri.fsPath);
const result = await executePatchloom(binaryPath, planned, folder.uri.fsPath);
const log = getPatchloomLog();
presentCliResultInOutput(log, result);

Expand Down Expand Up @@ -1111,7 +1135,7 @@ export async function runQuickAction(): Promise<void> {
const staged = await stageExternalPatchInWorkspace(folder.uri.fsPath, patchUri[0].fsPath);
try {
const planned = retargetQuickAction(action, staged.patchPath);
const result = await executePatchloom(binaryPath, planned.args, folder.uri.fsPath);
const result = await executePatchloom(binaryPath, planned, folder.uri.fsPath);
const log = getPatchloomLog();
const outcome = presentPatchMergeOutcome(log, result);

Expand Down Expand Up @@ -1151,7 +1175,7 @@ export async function runQuickAction(): Promise<void> {
}

const action = buildUndoQuickAction(folder.uri.fsPath);
const result = await executePatchloom(binaryPath, action.args, folder.uri.fsPath);
const result = await executePatchloom(binaryPath, action, folder.uri.fsPath);

if (result.exitCode !== 0) {
await vscode.window.showWarningMessage(formatUndoFailureMessage(result));
Expand Down Expand Up @@ -1302,7 +1326,8 @@ export function buildCreateQuickAction(filePath: string, content = ""): PlannedQ
targetPath: filePath,
targetArgIndices: [1],
// CLI requires --content/--stdin and --apply; preview-only create returns exit 2 and does not write.
args: ["create", filePath, "--content", content, "--apply"]
args: ["create", filePath, "--content", content],
apply: true
};
}

Expand Down Expand Up @@ -1474,20 +1499,22 @@ export function buildPatchApplyQuickAction(patchPath: string): PlannedQuickActio
title: `Apply patch ${path.basename(patchPath)}`,
targetPath: patchPath,
targetArgIndices: [2],
args: ["patch", "apply", patchPath, "--apply"]
args: ["patch", "apply", patchPath],
apply: true
};
}

export function buildPatchMergeQuickAction(patchPath: string, allowConflicts: boolean): PlannedQuickAction {
const args: string[] = ["patch", "merge", patchPath, "--apply"];
const args: string[] = ["patch", "merge", patchPath];
if (allowConflicts) {
args.push("--allow-conflicts");
}
return {
title: `Merge patch ${path.basename(patchPath)}`,
targetPath: patchPath,
targetArgIndices: [2],
args
args,
apply: true
};
}

Expand All @@ -1496,7 +1523,8 @@ export function buildUndoQuickAction(workspacePath: string): PlannedQuickAction
title: "Undo last patchloom change",
targetPath: workspacePath,
targetArgIndices: [],
args: ["undo", "--apply"]
args: ["undo"],
apply: true
};
}

Expand All @@ -1512,7 +1540,7 @@ export function isAllowedPreviewMiss(action: PlannedQuickAction, exitCode: numbe
if (exitCode !== 3) {
return false;
}
// PlannedQuickAction.args are pre-contain. executePatchloom prepends --contain later.
// PlannedQuickAction.args are operands only. executePatchloom adds --contain.
// doc update and delete-where: exit 3 is a path miss (key not found), not a soft no-op.
return !(
action.args[0] === "doc" &&
Expand All @@ -1535,17 +1563,7 @@ export function retargetQuickAction(action: PlannedQuickAction, nextTargetPath:
};
}

export function withApplyFlag(args: readonly string[]): string[] {
return args.at(-1) === "--apply" ? [...args] : [...args, "--apply"];
}

/**
* Prepend the global `--contain` flag so CLI ops cannot escape the cwd workspace.
* Global flags must appear before the subcommand (`patchloom --contain replace ...`).
*/
export function withContainFlag(args: readonly string[]): string[] {
return args[0] === "--contain" ? [...args] : ["--contain", ...args];
}

async function previewAndMaybeApply(
binaryPath: string,
Expand Down Expand Up @@ -1591,7 +1609,11 @@ async function previewAndMaybeApply(
return;
}

const result = await executePatchloom(binaryPath, withApplyFlag(action.args), target.workspaceFolder.uri.fsPath);
const result = await executePatchloom(
binaryPath,
{ args: action.args, apply: true },
target.workspaceFolder.uri.fsPath
);
if (result.exitCode !== 0) {
presentCliResultInOutput(getPatchloomLog(), result);
await vscode.window.showErrorMessage(
Expand Down Expand Up @@ -1620,7 +1642,7 @@ async function buildPreviewDocument(
try {
await fs.writeFile(tempPath, originalContent, "utf8");
const previewAction = retargetQuickAction(action, tempPath);
const result = await executePatchloom(binaryPath, withApplyFlag(previewAction.args), tempDir);
const result = await executePatchloom(binaryPath, { args: previewAction.args, apply: true }, tempDir);
if (result.exitCode !== 0 && !isAllowedPreviewMiss(action, result.exitCode)) {
presentCliResultInOutput(getPatchloomLog(), result);
throw new Error(formatQuickActionCliOutput(result));
Expand Down Expand Up @@ -1873,18 +1895,16 @@ async function ensureWorkspaceFileReady(target: WorkspaceFileTarget): Promise<bo
return true;
}

export interface ExecutePatchloomOptions {
/** When true (default), prefix args with global `--contain` for workspace path guarding. */
readonly contain?: boolean;
}

async function executePatchloom(
binaryPath: string,
args: readonly string[],
cwd: string,
options: ExecutePatchloomOptions = {}
invocation: PatchloomInvocation,
cwd: string
): Promise<PatchloomCommandResult> {
const finalArgs = options.contain === false ? [...args] : withContainFlag(args);
const finalArgs = serializePatchloomArgs({
args: invocation.args,
apply: invocation.apply,
contain: invocation.contain !== false
});
const log = getPatchloomLog();
const runtime = await getPatchloomRuntimeConfig();
const env = mergePatchloomEnv(process.env, runtime.extraEnv);
Expand Down
30 changes: 18 additions & 12 deletions test/unit/patchloomCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ import {
buildReplaceQuickAction,
buildSearchQuickAction,
retargetQuickAction,
withApplyFlag
serializePatchloomArgs
} from "../../src/commands/quickActions.js";
import { configureMcpTargets, inspectMcpTargets } from "../../src/mcp/config.js";
import { performManagedInstall } from "../../src/install/managed.js";
Expand Down Expand Up @@ -380,7 +380,7 @@ describe("patchloom CLI integration", async () => {
await fs.writeFile(previewFile, originalContent, "utf8");

const previewAction = retargetQuickAction(action, previewFile);
const applyArgs = withApplyFlag([...previewAction.args]);
const applyArgs = serializePatchloomArgs({ args: [...previewAction.args], apply: true });

await execFileAsync(binaryPath, applyArgs, { cwd: previewDir, timeout: 5000 });

Expand Down Expand Up @@ -529,7 +529,7 @@ describe("patchloom CLI integration", async () => {
await fs.writeFile(file, "---\na: 1\n---\nb: 2\n", "utf8");

const action = buildDocMergeQuickAction(file, '{"c": 3}', "0");
await execFileAsync(binaryPath, withApplyFlag(action.args), { timeout: 5000 });
await execFileAsync(binaryPath, serializePatchloomArgs({ args: action.args, apply: true }), { timeout: 5000 });

const content = await fs.readFile(file, "utf8");
assert.match(content, /a:\s*1/, "first document field preserved");
Expand All @@ -556,7 +556,7 @@ describe("patchloom CLI integration", async () => {
await fs.writeFile(file, "alpha\nbeta\n", "utf8");

const action = buildInsertAfterMatchQuickAction(file, "beta", "gamma");
await execFileAsync(binaryPath, withApplyFlag(action.args), { timeout: 5000 });
await execFileAsync(binaryPath, serializePatchloomArgs({ args: action.args, apply: true }), { timeout: 5000 });

const content = await fs.readFile(file, "utf8");
assert.equal(content, "alpha\nbeta\ngamma\n");
Expand All @@ -576,7 +576,7 @@ describe("patchloom CLI integration", async () => {
await fs.writeFile(file, "fn foo() {\n let a = 1;\n}\n", "utf8");

const action = buildApplyFragmentQuickAction(file, "after", "fn foo() {", " let x = 2;");
await execFileAsync(binaryPath, withApplyFlag(action.args), { timeout: 5000 });
await execFileAsync(binaryPath, serializePatchloomArgs({ args: action.args, apply: true }), { timeout: 5000 });

const content = await fs.readFile(file, "utf8");
assert.equal(content, "fn foo() {\n let x = 2;\n let a = 1;\n}\n");
Expand Down Expand Up @@ -700,7 +700,11 @@ describe("patchloom CLI integration", async () => {
);

const action = buildPatchApplyQuickAction(patchFile);
await execFileAsync(binaryPath, action.args, { cwd: dir, timeout: 5000 });
await execFileAsync(
binaryPath,
serializePatchloomArgs({ args: action.args, apply: action.apply }),
{ cwd: dir, timeout: 5000 }
);

const content = await fs.readFile(file, "utf8");
assert.match(content, /fn new\(\) \{\}/);
Expand All @@ -723,9 +727,11 @@ describe("patchloom CLI integration", async () => {
const action = buildCreateQuickAction(dest, "x");

try {
await execFileAsync(binaryPath, ["--json", ...action.args], {
timeout: 5000
});
await execFileAsync(
binaryPath,
["--json", ...serializePatchloomArgs({ args: action.args, apply: action.apply })],
{ timeout: 5000 }
);
assert.fail("create through a file parent should fail");
} catch (error) {
const failed = error as { stdout?: string; stderr?: string };
Expand Down Expand Up @@ -754,7 +760,7 @@ describe("patchloom CLI integration", async () => {
].join("\n"), "utf8");

const action = buildDocSetQuickAction(file, "service_a.retries", "3");
await execFileAsync(binaryPath, withApplyFlag(action.args), { timeout: 5000 });
await execFileAsync(binaryPath, serializePatchloomArgs({ args: action.args, apply: true }), { timeout: 5000 });

const content = await fs.readFile(file, "utf8");
assert.match(content, /<<: \*shared/, "alias merge key should be preserved");
Expand All @@ -779,7 +785,7 @@ describe("patchloom CLI integration", async () => {
);

const action = buildDocUpdateQuickAction(file, "items[*].enabled", "false");
await execFileAsync(binaryPath, withApplyFlag(action.args), { timeout: 5000 });
await execFileAsync(binaryPath, serializePatchloomArgs({ args: action.args, apply: true }), { timeout: 5000 });

const content = JSON.parse(await fs.readFile(file, "utf8")) as { items: Array<{ enabled: boolean }> };
assert.deepEqual(content.items, [{ enabled: false }, { enabled: false }]);
Expand All @@ -803,7 +809,7 @@ describe("patchloom CLI integration", async () => {
);

const action = buildDocDeleteWhereQuickAction(file, "items", "name=stale");
await execFileAsync(binaryPath, withApplyFlag(action.args), { timeout: 5000 });
await execFileAsync(binaryPath, serializePatchloomArgs({ args: action.args, apply: true }), { timeout: 5000 });

const content = JSON.parse(await fs.readFile(file, "utf8")) as { items: Array<{ name: string }> };
assert.deepEqual(content.items, [{ name: "keep" }]);
Expand Down
Loading