@@ -142,6 +142,12 @@ pub fn apply_update_chunks(
142142 chunks : & [ UpdateFileChunk ] ,
143143) -> Result < String , String > {
144144 let line_ending = dominant_line_ending ( original_content) ;
145+ // Remember at split time whether the file ended with a newline. The join below
146+ // must restore the terminator sentinel from this flag: once the lines are in a
147+ // buffer, a real trailing empty line (a file ending in two newlines) is
148+ // indistinguishable from the split terminator, so re-inferring it there loses
149+ // one terminal newline on every update to such files.
150+ let had_trailing_newline = original_content. ends_with ( '\n' ) ;
145151 // Remove CRLF's carriage return before matching, then restore the chosen convention on output.
146152 let mut original_lines: Vec < String > = original_content
147153 . split ( '\n' )
@@ -252,7 +258,12 @@ pub fn apply_update_chunks(
252258 result. splice ( start_idx..start_idx + old_len, new_segment) ;
253259 }
254260
255- if result. last ( ) . is_none_or ( |line| !line. is_empty ( ) ) {
261+ // Append the terminator sentinel from the tracked flag instead of inferring it
262+ // from the last line's emptiness: a real trailing empty line left behind by a
263+ // file ending in two newlines looks exactly like the sentinel, and declining to
264+ // push in that case would eat one terminal newline. Files that lacked a trailing
265+ // newline keep the historical policy of being normalized to newline-terminated.
266+ if had_trailing_newline || result. last ( ) . is_none_or ( |line| !line. is_empty ( ) ) {
256267 result. push ( String :: new ( ) ) ;
257268 }
258269
@@ -560,12 +571,18 @@ mod tests {
560571 }
561572
562573 #[ test]
563- fn trailing_empty_line_retry_matches_patch_parser_source_514_519 ( ) {
574+ fn trailing_empty_line_hunk_keeps_real_blank_line ( ) {
564575 let chunks = [ chunk ( & [ "alpha" , "" ] , & [ "beta" , "" ] ) ] ;
565576
577+ // The full old pattern ["alpha", ""] never matches the one-line file at the
578+ // strict tiers; the reflow tier matches just "alpha", so new_lines' trailing
579+ // "" survives into the buffer. The join previously absorbed that empty line
580+ // as the terminator sentinel (yielding "beta\n"); since the terminator is
581+ // now tracked from the original content instead of inferred from the last
582+ // line, the empty line is real content and the output gains a blank line.
566583 assert_eq ! (
567584 apply_update_chunks( "alpha\n " , "src/trailing.ts" , & chunks) . unwrap( ) ,
568- "beta\n "
585+ "beta\n \n "
569586 ) ;
570587 }
571588
@@ -629,4 +646,72 @@ mod tests {
629646 assert_eq ! ( result. as_bytes( ) , expected) ;
630647 }
631648 }
649+
650+ #[ test]
651+ fn update_preserves_two_terminal_newlines ( ) {
652+ // Regression repro: "alpha\n\n" splits to ["alpha", "", ""]; popping the split
653+ // terminator leaves a REAL trailing empty line, which the join previously
654+ // mistook for the sentinel and emitted "beta\n" (one newline lost).
655+ let result = apply_update_chunks (
656+ "alpha\n \n " ,
657+ "src/double.txt" ,
658+ & [ chunk ( & [ "alpha" ] , & [ "beta" ] ) ] ,
659+ )
660+ . unwrap ( ) ;
661+ assert_eq ! ( result. as_bytes( ) , b"beta\n \n " ) ;
662+ }
663+
664+ #[ test]
665+ fn update_keeps_single_terminal_newline ( ) {
666+ let result =
667+ apply_update_chunks ( "alpha\n " , "src/single.txt" , & [ chunk ( & [ "alpha" ] , & [ "beta" ] ) ] )
668+ . unwrap ( ) ;
669+ assert_eq ! ( result. as_bytes( ) , b"beta\n " ) ;
670+ }
671+
672+ #[ test]
673+ fn update_normalizes_unterminated_file_to_newline_terminated ( ) {
674+ // Policy preserved from before the terminal-newline fix: apply_patch already
675+ // normalized files without a trailing newline to newline-terminated output
676+ // (see mixed_and_unterminated_files_follow_dominant_newline_policy), so the
677+ // missing newline is deliberately added rather than preserved.
678+ let result =
679+ apply_update_chunks ( "alpha" , "src/bare.txt" , & [ chunk ( & [ "alpha" ] , & [ "beta" ] ) ] ) . unwrap ( ) ;
680+ assert_eq ! ( result. as_bytes( ) , b"beta\n " ) ;
681+ }
682+
683+ #[ test]
684+ fn hunk_that_explicitly_deletes_blank_line_still_deletes_it ( ) {
685+ let interior = apply_update_chunks (
686+ "alpha\n \n beta\n " ,
687+ "src/delete-blank.txt" ,
688+ & [ chunk ( & [ "alpha" , "" ] , & [ "beta" ] ) ] ,
689+ )
690+ . unwrap ( ) ;
691+ assert_eq ! ( interior. as_bytes( ) , b"beta\n beta\n " ) ;
692+
693+ // Deleting the trailing blank line of a two-newline file leaves exactly one
694+ // terminal newline: the hunk consumed the real empty line, the sentinel is
695+ // still restored.
696+ let trailing = apply_update_chunks (
697+ "alpha\n \n " ,
698+ "src/delete-trailing.txt" ,
699+ & [ chunk ( & [ "alpha" , "" ] , & [ "beta" ] ) ] ,
700+ )
701+ . unwrap ( ) ;
702+ assert_eq ! ( trailing. as_bytes( ) , b"beta\n " ) ;
703+ }
704+
705+ #[ test]
706+ fn crlf_with_two_terminal_newlines_preserves_majority_convention ( ) {
707+ // The CRLF normalization (dominant_line_ending) must compose with the
708+ // tracked terminator: both terminal newlines survive, both as CRLF.
709+ let result = apply_update_chunks (
710+ "alpha\r \n \r \n " ,
711+ "src/crlf-double.txt" ,
712+ & [ chunk ( & [ "alpha" ] , & [ "beta" ] ) ] ,
713+ )
714+ . unwrap ( ) ;
715+ assert_eq ! ( result. as_bytes( ) , b"beta\r \n \r \n " ) ;
716+ }
632717}
0 commit comments