Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions pkg/generator/merge/text_merger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
116 changes: 116 additions & 0 deletions pkg/generator/merge/text_merger_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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",
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
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)
}
})
}
}
Loading