Reject duplicate resolved paths in apply_patch (#37867)

## What changed

- Reject patches containing multiple operations whose paths resolve to the same file, such as `duplicate.txt` and `./duplicate.txt`.
- Preserve support for patches that update multiple distinct files.

## Testing

- Add CLI integration coverage for distinct updates and duplicate resolved paths.

GitOrigin-RevId: 0fb7f69ae31acc5c677268ffd9e38ad8d8314276
This commit is contained in:
viyatb-oai
2026-08-10 18:44:58 +00:00
committed by copyberry
parent dd22460869
commit a1c88e865d
2 changed files with 61 additions and 0 deletions
+7
View File
@@ -232,6 +232,13 @@ async fn try_verify_apply_patch_args(
let mut changes = HashMap::new();
for hunk in hunks {
let path = hunk.resolve_path(&effective_cwd)?;
if changes.contains_key(&path) {
return Err(ParseError::InvalidPatchError(format!(
"multiple operations target {}",
path.inferred_native_path_string()
))
.into());
}
match hunk {
Hunk::AddFile { contents, .. } => {
changes.insert(path, ApplyPatchFileChange::Add { content: contents });
@@ -437,6 +437,60 @@ D delete.txt
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_cli_preserves_distinct_updated_paths() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness().await?;
harness.write_file("first.txt", "first before\n").await?;
harness.write_file("second.txt", "second before\n").await?;
let patch = "*** Begin Patch\n*** Update File: first.txt\n@@\n-first before\n+first after\n*** Update File: second.txt\n@@\n-second before\n+second after\n*** End Patch";
let call_id = "apply-distinct-updates";
mount_apply_patch(&harness, call_id, patch, "done").await;
harness.submit("please update both files").await?;
assert_regex_match(
r"(?s)^Exit code: 0.*Success\. Updated the following files:\nM first\.txt\nM second\.txt\n?$",
&harness.apply_patch_output(call_id).await,
);
assert_eq!(harness.read_file_text("first.txt").await?, "first after\n");
assert_eq!(
harness.read_file_text("second.txt").await?,
"second after\n"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_cli_rejects_duplicate_resolved_paths() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness().await?;
harness.write_file("duplicate.txt", "before\n").await?;
let patch = "*** Begin Patch\n*** Update File: duplicate.txt\n@@\n-before\n+first after\n*** Update File: ./duplicate.txt\n@@\n-before\n+second after\n*** End Patch";
let call_id = "apply-duplicate-resolved-path";
mount_apply_patch(&harness, call_id, patch, "done").await;
harness.submit("please apply both updates").await?;
let out = harness.apply_patch_output(call_id).await;
assert!(
out.contains("apply_patch verification failed"),
"expected verification failure: {out}"
);
assert!(
out.contains("multiple operations target"),
"expected duplicate-path diagnostics: {out}"
);
assert_eq!(harness.read_file_text("duplicate.txt").await?, "before\n");
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn apply_patch_cli_multiple_chunks() -> Result<()> {
skip_if_no_network!(Ok(()));