Add a line-ending preservation mode to apply_patch (#37757)

## Why

Updating a file with `apply_patch` historically normalized its contents to LF,
which can rewrite line endings outside the requested change.

## What changed

- Add an opt-in `PreserveLineEndings` update mode and thread it through patch
  verification, application, and the standalone executable. Existing APIs keep
  the LF-normalizing mode by default.
- Retain the original endings of untouched and context lines, including in
  mixed-ending files, and use the file's first line ending for inserted or
  replaced lines.
- Track patch context separately from identical changed lines so preservation
  does not alter replacement ordering or matching.

## Testing

Add coverage for CRLF, CR, mixed endings, trailing blank lines, repeated lines,
EOF overlap rejection, and legacy LF normalization.

GitOrigin-RevId: 085f0cbc57e0965aa62c6a2d0e80870424e762f5
This commit is contained in:
Charlie Marsh
2026-08-10 01:39:55 +00:00
committed by copyberry
parent f344a80a3b
commit 21aa552e87
19 changed files with 1174 additions and 520 deletions
+17 -37
View File
@@ -274,12 +274,7 @@ impl StreamingPatchParser {
}
if update_line == EMPTY_CHANGE_CONTEXT_MARKER {
chunks.push(UpdateFileChunk {
change_context: None,
old_lines: Vec::new(),
new_lines: Vec::new(),
is_end_of_file: false,
});
chunks.push(UpdateFileChunk::default());
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
return Ok(());
}
@@ -287,9 +282,7 @@ impl StreamingPatchParser {
if let Some(change_context) = update_line.strip_prefix(CHANGE_CONTEXT_MARKER) {
chunks.push(UpdateFileChunk {
change_context: Some(change_context.to_string()),
old_lines: Vec::new(),
new_lines: Vec::new(),
is_end_of_file: false,
..UpdateFileChunk::default()
});
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
return Ok(());
@@ -313,16 +306,10 @@ impl StreamingPatchParser {
if line.is_empty() {
if chunks.is_empty() {
chunks.push(UpdateFileChunk {
change_context: None,
old_lines: Vec::new(),
new_lines: Vec::new(),
is_end_of_file: false,
});
chunks.push(UpdateFileChunk::default());
}
if let Some(chunk) = chunks.last_mut() {
chunk.old_lines.push(String::new());
chunk.new_lines.push(String::new());
chunk.push_context_line(String::new());
}
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
return Ok(());
@@ -330,16 +317,10 @@ impl StreamingPatchParser {
if let Some(line_to_add) = line.strip_prefix(' ') {
if chunks.is_empty() {
chunks.push(UpdateFileChunk {
change_context: None,
old_lines: Vec::new(),
new_lines: Vec::new(),
is_end_of_file: false,
});
chunks.push(UpdateFileChunk::default());
}
if let Some(chunk) = chunks.last_mut() {
chunk.old_lines.push(line_to_add.to_string());
chunk.new_lines.push(line_to_add.to_string());
chunk.push_context_line(line_to_add.to_string());
}
self.state.mode = StreamingParserMode::UpdateFile { hunk_line_number };
return Ok(());
@@ -347,12 +328,7 @@ impl StreamingPatchParser {
if let Some(line_to_add) = line.strip_prefix('+') {
if chunks.is_empty() {
chunks.push(UpdateFileChunk {
change_context: None,
old_lines: Vec::new(),
new_lines: Vec::new(),
is_end_of_file: false,
});
chunks.push(UpdateFileChunk::default());
}
if let Some(chunk) = chunks.last_mut() {
chunk.new_lines.push(line_to_add.to_string());
@@ -363,12 +339,7 @@ impl StreamingPatchParser {
if let Some(line_to_remove) = line.strip_prefix('-') {
if chunks.is_empty() {
chunks.push(UpdateFileChunk {
change_context: None,
old_lines: Vec::new(),
new_lines: Vec::new(),
is_end_of_file: false,
});
chunks.push(UpdateFileChunk::default());
}
if let Some(chunk) = chunks.last_mut() {
chunk.old_lines.push(line_to_remove.to_string());
@@ -445,6 +416,7 @@ mod tests {
change_context: None,
old_lines: vec!["old".to_string()],
new_lines: vec!["new".to_string()],
context_line_indices: vec![],
is_end_of_file: false,
}],
}])
@@ -635,12 +607,14 @@ mod tests {
change_context: None,
old_lines: vec!["old a".to_string(), "*** Update File: b.txt".to_string()],
new_lines: vec!["new a".to_string(), "*** Update File: b.txt".to_string()],
context_line_indices: vec![(1, 1)],
is_end_of_file: false,
},
UpdateFileChunk {
change_context: None,
old_lines: vec!["old b".to_string()],
new_lines: vec!["new b".to_string()],
context_line_indices: vec![],
is_end_of_file: false,
},
],
@@ -680,6 +654,7 @@ mod tests {
String::new(),
"context after".to_string(),
],
context_line_indices: vec![(0, 0), (1, 1), (2, 2)],
is_end_of_file: false,
}],
}])
@@ -700,6 +675,7 @@ mod tests {
change_context: None,
old_lines: Vec::new(),
new_lines: vec!["quux".to_string()],
context_line_indices: vec![],
is_end_of_file: true,
}],
}])
@@ -718,6 +694,7 @@ mod tests {
change_context: None,
old_lines: vec!["old".to_string()],
new_lines: vec!["new".to_string()],
context_line_indices: vec![],
is_end_of_file: false,
}],
}])
@@ -733,6 +710,7 @@ mod tests {
change_context: None,
old_lines: vec!["old\r".to_string()],
new_lines: vec!["new".to_string()],
context_line_indices: vec![],
is_end_of_file: false,
}],
}])
@@ -769,6 +747,7 @@ mod tests {
change_context: None,
old_lines: vec!["old".to_string()],
new_lines: vec!["new".to_string()],
context_line_indices: vec![],
is_end_of_file: false,
}],
}])
@@ -782,6 +761,7 @@ mod tests {
change_context: None,
old_lines: vec!["old".to_string()],
new_lines: vec!["new".to_string()],
context_line_indices: vec![],
is_end_of_file: false,
}],
}])