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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,13 +120,13 @@ Click it to run **Show Status**. That command writes the full report to the Outp
| **Merge patch (three-way)** | Apply a stale patch using three-way merge (v0.2.0+) |
| **Undo last change** | Restore the latest backup, one session id, or one workspace path |

Content fields open a scratch file so newlines stay newlines. Pattern and anchor fields start with the current selection when it is a single line. Preview copies the file under the workspace and runs the CLI with the workspace as its working directory, so `.patchloom.toml` format commands and EditorConfig match Apply. The preview copy is removed afterward. The diff uses a `patchloom-preview` document, which is not an untitled buffer and is not saved. A preview that finds no pattern says so. A command that exits cleanly and changes nothing says the command made no edit. Search still writes its text to the Output channel, and then offers the hits in a list that opens the file at the matching line. Files without a match open at line 1.
Content fields open a scratch file so newlines stay newlines. Pattern and anchor fields start with the current selection when it is a single line. Preview copies the file under the workspace and runs the CLI with the workspace as its working directory, so `.patchloom.toml` format commands and EditorConfig match Apply. The preview copy is removed afterward. The diff uses a `patchloom-preview` document, which is not an untitled buffer and is not saved. Unsaved edits must be saved before that preview. If the file changes while the preview is open, Apply stops and asks for a new preview. A preview that finds no pattern says so. A command that exits cleanly and changes nothing says the command made no edit. Search still writes its text to the Output channel, and then offers the hits in a list that opens the file at the matching line. Files without a match open at line 1.

Workspace Quick Actions and Batch Apply pass `--contain` so CLI paths stay inside the workspace root (CLI 0.10+). Containment is relative to the effective working directory (the workspace folder). Patch apply and patch merge ask for a file or the clipboard, copy that text under `.patchloom/` (already gitignored), show a dry run, and write only after a second confirmation. A dry run that would change files exits 2 with `ok: true` and `applied: false`. That result still reaches the write confirmation. A bad patch or a stale hunk stops before that confirmation. A three-way merge that conflicts, when conflict markers were not allowed, also stops. Nothing is written. Run it again and choose Allow conflict markers to write them. An empty clipboard is an error and does not write. Cancel leaves the files unchanged. A path inside that patch that leaves the workspace is rejected.

### Batch operations

`Patchloom: Batch Apply` opens a line-oriented plan whose example lines are comments, including replace, fuzzy replace, `doc.set`, JSONC `doc.set`, multi-match `doc.update`, `doc.delete_where`, multi-doc `doc.merge`, file append, directory `file.rename`, markdown section inserts, and tidy. Remove the leading `#` on the lines you want. Apply first runs the plan without `--apply` and shows that result. A preview that would change files exits 2 with `ok: true` and `applied: false`, including when another replace in the same plan is refused. That dry run still offers the write confirmation. A parse error or a plan that matches nothing does not. Confirming the write runs the plan with `--apply`. A hard error rolls the plan back. A formatter failure (`format_failed`) does not: that write may already be on disk, and Undo restores it. A replace that matches nothing, or a structured delete that removes nothing, is reported, other successful writes stay, and the completion message names those operations. A line whose first non-whitespace character is `#` is a comment. Blank lines and comments are not operations. A plan that contains only those lines is not applied.
`Patchloom: Batch Apply` opens a line-oriented plan whose example lines are comments, including replace, fuzzy replace, `doc.set`, JSONC `doc.set`, multi-match `doc.update`, `doc.delete_where`, multi-doc `doc.merge`, file append, directory `file.rename`, markdown section inserts, and tidy. Remove the leading `#` on the lines you want. Apply first runs the plan without `--apply` and shows that result. A preview that would change files exits 2 with `ok: true` and `applied: false`, including when another replace in the same plan is refused. That dry run still offers the write confirmation. A parse error or a plan that matches nothing does not. Confirming the write runs that same plan with `--apply`. If the open plan changed after the dry run, Apply stops and asks for another dry run. A hard error rolls the plan back. A formatter failure (`format_failed`) does not: that write may already be on disk, and Undo restores it. A replace that matches nothing, or a structured delete that removes nothing, is reported, other successful writes stay, and the completion message names those operations. A line whose first non-whitespace character is `#` is a comment. Blank lines and comments are not operations. A plan that contains only those lines is not applied.

### Output channel

Expand Down
11 changes: 11 additions & 0 deletions src/commands/batchApply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ export function isEmptyBatchPlan(plan: string): boolean {
return parseBatchOperationCount(plan) === 0;
}

/** Apply must write the text the dry run saw, not a later edit of the open plan. */
export function batchPlanMatchesDryRun(dryRunPlan: string, currentPlan: string): boolean {
return dryRunPlan === currentPlan;
}

