From e78b8bb72f2b38817d24683b026330e3733d4f68 Mon Sep 17 00:00:00 2001 From: neo Date: Tue, 3 Mar 2026 18:12:38 +0900 Subject: [PATCH 1/3] Allow non-conflict edit and reoload --- internal/tui/merge_apply_test.go | 337 ++++++++++++++++++++- internal/tui/render_helpers.go | 24 +- internal/tui/render_helpers_test.go | 24 +- internal/tui/tui.go | 447 ++++++++++++++++++++++++++-- 4 files changed, 794 insertions(+), 38 deletions(-) diff --git a/internal/tui/merge_apply_test.go b/internal/tui/merge_apply_test.go index 38cb4c0..c464af0 100644 --- a/internal/tui/merge_apply_test.go +++ b/internal/tui/merge_apply_test.go @@ -98,11 +98,340 @@ func TestApplyMergedResolutionsSkipsConflictMarkers(t *testing.T) { } } -func TestApplyMergedResolutionsAlignmentFailure(t *testing.T) { +func TestApplyMergedResolutionsAllowsNonConflictDeletion(t *testing.T) { doc := parseSingleConflictDoc(t) - _, _, _, _, err := applyMergedResolutions(doc, []byte("ours\nend\n")) - if err == nil { - t.Fatalf("expected alignment error") + merged := []byte("ours\nend\n") + + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("expected no manual resolutions") + } + seg := conflictSegment(t, updated, 0) + if seg.Resolution != markers.ResolutionOurs { + t.Fatalf("resolution = %q, want %q", seg.Resolution, markers.ResolutionOurs) + } + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) + } +} + +func TestApplyMergedResolutionsPreservesNonConflictEditsWhenResolved(t *testing.T) { + doc := parseSingleConflictDoc(t) + merged := []byte("start edited\nextra line\nours\nend changed\n") + + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("expected no manual resolutions") + } + seg := conflictSegment(t, updated, 0) + if seg.Resolution != markers.ResolutionOurs { + t.Fatalf("resolution = %q, want %q", seg.Resolution, markers.ResolutionOurs) + } + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) + } +} + +func TestApplyMergedResolutionsHandlesEditedSingleLineSeparator(t *testing.T) { + data := []byte("intro\n<<<<<<< HEAD\nours1\n=======\ntheirs1\n>>>>>>> branch\nanchor-one\n<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>> branch\ntail\n") + doc, err := markers.Parse(data) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte("intro\nanchor-one@@\nmanual2\ntail\n") + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + + if _, ok := manual[0]; ok { + t.Fatalf("conflict 0 should not be manual") + } + seg0 := conflictSegment(t, updated, 0) + if seg0.Resolution != markers.ResolutionNone { + t.Fatalf("conflict 0 resolution = %q, want %q", seg0.Resolution, markers.ResolutionNone) + } + + if got := string(manual[1]); got != "manual2\n" { + t.Fatalf("manual[1] = %q, want %q", got, "manual2\\n") + } + seg1 := conflictSegment(t, updated, 1) + if seg1.Resolution != markers.ResolutionUnset { + t.Fatalf("conflict 1 resolution = %q, want unset", seg1.Resolution) + } + + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected fully resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) + } +} + +func TestApplyMergedResolutionsWitrLicenseScenario(t *testing.T) { + diff3 := []byte(" Apache License\n" + + " Version 2.0, January 2004\n" + + " http://www.apache.org/licenses/\n" + + "\n" + + " TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n" + + "\n" + + " 1. Definitions.\n" + + "\n" + + " \"License\" shall mean the terms and conditions for use, reproduction,\n" + + " and distribution as defined by Sections 1 through 9 of this document.\n" + + "\n" + + " \"Licensor\" shall mean the copyright owner or entity authorized by\n" + + " the copyright owner that is granting the License.B\n" + + "\n" + + " \"Legal Entity\" shall mean the union of the acting entity and all\n" + + " other entities that control, are controlled by, or are under common\n" + + " control with that entity. For the purposes of this definition,\n" + + " \"control\" means (i) the power, direct or indirect, to cause the\n" + + " direction or management of such entity, whether by contract or\n" + + " otherwise, or (ii) ownership of fifty percent (50%) or more of the\n" + + "<<<<<<< HEAD\n" + + " outstanding shares, or (iii) beneficial ownership of such entity.A\n" + + "||||||| base\n" + + " outstanding shares, or (iii) beneficial ownership of such entity.\n" + + "=======\n" + + " outstanding shares, or (iii) beneficial ownership of such entity.B\n" + + ">>>>>>> branch\n" + + "\n" + + "<<<<<<< HEAD\n" + + " \"You\" (or \"Your\") shall mean an individual or Legal Entity\n" + + " exercising permissions granted by this License.A\n" + + "||||||| base\n" + + " \"You\" (or \"Your\") shall mean an individual or Legal Entity\n" + + " exercising permissions granted by this License.\n" + + "=======\n" + + " \"You\" (or \"Your\") shall mean an individual or Legal EntityB\n" + + " exercising permissions granted by this License.\n" + + ">>>>>>> branch\n" + + "\n" + + "<<<<<<< HEAD\n" + + "||||||| base\n" + + " \"Source\" form shall mean the preferred form for making modifications,\n" + + "=======\n" + + "asdsadf\n" + + " \"Source\" form shall mean the preferred form for making modifications,\n" + + ">>>>>>> branch\n" + + " including but not limited to software source code, documentation\n" + + " source, and configuration files.\n") + + doc, err := markers.Parse(diff3) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte(" Apache License\n" + + " Version 2.0, January 2004\n" + + " http://www.apache.org/licenses/\n" + + "\n" + + " TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n" + + "\n" + + " 1. Definitions.@@#@#@#\n" + + "\n" + + " \"License\" shall mean the terms and conditions for use, reproduction,\n" + + " and distribution as defined by Sections 1 through 9 of this document.\n" + + "\n" + + " \"Licensor\" shall mean the copyright owner or entity authorized by\n" + + " the copyright owner that is granting the License.B\n" + + "\n" + + " \"Legal Entity\" shall mean the union of the acting entity and all\n" + + " other entities that control, are controlled by, or are under common\n" + + " control with that entity. For the purposes of this definition,\n" + + " \"control\" means (i) the power, direct or indirect, to cause the\n" + + " direction or management of such entity, whether by contract or\n" + + " otherwise, or (ii) ownership of fifty percent (50%) or more of the\n" + + " outstanding shares, or (iii) beneficial ownership of such entity.A\n" + + "\n" + + " \"You\" (or \"Your\") shall mean an individual or Legal Entity\n" + + " exercising permissions granted by this License.A\n" + + "\n" + + " including but not limited to software source code, documentation\n" + + " source, and configuration files.\n") + + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("manualResolved len = %d, want 0", len(manual)) + } + + if got := conflictSegment(t, updated, 0).Resolution; got != markers.ResolutionOurs { + t.Fatalf("conflict 0 resolution = %q, want %q", got, markers.ResolutionOurs) + } + if got := conflictSegment(t, updated, 1).Resolution; got != markers.ResolutionOurs { + t.Fatalf("conflict 1 resolution = %q, want %q", got, markers.ResolutionOurs) + } + if got := conflictSegment(t, updated, 2).Resolution; got != markers.ResolutionOurs { + t.Fatalf("conflict 2 resolution = %q, want %q", got, markers.ResolutionOurs) + } + + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected fully resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered merged output mismatch") + } +} + +func TestApplyMergedResolutionsPrefersSingleNonEmptySideOverBoth(t *testing.T) { + data := []byte("start\n<<<<<<< HEAD\n||||||| base\nsource\n=======\nasdsadf\nsource\n>>>>>>> branch\nend\n") + doc, err := markers.Parse(data) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte("start\nasdsadf\nsource\nend\n") + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("expected no manual resolution, got %d", len(manual)) + } + + seg := conflictSegment(t, updated, 0) + if seg.Resolution != markers.ResolutionTheirs { + t.Fatalf("resolution = %q, want %q", seg.Resolution, markers.ResolutionTheirs) + } + + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) + } +} + +func TestApplyMergedResolutionsEmptyOursReopensAsOurs(t *testing.T) { + data := []byte("start\n<<<<<<< HEAD\n=======\ntheirs\n>>>>>>> branch\nend\n") + doc, err := markers.Parse(data) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte("start\nend\n") + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("expected no manual resolution, got %d", len(manual)) + } + + if got := conflictSegment(t, updated, 0).Resolution; got != markers.ResolutionOurs { + t.Fatalf("resolution = %q, want %q", got, markers.ResolutionOurs) + } + + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) + } +} + +func TestApplyMergedResolutionsEmptyTheirsReopensAsTheirs(t *testing.T) { + data := []byte("start\n<<<<<<< HEAD\nours\n=======\n>>>>>>> branch\nend\n") + doc, err := markers.Parse(data) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte("start\nend\n") + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("expected no manual resolution, got %d", len(manual)) + } + + if got := conflictSegment(t, updated, 0).Resolution; got != markers.ResolutionTheirs { + t.Fatalf("resolution = %q, want %q", got, markers.ResolutionTheirs) + } + + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) + } +} + +func TestApplyMergedResolutionsEmptyBothStaysNone(t *testing.T) { + data := []byte("start\n<<<<<<< HEAD\n=======\n>>>>>>> branch\nend\n") + doc, err := markers.Parse(data) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte("start\nend\n") + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("expected no manual resolution, got %d", len(manual)) + } + + if got := conflictSegment(t, updated, 0).Resolution; got != markers.ResolutionNone { + t.Fatalf("resolution = %q, want %q", got, markers.ResolutionNone) + } + + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) } } diff --git a/internal/tui/render_helpers.go b/internal/tui/render_helpers.go index 8830c90..eb7ee10 100644 --- a/internal/tui/render_helpers.go +++ b/internal/tui/render_helpers.go @@ -499,6 +499,16 @@ func buildResultLines(doc markers.Document, highlightConflict int, selectedSide dim: true, connector: connectorForResult(false, selected), }) + } else if effectiveResolution == markers.ResolutionNone && selected { + lines = append(lines, lineInfo{ + text: "[resolved: none]", + category: categoryResolved, + highlight: true, + selected: selected, + underline: underline, + dim: false, + connector: connectorForResult(true, selected), + }) } continue } @@ -533,7 +543,7 @@ func buildResultLines(doc markers.Document, highlightConflict int, selectedSide return lines, currentStart } -func buildResultPreviewLines(doc markers.Document, selectedSide selectionSide, manualResolved map[int][]byte) ([]string, map[int]lineCategory, []resultRange) { +func buildResultPreviewLines(doc markers.Document, selectedSide selectionSide, manualResolved map[int][]byte, highlightConflict int) ([]string, map[int]lineCategory, []resultRange) { var lines []string forced := map[int]lineCategory{} ranges := make([]resultRange, 0, len(doc.Conflicts)) @@ -575,9 +585,15 @@ func buildResultPreviewLines(doc markers.Document, selectedSide selectionSide, m appendLines(splitLines(s.Ours)) appendLines(splitLines(s.Theirs)) case markers.ResolutionNone: - placeholder := "[unresolved conflict]" - forced[len(lines)] = categoryConflicted - appendLines([]string{placeholder}) + if !resolved { + placeholder := "[unresolved conflict]" + forced[len(lines)] = categoryConflicted + appendLines([]string{placeholder}) + } else if conflictIndex == highlightConflict { + placeholder := "[resolved: none]" + forced[len(lines)] = categoryResolved + appendLines([]string{placeholder}) + } } ranges = append(ranges, resultRange{start: start, end: len(lines), resolved: resolved}) diff --git a/internal/tui/render_helpers_test.go b/internal/tui/render_helpers_test.go index 89877ba..01b93c7 100644 --- a/internal/tui/render_helpers_test.go +++ b/internal/tui/render_helpers_test.go @@ -232,7 +232,7 @@ func TestBuildResultPreviewLinesUsesSelection(t *testing.T) { Conflicts: []markers.ConflictRef{{SegmentIndex: 1}}, } - lines, forced, ranges := buildResultPreviewLines(doc, selectedTheirs, nil) + lines, forced, ranges := buildResultPreviewLines(doc, selectedTheirs, nil, 0) if len(forced) != 0 { t.Fatalf("forced len = %d, want 0", len(forced)) } @@ -268,18 +268,24 @@ func TestBuildResultPreviewLinesManualAndNone(t *testing.T) { } manual := map[int][]byte{0: []byte("manual\n")} - lines, forced, ranges := buildResultPreviewLines(doc, selectedOurs, manual) + lines, forced, ranges := buildResultPreviewLines(doc, selectedOurs, manual, 1) if len(lines) != 5 { t.Fatalf("lines len = %d, want 5", len(lines)) } if lines[1] != "manual" { t.Fatalf("manual line = %q, want manual", lines[1]) } - if lines[3] != "[unresolved conflict]" { - t.Fatalf("placeholder line = %q, want unresolved conflict", lines[3]) + if lines[2] != "middle" { + t.Fatalf("middle line = %q, want middle", lines[2]) } - if forced[3] != categoryConflicted { - t.Fatalf("forced category = %v, want conflicted", forced[3]) + if lines[3] != "[resolved: none]" { + t.Fatalf("resolved-none marker line = %q, want [resolved: none]", lines[3]) + } + if lines[4] != "end" { + t.Fatalf("end line = %q, want end", lines[4]) + } + if forced[3] != categoryResolved { + t.Fatalf("forced category = %v, want resolved", forced[3]) } if len(ranges) != 2 { t.Fatalf("ranges len = %d, want 2", len(ranges)) @@ -287,6 +293,12 @@ func TestBuildResultPreviewLinesManualAndNone(t *testing.T) { if !ranges[0].resolved { t.Fatalf("range 0 resolved = false, want true") } + if !ranges[1].resolved { + t.Fatalf("range 1 resolved = false, want true") + } + if ranges[1].end-ranges[1].start != 1 { + t.Fatalf("range 1 span len = %d, want 1", ranges[1].end-ranges[1].start) + } } func TestEntriesFromLines(t *testing.T) { diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e64dd14..eed4e9c 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1053,7 +1053,7 @@ func (m *model) updateViewports() { var resultLines []lineInfo var resultStart int if useFullDiff { - previewLines, forced, resultRanges := buildResultPreviewLines(m.doc, m.selectedSide, m.manualResolved) + previewLines, forced, resultRanges := buildResultPreviewLines(m.doc, m.selectedSide, m.manualResolved, m.currentConflict) resultEntries := diffEntries(m.baseLines, previewLines) resultLines, resultStart = buildResultLinesFromEntries(resultEntries, resultRanges, m.currentConflict, forced) } else { @@ -1376,25 +1376,66 @@ func applyMergedResolutions(doc markers.Document, mergedBytes []byte) (markers.D alignedLabelKnown := make([]bool, len(doc.Conflicts)) conflictIndex := -1 + pendingTextIndex := -1 + pendingTextStart := 0 + + setPendingText := func(end int) error { + if pendingTextIndex < 0 { + return nil + } + if end < pendingTextStart { + end = pendingTextStart + } + if end > len(mergedLines) { + end = len(mergedLines) + } + + textSeg, ok := doc.Segments[pendingTextIndex].(markers.TextSegment) + if !ok { + return fmt.Errorf("internal: expected text segment at index %d", pendingTextIndex) + } + textSeg.Bytes = bytes.Join(mergedLines[pendingTextStart:end], nil) + doc.Segments[pendingTextIndex] = textSeg + pendingTextIndex = -1 + return nil + } + for i, seg := range doc.Segments { switch s := seg.(type) { case markers.TextSegment: - textLines := markers.SplitLinesKeepEOL(s.Bytes) - if len(textLines) == 0 { - continue - } - idx := findSubslice(mergedLines, pos, textLines) - if idx == -1 { - return doc, manualResolved, alignedLabels, alignedLabelKnown, fmt.Errorf("failed to align text segment") - } - pos = idx + len(textLines) + _ = s + pendingTextIndex = i + pendingTextStart = pos case markers.ConflictSegment: conflictIndex++ + + searchPos := pos + var pendingTextLines [][]byte + if pendingTextIndex >= 0 { + textSeg, ok := doc.Segments[pendingTextIndex].(markers.TextSegment) + if !ok { + return doc, manualResolved, alignedLabels, alignedLabelKnown, fmt.Errorf("internal: expected text segment at index %d", pendingTextIndex) + } + pendingTextLines = markers.SplitLinesKeepEOL(textSeg.Bytes) + if len(pendingTextLines) > 0 { + searchPos = alignTextSegmentEnd(mergedLines, pos, pendingTextLines) + if searchPos < pos { + searchPos = pos + } + if searchPos > len(mergedLines) { + searchPos = len(mergedLines) + } + } + } + nextTextLines := nextTextSegmentLines(doc.Segments, i+1) nextIdx := -1 if len(nextTextLines) > 0 { - nextIdx = findSubslice(mergedLines, pos, nextTextLines) + nextIdx = findSubslice(mergedLines, searchPos, nextTextLines) + if nextIdx == -1 { + nextIdx = findApproxSubslice(mergedLines, searchPos, nextTextLines) + } } if nextIdx == -1 { nextIdx = len(mergedLines) @@ -1403,29 +1444,387 @@ func applyMergedResolutions(doc markers.Document, mergedBytes []byte) (markers.D return doc, manualResolved, alignedLabels, alignedLabelKnown, fmt.Errorf("failed to align conflict segment") } spanLines := mergedLines[pos:nextIdx] - if containsConflictMarkers(spanLines) { - alignedLabels[conflictIndex] = labelsFromConflictSpan(spanLines) + + start, end, resolution, manualBytes, labels, labelsKnown := classifyConflictSpan(spanLines, pendingTextLines, s) + if start < 0 || end < start || end > len(spanLines) { + return doc, manualResolved, alignedLabels, alignedLabelKnown, fmt.Errorf("internal: invalid conflict span classification") + } + + if err := setPendingText(pos + start); err != nil { + return doc, manualResolved, alignedLabels, alignedLabelKnown, err + } + + if labelsKnown { + alignedLabels[conflictIndex] = labels alignedLabelKnown[conflictIndex] = true - pos = nextIdx - continue } - resolution, matched := matchResolution(spanLines, s) - if matched { + + if manualBytes != nil { + manualResolved[conflictIndex] = manualBytes + } else { s.Resolution = resolution doc.Segments[i] = s - pos = nextIdx + } + + pos += end + } + } + + if err := setPendingText(len(mergedLines)); err != nil { + return doc, manualResolved, alignedLabels, alignedLabelKnown, err + } + + return doc, manualResolved, alignedLabels, alignedLabelKnown, nil +} + +func classifyConflictSpan(spanLines [][]byte, pendingTextLines [][]byte, seg markers.ConflictSegment) (int, int, markers.Resolution, []byte, conflictLabels, bool) { + if markerStart, markerEnd, ok := locateConflictMarkerSpan(spanLines); ok { + labels := labelsFromConflictSpan(spanLines[markerStart:markerEnd]) + return markerStart, markerEnd, markers.ResolutionUnset, nil, labels, true + } + + if len(spanLines) == 0 { + return 0, 0, inferEmptyOutputResolution(seg), nil, conflictLabels{}, false + } + + if matchStart, matchEnd, resolution, ok := findBestResolutionMatch(spanLines, seg); ok { + return matchStart, matchEnd, resolution, nil, conflictLabels{}, false + } + + manualStart := detectManualStart(spanLines, pendingTextLines) + if manualStart == len(spanLines) { + return manualStart, len(spanLines), inferEmptyOutputResolution(seg), nil, conflictLabels{}, false + } + return manualStart, len(spanLines), markers.ResolutionUnset, bytes.Join(spanLines[manualStart:], nil), conflictLabels{}, false +} + +func inferEmptyOutputResolution(seg markers.ConflictSegment) markers.Resolution { + oursEmpty := len(markers.SplitLinesKeepEOL(seg.Ours)) == 0 + theirsEmpty := len(markers.SplitLinesKeepEOL(seg.Theirs)) == 0 + + if oursEmpty && !theirsEmpty { + return markers.ResolutionOurs + } + if theirsEmpty && !oursEmpty { + return markers.ResolutionTheirs + } + + return markers.ResolutionNone +} + +func detectManualStart(spanLines [][]byte, pendingTextLines [][]byte) int { + if len(spanLines) == 0 || len(pendingTextLines) == 0 { + return 0 + } + + if idx := findSubslice(spanLines, 0, pendingTextLines); idx != -1 { + start := idx + len(pendingTextLines) + if start > len(spanLines) { + return len(spanLines) + } + return start + } + + if idx := findApproxSubslice(spanLines, 0, pendingTextLines); idx != -1 { + start := idx + len(pendingTextLines) + if start < 0 { + start = 0 + } + if start > len(spanLines) { + start = len(spanLines) + } + return start + } + + return 0 +} + +func locateConflictMarkerSpan(lines [][]byte) (int, int, bool) { + start := -1 + for i, line := range lines { + if bytes.HasPrefix(line, []byte("<<<<<<<")) { + start = i + break + } + } + if start == -1 { + return -1, -1, false + } + + for i := start + 1; i < len(lines); i++ { + if bytes.HasPrefix(lines[i], []byte(">>>>>>>")) { + return start, i + 1, true + } + } + + return start, len(lines), true +} + +func findBestResolutionMatch(spanLines [][]byte, seg markers.ConflictSegment) (int, int, markers.Resolution, bool) { + if len(spanLines) == 0 { + return 0, 0, inferEmptyOutputResolution(seg), true + } + + ours := markers.SplitLinesKeepEOL(seg.Ours) + theirs := markers.SplitLinesKeepEOL(seg.Theirs) + both := append(append([][]byte{}, ours...), theirs...) + + candidates := []struct { + resolution markers.Resolution + lines [][]byte + }{ + {resolution: markers.ResolutionOurs, lines: ours}, + {resolution: markers.ResolutionTheirs, lines: theirs}, + {resolution: markers.ResolutionBoth, lines: both}, + } + + found := false + bestStart := 0 + bestEnd := 0 + bestResolution := markers.ResolutionUnset + bestTotal := 0 + bestSuffix := 0 + bestPrefix := 0 + + for _, candidate := range candidates { + if len(candidate.lines) == 0 { + continue + } + + searchStart := 0 + for { + idx := findSubslice(spanLines, searchStart, candidate.lines) + if idx == -1 { + break + } + + end := idx + len(candidate.lines) + prefix := idx + suffix := len(spanLines) - end + total := prefix + suffix + + if !found || + total < bestTotal || + (total == bestTotal && suffix < bestSuffix) || + (total == bestTotal && suffix == bestSuffix && prefix < bestPrefix) { + found = true + bestStart = idx + bestEnd = end + bestResolution = candidate.resolution + bestTotal = total + bestSuffix = suffix + bestPrefix = prefix + } + + searchStart = idx + 1 + } + } + + if !found { + return 0, 0, markers.ResolutionUnset, false + } + + return bestStart, bestEnd, bestResolution, true +} + +func findApproxSubslice(haystack [][]byte, start int, needle [][]byte) int { + if len(needle) == 0 { + return start + } + if start < 0 { + start = 0 + } + + if len(needle) == 1 { + return findApproxLineIndex(haystack, start, needle[0]) + } + + window := len(needle) + if window > 8 { + window = 8 + } + + for size := window; size >= 2; size-- { + for offset := 0; offset+size <= len(needle); offset++ { + chunk := needle[offset : offset+size] + idx := findSubslice(haystack, start, chunk) + if idx == -1 { continue } - var manualBytes []byte - if len(spanLines) > 0 { - manualBytes = bytes.Join(spanLines, nil) + + candidateStart := idx - offset + if candidateStart < start { + continue } - manualResolved[conflictIndex] = manualBytes - pos = nextIdx + + return candidateStart } } - return doc, manualResolved, alignedLabels, alignedLabelKnown, nil + return -1 +} + +func findApproxLineIndex(lines [][]byte, start int, needle []byte) int { + needleTrimmed := bytes.TrimRight(needle, "\r\n") + if len(needleTrimmed) == 0 { + return -1 + } + + bestIndex := -1 + bestScore := 0 + for i := start; i < len(lines); i++ { + score := lineSimilarityPercent(lines[i], needle) + if score > bestScore { + bestScore = score + bestIndex = i + } + } + + if bestScore >= 70 { + return bestIndex + } + + return -1 +} + +func alignTextSegmentEnd(mergedLines [][]byte, start int, textLines [][]byte) int { + if start < 0 { + start = 0 + } + if start > len(mergedLines) { + return len(mergedLines) + } + if len(textLines) == 0 { + return start + } + + if idx := findSubslice(mergedLines, start, textLines); idx != -1 { + return idx + len(textLines) + } + + mergedIndex := start + textIndex := 0 + for textIndex < len(textLines) && mergedIndex < len(mergedLines) { + if linesEquivalentForAlignment(mergedLines[mergedIndex], textLines[textIndex]) { + mergedIndex++ + textIndex++ + continue + } + + if mergedIndex+1 < len(mergedLines) && linesEquivalentForAlignment(mergedLines[mergedIndex+1], textLines[textIndex]) { + mergedIndex++ + continue + } + + if textIndex+1 < len(textLines) && linesEquivalentForAlignment(mergedLines[mergedIndex], textLines[textIndex+1]) { + textIndex++ + continue + } + + mergedIndex++ + textIndex++ + } + + if mergedIndex > len(mergedLines) { + return len(mergedLines) + } + + return mergedIndex +} + +func linesEquivalentForAlignment(a []byte, b []byte) bool { + aTrimmed := bytes.TrimRight(a, "\r\n") + bTrimmed := bytes.TrimRight(b, "\r\n") + + if bytes.Equal(aTrimmed, bTrimmed) { + return true + } + + if len(aTrimmed) == 0 || len(bTrimmed) == 0 { + return false + } + + return lineSimilarityPercent(a, b) >= 88 +} + +func lineSimilarityPercent(a []byte, b []byte) int { + aTrimmed := bytes.TrimRight(a, "\r\n") + bTrimmed := bytes.TrimRight(b, "\r\n") + + if bytes.Equal(aTrimmed, bTrimmed) { + return 100 + } + + maxLen := len(aTrimmed) + if len(bTrimmed) > maxLen { + maxLen = len(bTrimmed) + } + if maxLen == 0 { + return 100 + } + + minLen := len(aTrimmed) + if len(bTrimmed) < minLen { + minLen = len(bTrimmed) + } + + best := 0 + if minLen > 0 && (bytes.Contains(aTrimmed, bTrimmed) || bytes.Contains(bTrimmed, aTrimmed)) { + best = minLen * 100 / maxLen + } + + prefix := commonPrefixLen(aTrimmed, bTrimmed) + suffix := commonSuffixLen(aTrimmed, bTrimmed, prefix) + if prefix+suffix > minLen { + suffix = minLen - prefix + if suffix < 0 { + suffix = 0 + } + } + + combined := (prefix + suffix) * 100 / maxLen + if combined > best { + best = combined + } + + return best +} + +func commonPrefixLen(a []byte, b []byte) int { + limit := len(a) + if len(b) < limit { + limit = len(b) + } + + count := 0 + for count < limit && a[count] == b[count] { + count++ + } + + return count +} + +func commonSuffixLen(a []byte, b []byte, prefix int) int { + limit := len(a) + if len(b) < limit { + limit = len(b) + } + if prefix > limit { + prefix = limit + } + + maxSuffix := limit - prefix + count := 0 + for count < maxSuffix { + ai := len(a) - 1 - count + bi := len(b) - 1 - count + if a[ai] != b[bi] { + break + } + count++ + } + + return count } func labelsFromConflictSpan(lines [][]byte) conflictLabels { From 0b73be70b98be0888977f2788e024653a2635746 Mon Sep 17 00:00:00 2001 From: neo Date: Fri, 6 Mar 2026 18:00:39 +0900 Subject: [PATCH 2/3] Fix false resolved for partial manual conflict edits --- internal/tui/merge_apply_test.go | 123 ++++++++++++++++++++++++++++++- internal/tui/tui.go | 100 +++++++++++++++++++++++-- 2 files changed, 213 insertions(+), 10 deletions(-) diff --git a/internal/tui/merge_apply_test.go b/internal/tui/merge_apply_test.go index c464af0..8ec6a2f 100644 --- a/internal/tui/merge_apply_test.go +++ b/internal/tui/merge_apply_test.go @@ -1,6 +1,7 @@ package tui import ( + "bytes" "testing" "github.com/chojs23/ec/internal/markers" @@ -193,8 +194,8 @@ func TestApplyMergedResolutionsHandlesEditedSingleLineSeparator(t *testing.T) { } } -func TestApplyMergedResolutionsWitrLicenseScenario(t *testing.T) { - diff3 := []byte(" Apache License\n" + +func witrLicenseDiff3Fixture() []byte { + return []byte(" Apache License\n" + " Version 2.0, January 2004\n" + " http://www.apache.org/licenses/\n" + "\n" + @@ -242,6 +243,10 @@ func TestApplyMergedResolutionsWitrLicenseScenario(t *testing.T) { ">>>>>>> branch\n" + " including but not limited to software source code, documentation\n" + " source, and configuration files.\n") +} + +func TestApplyMergedResolutionsWitrLicenseScenario(t *testing.T) { + diff3 := witrLicenseDiff3Fixture() doc, err := markers.Parse(diff3) if err != nil { @@ -294,6 +299,86 @@ func TestApplyMergedResolutionsWitrLicenseScenario(t *testing.T) { t.Fatalf("conflict 2 resolution = %q, want %q", got, markers.ResolutionOurs) } + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected fully resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered merged output mismatch:\nrendered=%q\nmerged=%q", string(rendered), string(merged)) + } +} + +func TestApplyMergedResolutionsWitrLicensePartialConflictStaysManual(t *testing.T) { + doc, err := markers.Parse(witrLicenseDiff3Fixture()) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte(" Apache License\n" + + " Version 2.0, January 2004\n" + + " http://www.apache.org/licenses/\n" + + "\n" + + " TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n" + + "\n" + + " 1. Definitions.\n" + + "\n" + + " \"License\" shall mean the terms and conditions for use, reproduction,\n" + + " and distribution as defined by Sections 1 through 9 of this document.\n" + + "\n" + + " \"Licensor\" shall mean the copyright owner or entity authorized by\n" + + " the copyright owner that is granting the License.B@#\n" + + "\n" + + " \"Legal Entity\" shall mean the union of the acting entity and all\n" + + " other entities that control, are controlled by, or are under common\n" + + " control with that entity. For the purposes of this definition,\n" + + " outstanding shares, or (iii) beneficial ownership of such entity.A\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + " \"You\" (or \"Your\") shall mean an individual or Legal Entity\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "asdsadf\n" + + " \"Source\" form shall mean the preferred form for making modifications,\n" + + " including but not limited to software source code, documentation\n" + + " source, and configuration files.\n") + + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 1 { + t.Fatalf("manualResolved len = %d, want 1", len(manual)) + } + + if got := conflictSegment(t, updated, 1).Resolution; got != markers.ResolutionUnset { + t.Fatalf("conflict 1 resolution = %q, want unset for manual conflict", got) + } + if got := conflictSegment(t, updated, 2).Resolution; got != markers.ResolutionTheirs { + t.Fatalf("conflict 2 resolution = %q, want %q", got, markers.ResolutionTheirs) + } + + manualBytes, ok := manual[1] + if !ok { + t.Fatalf("expected manual resolution for conflict 1") + } + if !bytes.Contains(manualBytes, []byte("\"You\" (or \"Your\") shall mean an individual or Legal Entity\n")) { + t.Fatalf("manual conflict missing kept LICENSE line: %q", string(manualBytes)) + } + if bytes.Contains(manualBytes, []byte("asdsadf\n")) { + t.Fatalf("manual conflict consumed next conflict output: %q", string(manualBytes)) + } + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) if err != nil { t.Fatalf("renderMergedOutput error: %v", err) @@ -306,6 +391,40 @@ func TestApplyMergedResolutionsWitrLicenseScenario(t *testing.T) { } } +func TestApplyMergedResolutionsKeepsTrueEmptyConflictWithBlankSeparator(t *testing.T) { + data := []byte("start\n<<<<<<< HEAD\nours1\n=======\ntheirs1\n>>>>>>> branch\n\n<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>> branch\nend\n") + doc, err := markers.Parse(data) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte("start\n\ntheirs2\nend\n") + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("expected no manual resolutions, got %d: manual=%v", len(manual), manual) + } + if got := conflictSegment(t, updated, 0).Resolution; got != markers.ResolutionNone { + t.Fatalf("conflict 0 resolution = %q, want %q", got, markers.ResolutionNone) + } + if got := conflictSegment(t, updated, 1).Resolution; got != markers.ResolutionTheirs { + t.Fatalf("conflict 1 resolution = %q, want %q", got, markers.ResolutionTheirs) + } + + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected fully resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) + } +} + func TestApplyMergedResolutionsPrefersSingleNonEmptySideOverBoth(t *testing.T) { data := []byte("start\n<<<<<<< HEAD\n||||||| base\nsource\n=======\nasdsadf\nsource\n>>>>>>> branch\nend\n") doc, err := markers.Parse(data) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index eed4e9c..aaf1d2b 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1436,6 +1436,11 @@ func applyMergedResolutions(doc markers.Document, mergedBytes []byte) (markers.D if nextIdx == -1 { nextIdx = findApproxSubslice(mergedLines, searchPos, nextTextLines) } + if nextIdx == searchPos && textLinesBlankOnly(nextTextLines) { + if fallbackIdx := findNextConflictBoundary(mergedLines, searchPos, doc.Segments, i+1); fallbackIdx > searchPos { + nextIdx = fallbackIdx + } + } } if nextIdx == -1 { nextIdx = len(mergedLines) @@ -1486,14 +1491,23 @@ func classifyConflictSpan(spanLines [][]byte, pendingTextLines [][]byte, seg mar if len(spanLines) == 0 { return 0, 0, inferEmptyOutputResolution(seg), nil, conflictLabels{}, false } + if textLinesBlankOnly(spanLines) { + return len(spanLines), len(spanLines), inferEmptyOutputResolution(seg), nil, conflictLabels{}, false + } if matchStart, matchEnd, resolution, ok := findBestResolutionMatch(spanLines, seg); ok { return matchStart, matchEnd, resolution, nil, conflictLabels{}, false } - manualStart := detectManualStart(spanLines, pendingTextLines) + manualStart, manualExact := detectManualStart(spanLines, pendingTextLines) + if manualStart < len(spanLines) && textLinesBlankOnly(spanLines[manualStart:]) { + return len(spanLines), len(spanLines), inferEmptyOutputResolution(seg), nil, conflictLabels{}, false + } if manualStart == len(spanLines) { - return manualStart, len(spanLines), inferEmptyOutputResolution(seg), nil, conflictLabels{}, false + if manualExact && (!textLinesBlankOnly(pendingTextLines) || textLinesBlankOnly(spanLines)) { + return manualStart, len(spanLines), inferEmptyOutputResolution(seg), nil, conflictLabels{}, false + } + return 0, len(spanLines), markers.ResolutionUnset, bytes.Join(spanLines, nil), conflictLabels{}, false } return manualStart, len(spanLines), markers.ResolutionUnset, bytes.Join(spanLines[manualStart:], nil), conflictLabels{}, false } @@ -1512,17 +1526,17 @@ func inferEmptyOutputResolution(seg markers.ConflictSegment) markers.Resolution return markers.ResolutionNone } -func detectManualStart(spanLines [][]byte, pendingTextLines [][]byte) int { +func detectManualStart(spanLines [][]byte, pendingTextLines [][]byte) (int, bool) { if len(spanLines) == 0 || len(pendingTextLines) == 0 { - return 0 + return 0, false } if idx := findSubslice(spanLines, 0, pendingTextLines); idx != -1 { start := idx + len(pendingTextLines) if start > len(spanLines) { - return len(spanLines) + return len(spanLines), true } - return start + return start, true } if idx := findApproxSubslice(spanLines, 0, pendingTextLines); idx != -1 { @@ -1533,10 +1547,10 @@ func detectManualStart(spanLines [][]byte, pendingTextLines [][]byte) int { if start > len(spanLines) { start = len(spanLines) } - return start + return start, false } - return 0 + return 0, false } func locateConflictMarkerSpan(lines [][]byte) (int, int, bool) { @@ -1843,6 +1857,76 @@ func labelsFromConflictSpan(lines [][]byte) conflictLabels { return labels } +func textLinesBlankOnly(lines [][]byte) bool { + if len(lines) == 0 { + return false + } + + for _, line := range lines { + if len(bytes.TrimSpace(line)) != 0 { + return false + } + } + + return true +} + +func findNextConflictBoundary(mergedLines [][]byte, start int, segments []markers.Segment, segmentStart int) int { + best := findConflictMarkerLineIndex(mergedLines, start) + + nextConflict, ok := nextConflictSegment(segments, segmentStart) + if !ok { + return best + } + + for _, candidate := range conflictMatchCandidates(nextConflict) { + if len(candidate) == 0 { + continue + } + idx := findSubslice(mergedLines, start, candidate) + if idx == -1 { + continue + } + if best == -1 || idx < best { + best = idx + } + } + + return best +} + +func nextConflictSegment(segments []markers.Segment, start int) (markers.ConflictSegment, bool) { + for i := start; i < len(segments); i++ { + if seg, ok := segments[i].(markers.ConflictSegment); ok { + return seg, true + } + } + + return markers.ConflictSegment{}, false +} + +func conflictMatchCandidates(seg markers.ConflictSegment) [][][]byte { + ours := markers.SplitLinesKeepEOL(seg.Ours) + theirs := markers.SplitLinesKeepEOL(seg.Theirs) + both := append(append([][]byte{}, ours...), theirs...) + + return [][][]byte{ours, theirs, both} +} + +func findConflictMarkerLineIndex(lines [][]byte, start int) int { + if start < 0 { + start = 0 + } + + for i := start; i < len(lines); i++ { + if bytes.HasPrefix(lines[i], []byte("<<<<<<<")) { + return i + } + } + + return -1 +} + func nextTextSegmentLines(segments []markers.Segment, start int) [][]byte { for i := start; i < len(segments); i++ { if text, ok := segments[i].(markers.TextSegment); ok { From 10a8eca11e6a62b80724f81cdb7004c6623233e2 Mon Sep 17 00:00:00 2001 From: neo Date: Mon, 9 Mar 2026 10:53:28 +0900 Subject: [PATCH 3/3] Fix conflict reopen alignment so preserved text is not misclassified as conflict output --- internal/tui/merge_apply_test.go | 99 ++++++++++++++++++++++++++++++++ internal/tui/tui.go | 35 +++++++++-- 2 files changed, 130 insertions(+), 4 deletions(-) diff --git a/internal/tui/merge_apply_test.go b/internal/tui/merge_apply_test.go index 8ec6a2f..2bfbf19 100644 --- a/internal/tui/merge_apply_test.go +++ b/internal/tui/merge_apply_test.go @@ -194,6 +194,105 @@ func TestApplyMergedResolutionsHandlesEditedSingleLineSeparator(t *testing.T) { } } +func TestApplyMergedResolutionsKeepsDuplicatePrefixOutsideConflict(t *testing.T) { + data := []byte("keep\n<<<<<<< HEAD\nkeep\n=======\ndrop\n>>>>>>> branch\ntail\n") + doc, err := markers.Parse(data) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte("keep\ntail\n") + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("expected no manual resolutions") + } + + seg := conflictSegment(t, updated, 0) + if seg.Resolution != markers.ResolutionNone { + t.Fatalf("resolution = %q, want %q", seg.Resolution, markers.ResolutionNone) + } + + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) + } +} + +func TestApplyMergedResolutionsKeepsFuzzyPrefixOutsideConflict(t *testing.T) { + data := []byte("keep root\n<<<<<<< HEAD\nkeep root!\n=======\ndrop\n>>>>>>> branch\ntail\n") + doc, err := markers.Parse(data) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte("keep root!\ntail\n") + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("expected no manual resolutions") + } + + seg := conflictSegment(t, updated, 0) + if seg.Resolution != markers.ResolutionNone { + t.Fatalf("resolution = %q, want %q", seg.Resolution, markers.ResolutionNone) + } + + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) + } +} + +func TestApplyMergedResolutionsKeepsDuplicateSuffixOutsideConflict(t *testing.T) { + data := []byte("gone\nkeep\n<<<<<<< HEAD\nkeep\n=======\ndrop\n>>>>>>> branch\ntail\n") + doc, err := markers.Parse(data) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + + merged := []byte("keep\ntail\n") + updated, manual, labels, known, err := applyMergedResolutions(doc, merged) + if err != nil { + t.Fatalf("applyMergedResolutions error: %v", err) + } + if len(manual) != 0 { + t.Fatalf("expected no manual resolutions") + } + + seg := conflictSegment(t, updated, 0) + if seg.Resolution != markers.ResolutionNone { + t.Fatalf("resolution = %q, want %q", seg.Resolution, markers.ResolutionNone) + } + + rendered, unresolved, err := renderMergedOutput(updated, manual, labels, known) + if err != nil { + t.Fatalf("renderMergedOutput error: %v", err) + } + if unresolved { + t.Fatalf("expected resolved output") + } + if string(rendered) != string(merged) { + t.Fatalf("rendered = %q, want %q", string(rendered), string(merged)) + } +} + func witrLicenseDiff3Fixture() []byte { return []byte(" Apache License\n" + " Version 2.0, January 2004\n" + diff --git a/internal/tui/tui.go b/internal/tui/tui.go index aaf1d2b..be2d14e 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -1445,17 +1445,23 @@ func applyMergedResolutions(doc markers.Document, mergedBytes []byte) (markers.D if nextIdx == -1 { nextIdx = len(mergedLines) } - if nextIdx < pos { + + conflictPos := pos + if textAlignedBeforeConflict(mergedLines, pos, searchPos, pendingTextLines) { + conflictPos = searchPos + } + + if nextIdx < conflictPos { return doc, manualResolved, alignedLabels, alignedLabelKnown, fmt.Errorf("failed to align conflict segment") } - spanLines := mergedLines[pos:nextIdx] + spanLines := mergedLines[conflictPos:nextIdx] start, end, resolution, manualBytes, labels, labelsKnown := classifyConflictSpan(spanLines, pendingTextLines, s) if start < 0 || end < start || end > len(spanLines) { return doc, manualResolved, alignedLabels, alignedLabelKnown, fmt.Errorf("internal: invalid conflict span classification") } - if err := setPendingText(pos + start); err != nil { + if err := setPendingText(conflictPos + start); err != nil { return doc, manualResolved, alignedLabels, alignedLabelKnown, err } @@ -1471,7 +1477,7 @@ func applyMergedResolutions(doc markers.Document, mergedBytes []byte) (markers.D doc.Segments[i] = s } - pos += end + pos = conflictPos + end } } @@ -1701,6 +1707,27 @@ func findApproxLineIndex(lines [][]byte, start int, needle []byte) int { return -1 } +func textAlignedBeforeConflict(mergedLines [][]byte, pos int, searchPos int, pendingTextLines [][]byte) bool { + if pos < 0 || searchPos <= pos || searchPos > len(mergedLines) { + return false + } + + alignedLines := mergedLines[pos:searchPos] + if len(alignedLines) == 0 || len(alignedLines) > len(pendingTextLines) { + return false + } + + if idx := findSubslice(pendingTextLines, 0, alignedLines); idx != -1 { + return true + } + + if idx := findApproxSubslice(pendingTextLines, 0, alignedLines); idx != -1 { + return true + } + + return false +} + func alignTextSegmentEnd(mergedLines [][]byte, start int, textLines [][]byte) int { if start < 0 { start = 0