diff --git a/pkg/generator/merge/text_merger.go b/pkg/generator/merge/text_merger.go index e98a0ade9d..83593801d3 100644 --- a/pkg/generator/merge/text_merger.go +++ b/pkg/generator/merge/text_merger.go @@ -53,15 +53,29 @@ type MergeResult struct { // - theirs: The template's version (with template updates) // // Returns the merged content or an error if conflicts exceed threshold. +// +// The output also preserves each input's exact trailing-newline count; see +// the newline-handling comment on the diff3.Merge call below for why. func (m *TextMerger) Merge(base, ours, theirs string) (*MergeResult, error) { defer perf.Track(nil, "merge.TextMerger.Merge")() // Perform the 3-way merge using diff3. // Parameter order: (mine/ours, original/base, yours/theirs). + // + // Each input gets one newline appended before diff3 ever sees it. diff3's + // line reader (bufio.Scanner) can't tell how many trailing newlines an + // input had — for N >= 1 trailing newlines, its join step always + // reconstructs exactly N-1 (it loses exactly one, regardless of how many + // there were; for N == 0 there's nothing to lose). Appending one newline + // here bumps every input's count to at least 1, so that guaranteed loss + // of exactly one newline cancels out and the original count survives + // (whichever side's content ends up dominating a given region carries + // its own newline count through unaffected, since the +1/-1 cancellation + // applies to each side independently). mergeResult, err := diff3.Merge( - strings.NewReader(ours), - strings.NewReader(base), - strings.NewReader(theirs), + strings.NewReader(ours+newlineSeparator), + strings.NewReader(base+newlineSeparator), + strings.NewReader(theirs+newlineSeparator), false, // Don't show base in conflict markers. "Ours", "Theirs", diff --git a/pkg/generator/merge/text_merger_test.go b/pkg/generator/merge/text_merger_test.go index 222f2efd98..7eb4ea3748 100644 --- a/pkg/generator/merge/text_merger_test.go +++ b/pkg/generator/merge/text_merger_test.go @@ -752,3 +752,119 @@ text`, }) } } + +// TestTextMerger_TrailingNewlinePreservation asserts exact byte-for-byte +// merge output, trailing-newline count included, split into merges with no +// genuine ours/theirs divergence (result must equal the unchanged content +// verbatim) and genuine template-only changes where ours never diverges from +// base (result must equal theirs verbatim). See Merge's doc comment for why +// each input gets one newline appended before diff3 runs. +func TestTextMerger_TrailingNewlinePreservation(t *testing.T) { + tests := []struct { + name string + base string + ours string + theirs string + want string + }{ + { + name: "identical base/ours/theirs", + base: "line 1\nline 2\nline 3\n", + ours: "line 1\nline 2\nline 3\n", + theirs: "line 1\nline 2\nline 3\n", + want: "line 1\nline 2\nline 3\n", + }, + { + name: "theirs equals ours though base differs", + base: "line 1\nline 2\n", + ours: "line 1\nline 2\nline 3\n", + theirs: "line 1\nline 2\nline 3\n", + want: "line 1\nline 2\nline 3\n", + }, + { + name: "unchanged, one trailing newline", + base: "line 1\nline 2\n", + ours: "line 1\nline 2\n", + theirs: "line 1\nline 2\n", + want: "line 1\nline 2\n", + }, + { + name: "unchanged, no trailing newline", + base: "line 1\nline 2", + ours: "line 1\nline 2", + theirs: "line 1\nline 2", + want: "line 1\nline 2", + }, + { + name: "unchanged, blank line at EOF (two trailing newlines)", + base: "line 1\nline 2\n\n", + ours: "line 1\nline 2\n\n", + theirs: "line 1\nline 2\n\n", + want: "line 1\nline 2\n\n", + }, + { + name: "unchanged, two blank lines at EOF (three trailing newlines)", + base: "line 1\nline 2\n\n\n", + ours: "line 1\nline 2\n\n\n", + theirs: "line 1\nline 2\n\n\n", + want: "line 1\nline 2\n\n\n", + }, + { + name: "unchanged, internal blank line, single trailing newline", + base: "line 1\n\nline 2\n", + ours: "line 1\n\nline 2\n", + theirs: "line 1\n\nline 2\n", + want: "line 1\n\nline 2\n", + }, + { + name: "genuine change, theirs ends with blank line at EOF", + base: "line 1\nline 2\n\n", + ours: "line 1\nline 2\n\n", + theirs: "line 1\nline 2\nline 3\n\n", + want: "line 1\nline 2\nline 3\n\n", + }, + { + name: "genuine change, theirs keeps single trailing newline", + base: "line 1\nline 2\n", + ours: "line 1\nline 2\n", + theirs: "line 1\nline 2\nline 3\n", + want: "line 1\nline 2\nline 3\n", + }, + { + name: "genuine change, theirs has no trailing newline", + base: "line 1\nline 2", + ours: "line 1\nline 2", + theirs: "line 1\nline 2\nline 3", + want: "line 1\nline 2\nline 3", + }, + { + name: "genuine change, theirs adds trailing blank line (EOF newline count only)", + base: "line 1\n", + ours: "line 1\n", + theirs: "line 1\n\n", + want: "line 1\n\n", + }, + { + name: "genuine change, theirs removes trailing newline (EOF newline count only)", + base: "line 1\n", + ours: "line 1\n", + theirs: "line 1", + want: "line 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := NewTextMerger(50).Merge(tt.base, tt.ours, tt.theirs) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if result.Content != tt.want { + t.Errorf("Merge result mismatch\nGot: %q\nWant: %q", result.Content, tt.want) + } + if result.HasConflicts { + t.Errorf("Unexpected conflicts: %d", result.ConflictCount) + } + }) + } +}