/** CLI argv for Batch Apply. `--json` is how a refused replace is visible on exit 0. */
export function buildBatchApplyArgs(apply = true): string[] {
return serializePatchloomArgs({ args: ["batch", "--json"], apply, contain: true });
Expand Down Expand Up @@ -283,6 +288,12 @@ export async function batchApply(): Promise<void> {
if (write !== "Apply") {
return;
}
if (!batchPlanMatchesDryRun(plan, doc.getText())) {
await vscode.window.showWarningMessage(
"The batch plan changed after the dry run. Run Dry run again before applying."
);
return;
}

const args = buildBatchApplyArgs(true);
logCliCommand(log, runtime.trace, binaryPath, args, folder.uri.fsPath);
Expand Down
13 changes: 13 additions & 0 deletions src/commands/previewExit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ export function previewDryRunMayContinue(result: {
return report !== undefined && report.ok === true && report.applied !== true;
}

function samePreviewText(left: string, right: string): boolean {
return left.replace(/\r\n/g, "\n") === right.replace(/\r\n/g, "\n");
}

/** The preview was built from one snapshot. A dirty buffer or a later disk edit is a different file. */
export function previewSnapshotStillCurrent(
snapshot: string,
bufferText: string,
diskText: string
): boolean {
return samePreviewText(bufferText, snapshot) && samePreviewText(diskText, snapshot);
}

export type DryRunFollowUp = "apply" | "stop" | "conflicts";

/**
Expand Down
15 changes: 14 additions & 1 deletion src/commands/quickActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
resolveWorkspaceRelativePath,
sameRealFilePath
} from "../workspace/pathContainment.js";
import { dryRunFollowUp } from "./previewExit.js";
import { dryRunFollowUp, previewSnapshotStillCurrent } from "./previewExit.js";

export {
isPathInsideWorkspace,
Expand Down Expand Up @@ -2218,6 +2218,12 @@ async function previewAndMaybeApply(
}
const originalDocument = await vscode.workspace.openTextDocument(target.uri);
const originalContent = await fs.readFile(target.absolutePath, "utf8");
if (!previewSnapshotStillCurrent(originalContent, originalDocument.getText(), originalContent)) {
await vscode.window.showWarningMessage(
`Save unsaved edits to ${target.relativePath} before previewing. The preview reads the file on disk.`
);
return;
}
let preview: VSCode.TextDocument | undefined;
try {
const built = await buildPreviewDocument(
Expand Down Expand Up @@ -2256,6 +2262,13 @@ async function previewAndMaybeApply(
if (choice !== "Apply Changes") {
return;
}
const diskNow = await fs.readFile(target.absolutePath, "utf8");
if (!previewSnapshotStillCurrent(originalContent, originalDocument.getText(), diskNow)) {
await vscode.window.showWarningMessage(
`${target.relativePath} changed after the preview. Preview again before applying.`
);
return;
}

const result = await executePatchloom(
binaryPath,
Expand Down
7 changes: 7 additions & 0 deletions test/unit/batchApply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
buildBatchDryRunArgs,
buildBatchTemplate,
formatBatchApplyCompletion,
batchPlanMatchesDryRun,
isEmptyBatchPlan,
parseBatchOperationCount
} from "../../src/commands/batchApply.js";
Expand All @@ -17,6 +18,12 @@ function exampleLines(): string[] {
.map((line) => line.startsWith("# ") ? line.slice(2) : line);
}

test("batch apply requires the open plan to match the dry run", () => {
const plan = "replace README.md \"old\" \"new\"\n";
assert.equal(batchPlanMatchesDryRun(plan, plan), true);
assert.equal(batchPlanMatchesDryRun(plan, `${plan}tidy\n`), false);
});

test("buildBatchTemplate returns line-oriented format with twelve operations", () => {
const template = buildBatchTemplate();
const commented = template.split("\n").filter((line) => line.trim().length > 0);
Expand Down
8 changes: 7 additions & 1 deletion test/unit/previewExit.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { dryRunFollowUp, previewDryRunMayContinue } from "../../src/commands/previewExit.js";
import { dryRunFollowUp, previewDryRunMayContinue, previewSnapshotStillCurrent } from "../../src/commands/previewExit.js";

const batchWouldWrite = `{
"ok": true,
Expand Down Expand Up @@ -59,6 +59,12 @@ test("preview dry run stops on failures and on exit 2 that is not a pending writ
assert.equal(previewDryRunMayContinue({ exitCode: 1, stdout: batchWouldWrite }), false);
});

test("preview apply stops when the buffer or disk no longer matches the snapshot", () => {
assert.equal(previewSnapshotStillCurrent("hello\n", "hello\r\n", "hello\n"), true);
assert.equal(previewSnapshotStillCurrent("hello\n", "hello!\n", "hello\n"), false);
assert.equal(previewSnapshotStillCurrent("hello\n", "hello\n", "hello!\n"), false);
});

test("dry run follow-up offers apply only when the preview would write or change nothing", () => {
const wouldWrite = { exitCode: 2, stdout: patchWouldWrite };
const conflict = { exitCode: 8, stdout: '{"ok":false,"error_kind":"conflicts","applied":false}' };
Expand Down
Loading