@@ -462,6 +462,7 @@ pub(super) struct ParsedPatchLine {
462462 pub ( super ) old_anchor : usize ,
463463 pub ( super ) new_anchor : usize ,
464464 pub ( super ) text : String ,
465+ pub ( super ) no_newline_after : bool ,
465466}
466467
467468#[ derive( Debug , Clone ) ]
@@ -527,32 +528,34 @@ pub(super) fn parse_zero_context_patch(diff_patch: &str) -> Result<ParsedPatch,
527528 }
528529
529530 if let Some ( text) = body_line. strip_prefix ( '+' ) {
530- if !body_line. starts_with ( "+++" ) {
531- parsed_lines. push ( ParsedPatchLine {
532- line_type : SelectionLineType :: Add ,
533- old_line : None ,
534- new_line : Some ( new_cursor) ,
535- old_anchor : old_cursor,
536- new_anchor : new_cursor,
537- text : text. to_string ( ) ,
538- } ) ;
539- new_cursor += 1 ;
540- }
531+ parsed_lines. push ( ParsedPatchLine {
532+ line_type : SelectionLineType :: Add ,
533+ old_line : None ,
534+ new_line : Some ( new_cursor) ,
535+ old_anchor : old_cursor,
536+ new_anchor : new_cursor,
537+ text : text. to_string ( ) ,
538+ no_newline_after : false ,
539+ } ) ;
540+ new_cursor += 1 ;
541541 } else if let Some ( text) = body_line. strip_prefix ( '-' ) {
542- if !body_line. starts_with ( "---" ) {
543- parsed_lines. push ( ParsedPatchLine {
544- line_type : SelectionLineType :: Del ,
545- old_line : Some ( old_cursor) ,
546- new_line : None ,
547- old_anchor : old_cursor,
548- new_anchor : new_cursor,
549- text : text. to_string ( ) ,
550- } ) ;
551- old_cursor += 1 ;
552- }
542+ parsed_lines. push ( ParsedPatchLine {
543+ line_type : SelectionLineType :: Del ,
544+ old_line : Some ( old_cursor) ,
545+ new_line : None ,
546+ old_anchor : old_cursor,
547+ new_anchor : new_cursor,
548+ text : text. to_string ( ) ,
549+ no_newline_after : false ,
550+ } ) ;
551+ old_cursor += 1 ;
553552 } else if body_line. starts_with ( ' ' ) {
554553 old_cursor += 1 ;
555554 new_cursor += 1 ;
555+ } else if body_line == "\\ No newline at end of file" {
556+ if let Some ( last_line) = parsed_lines. last_mut ( ) {
557+ last_line. no_newline_after = true ;
558+ }
556559 }
557560 inner_index += 1 ;
558561 }
@@ -879,6 +882,9 @@ fn append_full_hunk_with_context(
879882 '-'
880883 } ;
881884 output. push ( format ! ( "{prefix}{}" , line. text) ) ;
885+ if line. no_newline_after {
886+ output. push ( "\\ No newline at end of file" . to_string ( ) ) ;
887+ }
882888 }
883889
884890 if after_count > 0 {
@@ -928,6 +934,9 @@ fn build_selected_patch(
928934 '-'
929935 } ;
930936 output. push ( format ! ( "{prefix}{}" , line. text) ) ;
937+ if line. no_newline_after {
938+ output. push ( "\\ No newline at end of file" . to_string ( ) ) ;
939+ }
931940 }
932941 group. clear ( ) ;
933942 } ;
@@ -1111,6 +1120,49 @@ fn build_display_hunk_patch(
11111120 Ok ( ( patch, hunk. lines . len ( ) ) )
11121121}
11131122
1123+ async fn load_selection_source_patch (
1124+ repo_root : & Path ,
1125+ action_path : & str ,
1126+ source : & str ,
1127+ ignore_whitespace_changes : bool ,
1128+ ) -> Result < String , String > {
1129+ let repo = Repository :: open ( repo_root) . map_err ( |e| e. to_string ( ) ) ?;
1130+ let status = repo
1131+ . status_file ( Path :: new ( action_path) )
1132+ . unwrap_or ( Status :: empty ( ) ) ;
1133+ let is_untracked_worktree_file =
1134+ status. contains ( Status :: WT_NEW ) && !status. contains ( Status :: INDEX_NEW ) ;
1135+
1136+ let mut args = vec ! [ "diff" ] ;
1137+ if source == "unstaged" && is_untracked_worktree_file {
1138+ args. push ( "--no-index" ) ;
1139+ args. push ( "--no-color" ) ;
1140+ args. push ( "-U0" ) ;
1141+ if ignore_whitespace_changes {
1142+ args. push ( "-w" ) ;
1143+ }
1144+ args. push ( "--" ) ;
1145+ args. push ( if cfg ! ( windows) { "NUL" } else { "/dev/null" } ) ;
1146+ args. push ( action_path) ;
1147+ } else {
1148+ if source == "staged" {
1149+ args. push ( "--cached" ) ;
1150+ }
1151+ args. push ( "--no-color" ) ;
1152+ args. push ( "-U0" ) ;
1153+ if ignore_whitespace_changes {
1154+ args. push ( "-w" ) ;
1155+ }
1156+ args. push ( "--" ) ;
1157+ args. push ( action_path) ;
1158+ }
1159+
1160+ Ok ( String :: from_utf8_lossy (
1161+ & git_core:: run_git_diff ( & repo_root. to_path_buf ( ) , & args) . await ?,
1162+ )
1163+ . to_string ( ) )
1164+ }
1165+
11141166pub ( super ) async fn stage_git_selection_inner (
11151167 workspaces : & Mutex < HashMap < String , WorkspaceEntry > > ,
11161168 workspace_id : String ,
@@ -1131,9 +1183,9 @@ pub(super) async fn stage_git_selection_inner(
11311183 }
11321184 let action_path = action_paths[ 0 ] . clone ( ) ;
11331185
1134- let ( diff_args , reverse_apply) : ( & [ & str ] , bool ) = match ( op. as_str ( ) , source. as_str ( ) ) {
1135- ( "stage" , "unstaged" ) => ( & [ "diff" , "--no-color" , "-U0" , "--" ] , false ) ,
1136- ( "unstage" , "staged" ) => ( & [ "diff" , "--cached" , "--no-color" , "-U0" , "--" ] , true ) ,
1186+ let reverse_apply = match ( op. as_str ( ) , source. as_str ( ) ) {
1187+ ( "stage" , "unstaged" ) => false ,
1188+ ( "unstage" , "staged" ) => true ,
11371189 ( "stage" , "staged" ) => {
11381190 return Err ( "Staging selected lines requires source `unstaged`." . to_string ( ) ) ;
11391191 }
@@ -1145,14 +1197,8 @@ pub(super) async fn stage_git_selection_inner(
11451197 }
11461198 } ;
11471199
1148- let mut args = diff_args. to_vec ( ) ;
1149- args. push ( action_path. as_str ( ) ) ;
1150- let source_patch = String :: from_utf8_lossy ( & git_core:: run_git_diff (
1151- & repo_root. to_path_buf ( ) ,
1152- & args,
1153- )
1154- . await ?)
1155- . to_string ( ) ;
1200+ let source_patch =
1201+ load_selection_source_patch ( & repo_root, action_path. as_str ( ) , & source, false ) . await ?;
11561202 if source_patch. trim ( ) . is_empty ( ) {
11571203 return Err ( "No changes available for the requested selection source." . to_string ( ) ) ;
11581204 }
@@ -1279,21 +1325,13 @@ pub(super) async fn apply_git_display_hunk_inner(
12791325 _ => unreachable ! ( ) ,
12801326 } ;
12811327
1282- let mut args = vec ! [ "diff" ] ;
1283- if source == "staged" {
1284- args. push ( "--cached" ) ;
1285- }
1286- args. push ( "--no-color" ) ;
1287- args. push ( "-U0" ) ;
1288- if ignore_whitespace_changes {
1289- args. push ( "-w" ) ;
1290- }
1291- args. push ( "--" ) ;
1292- args. push ( action_path. as_str ( ) ) ;
1293- let source_patch = String :: from_utf8_lossy (
1294- & git_core:: run_git_diff ( & repo_root. to_path_buf ( ) , & args) . await ?,
1328+ let source_patch = load_selection_source_patch (
1329+ & repo_root,
1330+ action_path. as_str ( ) ,
1331+ source,
1332+ ignore_whitespace_changes,
12951333 )
1296- . to_string ( ) ;
1334+ . await ? ;
12971335 if source_patch. trim ( ) . is_empty ( ) {
12981336 return Err ( "No changes available for the requested display hunk." . to_string ( ) ) ;
12991337 }
@@ -2030,4 +2068,100 @@ mod tests {
20302068
20312069 fs:: remove_dir_all ( & repo_root) . expect ( "failed to cleanup temp repo" ) ;
20322070 }
2071+
2072+ #[ test]
2073+ fn parse_zero_context_patch_keeps_no_newline_markers ( ) {
2074+ let diff_patch = concat ! (
2075+ "diff --git a/example.txt b/example.txt\n " ,
2076+ "index 1111111..2222222 100644\n " ,
2077+ "--- a/example.txt\n " ,
2078+ "+++ b/example.txt\n " ,
2079+ "@@ -1 +1 @@\n " ,
2080+ "-before\n " ,
2081+ "\\ No newline at end of file\n " ,
2082+ "+after\n " ,
2083+ "\\ No newline at end of file\n "
2084+ ) ;
2085+
2086+ let parsed = parse_zero_context_patch ( diff_patch) . expect ( "parse source patch" ) ;
2087+
2088+ assert_eq ! ( parsed. hunks. len( ) , 1 ) ;
2089+ assert_eq ! ( parsed. hunks[ 0 ] . lines. len( ) , 2 ) ;
2090+ assert ! ( parsed. hunks[ 0 ] . lines[ 0 ] . no_newline_after) ;
2091+ assert ! ( parsed. hunks[ 0 ] . lines[ 1 ] . no_newline_after) ;
2092+ }
2093+
2094+ #[ test]
2095+ fn parse_zero_context_patch_keeps_content_lines_starting_with_patch_header_prefixes ( ) {
2096+ let diff_patch = concat ! (
2097+ "diff --git a/example.txt b/example.txt\n " ,
2098+ "index 1111111..2222222 100644\n " ,
2099+ "--- a/example.txt\n " ,
2100+ "+++ b/example.txt\n " ,
2101+ "@@ -1,2 +1,2 @@\n " ,
2102+ "----title\n " ,
2103+ "-plain\n " ,
2104+ "++++title\n " ,
2105+ "+plain updated\n "
2106+ ) ;
2107+
2108+ let parsed = parse_zero_context_patch ( diff_patch) . expect ( "parse source patch" ) ;
2109+ let texts: Vec < & str > = parsed. hunks [ 0 ] . lines . iter ( ) . map ( |line| line. text . as_str ( ) ) . collect ( ) ;
2110+
2111+ assert_eq ! ( texts, vec![ "---title" , "plain" , "+++title" , "plain updated" ] ) ;
2112+ }
2113+
2114+ #[ test]
2115+ fn build_selected_patch_preserves_no_newline_markers_for_apply ( ) {
2116+ let repo_root = create_temp_repo ( ) ;
2117+ let file_path = repo_root. join ( "example.txt" ) ;
2118+
2119+ fs:: write ( & file_path, "before" ) . expect ( "write baseline" ) ;
2120+ run_git ( & repo_root, & [ "add" , "--" , "example.txt" ] ) ;
2121+ run_git ( & repo_root, & [ "commit" , "-m" , "Initial baseline" , "--quiet" ] ) ;
2122+
2123+ fs:: write ( & file_path, "after" ) . expect ( "write changed file" ) ;
2124+
2125+ let source_patch = run_git ( & repo_root, & [ "diff" , "--no-color" , "-U0" , "--" , "example.txt" ] ) ;
2126+ let parsed = parse_zero_context_patch ( & source_patch) . expect ( "failed to parse source patch" ) ;
2127+ let selected_lines: HashSet < SelectionLineKey > = parsed. hunks [ 0 ]
2128+ . lines
2129+ . iter ( )
2130+ . map ( |line| SelectionLineKey {
2131+ line_type : line. line_type ,
2132+ old_line : line. old_line ,
2133+ new_line : line. new_line ,
2134+ text : line. text . clone ( ) ,
2135+ } )
2136+ . collect ( ) ;
2137+
2138+ let file_context = SelectionSourceFileContext {
2139+ old_lines : vec ! [ "before" . to_string( ) ] ,
2140+ new_lines : vec ! [ "after" . to_string( ) ] ,
2141+ } ;
2142+ let ( selected_patch, _) = build_selected_patch ( & source_patch, & selected_lines, & file_context)
2143+ . expect ( "selection patch failed" ) ;
2144+
2145+ assert ! (
2146+ selected_patch. contains( "\\ No newline at end of file" ) ,
2147+ "selection patch should preserve no-newline marker: {selected_patch}"
2148+ ) ;
2149+
2150+ run_git_with_stdin (
2151+ & repo_root,
2152+ & [ "apply" , "--cached" , "--unidiff-zero" , "--whitespace=nowarn" , "-" ] ,
2153+ & selected_patch,
2154+ ) ;
2155+
2156+ let cached_patch = run_git (
2157+ & repo_root,
2158+ & [ "diff" , "--cached" , "--no-color" , "-U0" , "--" , "example.txt" ] ,
2159+ ) ;
2160+ assert ! (
2161+ cached_patch. contains( "+after" ) ,
2162+ "cached patch did not stage newline-less change: {cached_patch}"
2163+ ) ;
2164+
2165+ fs:: remove_dir_all ( & repo_root) . expect ( "failed to cleanup temp repo" ) ;
2166+ }
20332167}
0 commit comments