Merge PR #4111: fix(edit): guard apply_patch against clobber and swallowed multi-file failures (@roboomp)

This commit is contained in:
can1357
2026-07-01 21:53:15 +02:00
17 changed files with 240 additions and 2 deletions
+28
View File
@@ -138,6 +138,7 @@ async function executeApplyPatchPerFile(
const perFileResults: EditToolPerFileResult[] = [];
const contentTexts: string[] = [];
let errorCount = 0;
for (let i = 0; i < fileEntries.length; i++) {
const { path, run } = fileEntries[i];
@@ -169,6 +170,29 @@ async function executeApplyPatchPerFile(
const displayErrorText = err instanceof HashlineMismatchError ? err.displayMessage : undefined;
perFileResults.push({ path, diff: "", isError: true, errorText, displayErrorText });
contentTexts.push(`Error editing ${path}: ${errorText}`);
errorCount++;
// Later entries were authored assuming this file's post-state; a
// partial cascade after failure typically compounds damage. Stop
// here, report applied vs. skipped, and let the caller re-issue
// only the failed and unapplied files. Matches
// `executeSinglePathEntries` semantics.
if (i > 0) {
const appliedPaths = fileEntries
.slice(0, i)
.map(e => e.path)
.join(", ");
contentTexts.push(`Files already applied: ${appliedPaths}.`);
}
if (i + 1 < fileEntries.length) {
const skippedPaths = fileEntries
.slice(i + 1)
.map(e => e.path)
.join(", ");
contentTexts.push(
`Files NOT applied: ${skippedPaths}; re-read the affected files and re-issue only the failed and unapplied files.`,
);
}
break;
}
// Emit partial result after each file so UI shows progressive completion
@@ -197,6 +221,10 @@ async function executeApplyPatchPerFile(
firstChangedLine: perFileResults.find(r => r.firstChangedLine)?.firstChangedLine,
perFileResults,
}),
// Any per-file failure marks the aggregate result as an error so the
// agent loop and renderer take the error branch instead of treating
// a mixed partial application as a successful edit.
...(errorCount > 0 ? { isError: true } : {}),
};
}
@@ -1492,6 +1492,14 @@ async function applyNormalizedPatch(input: PatchInput, options: ApplyPatchOption
if (destPath === absolutePath) {
throw new ApplyPatchError("rename path is the same as source path");
}
// The `*** Move to` / rename contract is strictly non-overwriting:
// reject before the update path reads or writes anything, so both
// source and pre-existing destination remain untouched. Callers who
// really need to replace the destination must delete it in an
// earlier hunk.
if (await fs.exists(destPath)) {
throw new ApplyPatchError(`Cannot rename ${input.path} to ${input.rename}: destination already exists.`);
}
}
// Handle CREATE operation
@@ -1499,6 +1507,15 @@ async function applyNormalizedPatch(input: PatchInput, options: ApplyPatchOption
if (!input.diff) {
throw new ApplyPatchError("Create operation requires diff (file content)");
}
// The `*** Add File` / create contract is strictly non-overwriting:
// reject before mkdir/write so pre-existing content stays intact and
// the caller can re-issue as an explicit `*** Update File` (or a
// delete+add pair) if overwrite is genuinely intended.
if (await fs.exists(absolutePath)) {
throw new ApplyPatchError(
`Cannot create ${input.path}: file already exists. Use *** Update File to modify it in place.`,
);
}
// Strip + prefixes if present (handles diffs formatted as additions)
const normalizedContent = normalizeCreateContent(input.diff);
const content = normalizedContent.endsWith("\n") ? normalizedContent : `${normalizedContent}\n`;