Merge PR #4111: fix(edit): guard apply_patch against clobber and swallowed multi-file failures (@roboomp)
This commit is contained in:
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user