diff --git a/README.md b/README.md index ba7386b..563f65e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/commands/batchApply.ts b/src/commands/batchApply.ts index 0ce5852..ef14244 100644 --- a/src/commands/batchApply.ts +++ b/src/commands/batchApply.ts @@ -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 }); @@ -283,6 +288,12 @@ export async function batchApply(): Promise { 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); diff --git a/src/commands/previewExit.ts b/src/commands/previewExit.ts index 2f645f7..65835bd 100644 --- a/src/commands/previewExit.ts +++ b/src/commands/previewExit.ts @@ -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"; /** diff --git a/src/commands/quickActions.ts b/src/commands/quickActions.ts index 6de5114..f2bdd5c 100644 --- a/src/commands/quickActions.ts +++ b/src/commands/quickActions.ts @@ -18,7 +18,7 @@ import { resolveWorkspaceRelativePath, sameRealFilePath } from "../workspace/pathContainment.js"; -import { dryRunFollowUp } from "./previewExit.js"; +import { dryRunFollowUp, previewSnapshotStillCurrent } from "./previewExit.js"; export { isPathInsideWorkspace, @@ -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( @@ -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, diff --git a/test/unit/batchApply.test.ts b/test/unit/batchApply.test.ts index b0e8aee..12f3d6a 100644 --- a/test/unit/batchApply.test.ts +++ b/test/unit/batchApply.test.ts @@ -6,6 +6,7 @@ import { buildBatchDryRunArgs, buildBatchTemplate, formatBatchApplyCompletion, + batchPlanMatchesDryRun, isEmptyBatchPlan, parseBatchOperationCount } from "../../src/commands/batchApply.js"; @@ -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); diff --git a/test/unit/previewExit.test.ts b/test/unit/previewExit.test.ts index 59279df..66149ad 100644 --- a/test/unit/previewExit.test.ts +++ b/test/unit/previewExit.test.ts @@ -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, @@ -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}' };