From e78b8bb72f2b38817d24683b026330e3733d4f68 Mon Sep 17 00:00:00 2001 From: neo Date: Tue, 3 Mar 2026 18:12:38 +0900 Subject: [PATCH 1/8] 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/8] 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/8] 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 From ea9e27a69a845ff90bbd6df4de4d81bdbe68e2d8 Mon Sep 17 00:00:00 2001 From: neo Date: Mon, 9 Mar 2026 12:28:45 +0900 Subject: [PATCH 4/8] Refactor TUI startup and reload to use a shared canonical-diff3 loader --- internal/tui/merge_apply_test.go | 33 +++++ internal/tui/session.go | 146 +++++++++++++++++++++ internal/tui/tui.go | 97 +++++++------- internal/tui/tui_test.go | 209 +++++++++++++++++++++++++++++++ 4 files changed, 439 insertions(+), 46 deletions(-) create mode 100644 internal/tui/session.go diff --git a/internal/tui/merge_apply_test.go b/internal/tui/merge_apply_test.go index 2bfbf19..d00d97e 100644 --- a/internal/tui/merge_apply_test.go +++ b/internal/tui/merge_apply_test.go @@ -293,6 +293,39 @@ func TestApplyMergedResolutionsKeepsDuplicateSuffixOutsideConflict(t *testing.T) } } +func TestApplyMergedResolutionsKeepsEditedAndSkippedTextOutsideConflict(t *testing.T) { + data := []byte("intro\ngone\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("intro!\nkeep\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/session.go b/internal/tui/session.go new file mode 100644 index 0000000..924677f --- /dev/null +++ b/internal/tui/session.go @@ -0,0 +1,146 @@ +package tui + +import ( + "context" + "fmt" + "os" + + "github.com/chojs23/ec/internal/cli" + "github.com/chojs23/ec/internal/gitmerge" + "github.com/chojs23/ec/internal/markers" +) + +type resolverDocumentState struct { + doc markers.Document + manualResolved map[int][]byte + mergedLabels []conflictLabels + mergedLabelKnown []bool +} + +func loadResolverDocumentState(ctx context.Context, opts cli.Options) (resolverDocumentState, error) { + canonicalDoc, err := loadCanonicalDiff3Document(ctx, opts) + if err != nil { + return resolverDocumentState{}, err + } + + state := resolverDocumentState{ + doc: canonicalDoc, + manualResolved: map[int][]byte{}, + mergedLabels: make([]conflictLabels, len(canonicalDoc.Conflicts)), + mergedLabelKnown: make([]bool, len(canonicalDoc.Conflicts)), + } + + mergedBytes, err := os.ReadFile(opts.MergedPath) + if err != nil { + return state, nil + } + + if mergedDoc, ok := tryBuildMarkerDrivenDocument(canonicalDoc, mergedBytes); ok { + state.doc = mergedDoc + return state, nil + } + + updated, manual, labels, known, err := applyMergedResolutions(canonicalDoc, mergedBytes) + if err != nil { + return resolverDocumentState{}, fmt.Errorf("apply merged resolutions: %w", err) + } + + state.doc = updated + state.manualResolved = manual + state.mergedLabels = labels + state.mergedLabelKnown = known + return state, nil +} + +func loadCanonicalDiff3Document(ctx context.Context, opts cli.Options) (markers.Document, error) { + diff3Bytes, err := gitmerge.MergeFileDiff3(ctx, opts.LocalPath, opts.BasePath, opts.RemotePath) + if err != nil { + return markers.Document{}, fmt.Errorf("generate diff3 view: %w", err) + } + + doc, err := markers.Parse(diff3Bytes) + if err != nil { + return markers.Document{}, fmt.Errorf("parse diff3 view: %w", err) + } + + return doc, nil +} + +func tryBuildMarkerDrivenDocument(canonicalDoc markers.Document, mergedBytes []byte) (markers.Document, bool) { + mergedDoc, err := markers.Parse(mergedBytes) + if err != nil { + return markers.Document{}, false + } + if len(mergedDoc.Conflicts) == 0 { + return markers.Document{}, false + } + if len(mergedDoc.Conflicts) != len(canonicalDoc.Conflicts) { + return markers.Document{}, false + } + + enriched, err := enrichMergedDocumentWithBase(canonicalDoc, mergedDoc) + if err != nil { + return markers.Document{}, false + } + + return enriched, true +} + +func enrichMergedDocumentWithBase(canonicalDoc markers.Document, mergedDoc markers.Document) (markers.Document, error) { + if len(mergedDoc.Conflicts) != len(canonicalDoc.Conflicts) { + return markers.Document{}, fmt.Errorf("conflict count mismatch: merged=%d canonical=%d", len(mergedDoc.Conflicts), len(canonicalDoc.Conflicts)) + } + + out := markers.Document{ + Segments: make([]markers.Segment, 0, len(mergedDoc.Segments)), + Conflicts: make([]markers.ConflictRef, 0, len(mergedDoc.Conflicts)), + } + + conflictIndex := 0 + for _, seg := range mergedDoc.Segments { + switch s := seg.(type) { + case markers.TextSegment: + out.Segments = append(out.Segments, markers.TextSegment{Bytes: append([]byte(nil), s.Bytes...)}) + + case markers.ConflictSegment: + if conflictIndex >= len(canonicalDoc.Conflicts) { + return markers.Document{}, fmt.Errorf("merged conflict index %d out of bounds", conflictIndex) + } + + ref := canonicalDoc.Conflicts[conflictIndex] + canonicalSeg, ok := canonicalDoc.Segments[ref.SegmentIndex].(markers.ConflictSegment) + if !ok { + return markers.Document{}, fmt.Errorf("canonical conflict %d is not a conflict segment", conflictIndex) + } + + enriched := canonicalSeg + enriched.Ours = append([]byte(nil), s.Ours...) + enriched.Theirs = append([]byte(nil), s.Theirs...) + if len(s.Base) > 0 || s.BaseLabel != "" { + enriched.Base = append([]byte(nil), s.Base...) + enriched.BaseLabel = s.BaseLabel + } + if s.OursLabel != "" { + enriched.OursLabel = s.OursLabel + } + if s.TheirsLabel != "" { + enriched.TheirsLabel = s.TheirsLabel + } + enriched.Resolution = markers.ResolutionUnset + + segIndex := len(out.Segments) + out.Segments = append(out.Segments, enriched) + out.Conflicts = append(out.Conflicts, markers.ConflictRef{SegmentIndex: segIndex}) + conflictIndex++ + + default: + return markers.Document{}, fmt.Errorf("unknown merged segment type %T", seg) + } + } + + if conflictIndex != len(canonicalDoc.Conflicts) { + return markers.Document{}, fmt.Errorf("merged conflict count mismatch after enrichment: got %d want %d", conflictIndex, len(canonicalDoc.Conflicts)) + } + + return out, nil +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index be2d14e..6b6aba1 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -17,7 +17,6 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/chojs23/ec/internal/cli" "github.com/chojs23/ec/internal/engine" - "github.com/chojs23/ec/internal/gitmerge" "github.com/chojs23/ec/internal/gitutil" "github.com/chojs23/ec/internal/markers" ) @@ -215,30 +214,12 @@ func Run(ctx context.Context, opts cli.Options) error { if err := ensureThemeLoaded(); err != nil { return err } - // Generate diff3 view - diff3Bytes, err := gitmerge.MergeFileDiff3(ctx, opts.LocalPath, opts.BasePath, opts.RemotePath) + resolverState, err := loadResolverDocumentState(ctx, opts) if err != nil { - return fmt.Errorf("failed to generate diff3 view: %w", err) - } - - // Parse conflicts - doc, err := markers.Parse(diff3Bytes) - if err != nil { - return fmt.Errorf("failed to parse conflicts: %w", err) + return err } - manualResolved := map[int][]byte{} - var mergedLabels []conflictLabels - var mergedLabelKnown []bool - if mergedBytes, err := os.ReadFile(opts.MergedPath); err == nil { - updated, manual, labels, known, updateErr := applyMergedResolutions(doc, mergedBytes) - if updateErr == nil { - doc = updated - manualResolved = manual - mergedLabels = labels - mergedLabelKnown = known - } - } + doc := resolverState.doc // Validate base completeness unless explicitly allowed to proceed without it. if !opts.AllowMissingBase { @@ -271,9 +252,9 @@ func Run(ctx context.Context, opts cli.Options) error { useFullDiff: useFullDiff, currentConflict: 0, selectedSide: selectedOurs, - mergedLabels: mergedLabels, - mergedLabelKnown: mergedLabelKnown, - manualResolved: manualResolved, + mergedLabels: resolverState.mergedLabels, + mergedLabelKnown: resolverState.mergedLabelKnown, + manualResolved: resolverState.manualResolved, pendingScroll: true, } @@ -373,20 +354,12 @@ func (m *model) openEditor() tea.Cmd { } func (m *model) reloadFromFile() error { - editedBytes, err := os.ReadFile(m.opts.MergedPath) - if err != nil { - return fmt.Errorf("read edited file: %w", err) - } - - diff3Bytes, err := gitmerge.MergeFileDiff3(m.ctx, m.opts.LocalPath, m.opts.BasePath, m.opts.RemotePath) + resolverState, err := loadResolverDocumentState(m.ctx, m.opts) if err != nil { - return fmt.Errorf("regenerate diff3 view: %w", err) + return err } - doc, err := markers.Parse(diff3Bytes) - if err != nil { - return fmt.Errorf("parse diff3 view: %w", err) - } + doc := resolverState.doc if !m.opts.AllowMissingBase { if err := engine.ValidateBaseCompleteness(doc); err != nil { @@ -398,17 +371,12 @@ func (m *model) reloadFromFile() error { } } - updated, manual, labels, known, err := applyMergedResolutions(doc, editedBytes) - if err != nil { - return fmt.Errorf("apply merged resolutions: %w", err) - } - return m.applyResolverMutation(func() error { - m.state.ReplaceDocument(updated) + m.state.ReplaceDocument(doc) m.doc = m.state.Document() - m.manualResolved = manual - m.mergedLabels = labels - m.mergedLabelKnown = known + m.manualResolved = resolverState.manualResolved + m.mergedLabels = resolverState.mergedLabels + m.mergedLabelKnown = resolverState.mergedLabelKnown if m.currentConflict >= len(m.doc.Conflicts) { m.currentConflict = len(m.doc.Conflicts) - 1 @@ -1713,7 +1681,7 @@ func textAlignedBeforeConflict(mergedLines [][]byte, pos int, searchPos int, pen } alignedLines := mergedLines[pos:searchPos] - if len(alignedLines) == 0 || len(alignedLines) > len(pendingTextLines) { + if len(alignedLines) == 0 { return false } @@ -1725,9 +1693,46 @@ func textAlignedBeforeConflict(mergedLines [][]byte, pos int, searchPos int, pen return true } + if canAlignPreservedText(alignedLines, pendingTextLines) { + return true + } + return false } +func canAlignPreservedText(alignedLines [][]byte, pendingTextLines [][]byte) bool { + alignedIndex := 0 + pendingIndex := 0 + + for alignedIndex < len(alignedLines) && pendingIndex < len(pendingTextLines) { + if linesEquivalentForAlignment(alignedLines[alignedIndex], pendingTextLines[pendingIndex]) { + alignedIndex++ + pendingIndex++ + continue + } + + if alignedIndex+1 < len(alignedLines) && linesEquivalentForAlignment(alignedLines[alignedIndex+1], pendingTextLines[pendingIndex]) { + alignedIndex++ + continue + } + + if pendingIndex+1 < len(pendingTextLines) && linesEquivalentForAlignment(alignedLines[alignedIndex], pendingTextLines[pendingIndex+1]) { + pendingIndex++ + continue + } + + if lineSimilarityPercent(alignedLines[alignedIndex], pendingTextLines[pendingIndex]) >= 70 { + alignedIndex++ + pendingIndex++ + continue + } + + return false + } + + return alignedIndex == len(alignedLines) +} + func alignTextSegmentEnd(mergedLines [][]byte, start int, textLines [][]byte) int { if start < 0 { start = 0 diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index f37bd63..978a677 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -268,6 +268,215 @@ func TestReloadFromFilePreservesManualResolution(t *testing.T) { } } +func TestLoadResolverDocumentStatePrefersValidMergedMarkers(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration-style test in short mode") + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found in PATH") + } + + ctx := context.Background() + tmpDir := t.TempDir() + + basePath := filepath.Join(tmpDir, "base.txt") + localPath := filepath.Join(tmpDir, "local.txt") + remotePath := filepath.Join(tmpDir, "remote.txt") + mergedPath := filepath.Join(tmpDir, "merged.txt") + + baseContent := "intro\nbase line\noutro\n" + localContent := "intro\nlocal line\noutro\n" + remoteContent := "intro\nremote line\noutro\n" + mergedContent := "intro edited\n<<<<<<< ours-label\nlocal from merged\n=======\nremote from merged\n>>>>>>> theirs-label\noutro edited\n" + + if err := os.WriteFile(basePath, []byte(baseContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(localPath, []byte(localContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(remotePath, []byte(remoteContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mergedPath, []byte(mergedContent), 0o644); err != nil { + t.Fatal(err) + } + + state, err := loadResolverDocumentState(ctx, cli.Options{ + BasePath: basePath, + LocalPath: localPath, + RemotePath: remotePath, + MergedPath: mergedPath, + }) + if err != nil { + t.Fatalf("loadResolverDocumentState error = %v", err) + } + if len(state.manualResolved) != 0 { + t.Fatalf("manualResolved = %d, want 0", len(state.manualResolved)) + } + if len(state.doc.Conflicts) != 1 { + t.Fatalf("conflicts = %d, want 1", len(state.doc.Conflicts)) + } + + intro, ok := state.doc.Segments[0].(markers.TextSegment) + if !ok { + t.Fatalf("segment 0 = %T, want TextSegment", state.doc.Segments[0]) + } + if string(intro.Bytes) != "intro edited\n" { + t.Fatalf("intro text = %q", string(intro.Bytes)) + } + + seg := conflictSegment(t, state.doc, 0) + if string(seg.Ours) != "local from merged\n" { + t.Fatalf("seg.Ours = %q", string(seg.Ours)) + } + if string(seg.Base) != "base line\n" { + t.Fatalf("seg.Base = %q", string(seg.Base)) + } + if string(seg.Theirs) != "remote from merged\n" { + t.Fatalf("seg.Theirs = %q", string(seg.Theirs)) + } + if seg.OursLabel != "ours-label" || seg.TheirsLabel != "theirs-label" { + t.Fatalf("labels = %q/%q", seg.OursLabel, seg.TheirsLabel) + } + + outro, ok := state.doc.Segments[2].(markers.TextSegment) + if !ok { + t.Fatalf("segment 2 = %T, want TextSegment", state.doc.Segments[2]) + } + if string(outro.Bytes) != "outro edited\n" { + t.Fatalf("outro text = %q", string(outro.Bytes)) + } +} + +func TestLoadResolverDocumentStateFallsBackForMixedResolvedMergedFile(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration-style test in short mode") + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found in PATH") + } + + ctx := context.Background() + tmpDir := t.TempDir() + + basePath := filepath.Join(tmpDir, "base.txt") + localPath := filepath.Join(tmpDir, "local.txt") + remotePath := filepath.Join(tmpDir, "remote.txt") + mergedPath := filepath.Join(tmpDir, "merged.txt") + + baseContent := "top\nbase1\nmiddle\nbase2\nbottom\n" + localContent := "top\nlocal1\nmiddle\nlocal2\nbottom\n" + remoteContent := "top\nremote1\nmiddle\nremote2\nbottom\n" + mergedContent := "top\nlocal1\nmiddle edited\n<<<<<<< ours\nlocal2\n=======\nremote2\n>>>>>>> theirs\nbottom edited\n" + + if err := os.WriteFile(basePath, []byte(baseContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(localPath, []byte(localContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(remotePath, []byte(remoteContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mergedPath, []byte(mergedContent), 0o644); err != nil { + t.Fatal(err) + } + + state, err := loadResolverDocumentState(ctx, cli.Options{ + BasePath: basePath, + LocalPath: localPath, + RemotePath: remotePath, + MergedPath: mergedPath, + }) + if err != nil { + t.Fatalf("loadResolverDocumentState error = %v", err) + } + if len(state.doc.Conflicts) != 2 { + t.Fatalf("conflicts = %d, want 2", len(state.doc.Conflicts)) + } + +} + +func TestReloadFromFilePrefersValidMergedMarkers(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration-style test in short mode") + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found in PATH") + } + + ctx := context.Background() + tmpDir := t.TempDir() + + basePath := filepath.Join(tmpDir, "base.txt") + localPath := filepath.Join(tmpDir, "local.txt") + remotePath := filepath.Join(tmpDir, "remote.txt") + mergedPath := filepath.Join(tmpDir, "merged.txt") + + baseContent := "intro\nbase line\noutro\n" + localContent := "intro\nlocal line\noutro\n" + remoteContent := "intro\nremote line\noutro\n" + mergedContent := "intro edited\n<<<<<<< ours-label\nlocal from merged\n=======\nremote from merged\n>>>>>>> theirs-label\noutro edited\n" + + if err := os.WriteFile(basePath, []byte(baseContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(localPath, []byte(localContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(remotePath, []byte(remoteContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mergedPath, []byte(mergedContent), 0o644); err != nil { + t.Fatal(err) + } + + canonicalDoc, err := loadCanonicalDiff3Document(ctx, cli.Options{ + BasePath: basePath, + LocalPath: localPath, + RemotePath: remotePath, + MergedPath: mergedPath, + }) + if err != nil { + t.Fatalf("loadCanonicalDiff3Document error = %v", err) + } + resolverState, err := engine.NewState(canonicalDoc) + if err != nil { + t.Fatalf("NewState error = %v", err) + } + + m := model{ + ctx: ctx, + opts: cli.Options{BasePath: basePath, LocalPath: localPath, RemotePath: remotePath, MergedPath: mergedPath}, + state: resolverState, + doc: canonicalDoc, + } + + if err := m.reloadFromFile(); err != nil { + t.Fatalf("reloadFromFile error = %v", err) + } + + intro, ok := m.doc.Segments[0].(markers.TextSegment) + if !ok { + t.Fatalf("segment 0 = %T, want TextSegment", m.doc.Segments[0]) + } + if string(intro.Bytes) != "intro edited\n" { + t.Fatalf("intro text = %q", string(intro.Bytes)) + } + + seg := conflictSegment(t, m.doc, 0) + if string(seg.Ours) != "local from merged\n" { + t.Fatalf("seg.Ours = %q", string(seg.Ours)) + } + if seg.OursLabel != "ours-label" || seg.TheirsLabel != "theirs-label" { + t.Fatalf("labels = %q/%q", seg.OursLabel, seg.TheirsLabel) + } + if len(m.manualResolved) != 0 { + t.Fatalf("manualResolved = %d, want 0", len(m.manualResolved)) + } +} + func TestReloadFromFileKeepsExistingUndoHistory(t *testing.T) { if testing.Short() { t.Skip("skipping integration-style test in short mode") From 4f1135fc4af0dce165a5f1917067531a78c60594 Mon Sep 17 00:00:00 2001 From: neo Date: Mon, 9 Mar 2026 13:43:35 +0900 Subject: [PATCH 5/8] Move ec startup, reload, and apply-all onto an explicit canonical 3-way model --- internal/engine/engine.go | 8 +-- internal/engine/engine_test.go | 55 ++++++++++++++++++ internal/mergeview/loader.go | 27 +++++++++ internal/tui/session.go | 102 +-------------------------------- internal/tui/tui_test.go | 49 ++++++++++++---- 5 files changed, 123 insertions(+), 118 deletions(-) create mode 100644 internal/mergeview/loader.go diff --git a/internal/engine/engine.go b/internal/engine/engine.go index c6991b8..1267740 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -9,8 +9,8 @@ import ( "path/filepath" "github.com/chojs23/ec/internal/cli" - "github.com/chojs23/ec/internal/gitmerge" "github.com/chojs23/ec/internal/markers" + "github.com/chojs23/ec/internal/mergeview" ) func CheckResolvedFile(mergedPath string) (bool, error) { @@ -46,11 +46,7 @@ func ApplyAllAndWrite(ctx context.Context, opts cli.Options) error { return nil } - mergeViewBytes, err := gitmerge.MergeFileDiff3(ctx, opts.LocalPath, opts.BasePath, opts.RemotePath) - if err != nil { - return err - } - viewDoc, err := markers.Parse(mergeViewBytes) + viewDoc, err := mergeview.LoadCanonicalDocument(ctx, opts) if err != nil { return err } diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go index ad87280..8a0e6bb 100644 --- a/internal/engine/engine_test.go +++ b/internal/engine/engine_test.go @@ -115,6 +115,61 @@ func TestApplyAllAndWrite_NoConflictsNoWrite(t *testing.T) { } } +func TestApplyAllAndWriteUsesCanonicalThreeWayInputsOverMergedMarkers(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration-style test in short mode") + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found in PATH") + } + + ctx := context.Background() + tmpDir := t.TempDir() + + basePath := filepath.Join(tmpDir, "base.txt") + localPath := filepath.Join(tmpDir, "local.txt") + remotePath := filepath.Join(tmpDir, "remote.txt") + mergedPath := filepath.Join(tmpDir, "merged.txt") + + baseContent := "line1\nbase content\nline3\n" + localContent := "line1\nlocal change\nline3\n" + remoteContent := "line1\nremote change\nline3\n" + mergedContent := "line1\n<<<<<<< ours-label\nlocal from merged marker\n=======\nremote from merged marker\n>>>>>>> theirs-label\nline3\n" + + if err := os.WriteFile(basePath, []byte(baseContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(localPath, []byte(localContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(remotePath, []byte(remoteContent), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(mergedPath, []byte(mergedContent), 0o644); err != nil { + t.Fatal(err) + } + + opts := cli.Options{ + BasePath: basePath, + LocalPath: localPath, + RemotePath: remotePath, + MergedPath: mergedPath, + ApplyAll: "ours", + } + + if err := ApplyAllAndWrite(ctx, opts); err != nil { + t.Fatalf("ApplyAllAndWrite failed: %v", err) + } + + resolved, err := os.ReadFile(mergedPath) + if err != nil { + t.Fatal(err) + } + if string(resolved) != localContent { + t.Fatalf("resolved output mismatch: got %q want %q", string(resolved), localContent) + } +} + func TestCheckResolvedFile(t *testing.T) { tmpDir := t.TempDir() diff --git a/internal/mergeview/loader.go b/internal/mergeview/loader.go new file mode 100644 index 0000000..a7f85a1 --- /dev/null +++ b/internal/mergeview/loader.go @@ -0,0 +1,27 @@ +package mergeview + +import ( + "context" + "fmt" + + "github.com/chojs23/ec/internal/cli" + "github.com/chojs23/ec/internal/gitmerge" + "github.com/chojs23/ec/internal/markers" +) + +// LoadCanonicalDocument builds the canonical conflict document from the explicit +// base/local/remote inputs. This keeps conflict structure anchored to the stage +// files instead of the merged working copy. +func LoadCanonicalDocument(ctx context.Context, opts cli.Options) (markers.Document, error) { + diff3Bytes, err := gitmerge.MergeFileDiff3(ctx, opts.LocalPath, opts.BasePath, opts.RemotePath) + if err != nil { + return markers.Document{}, fmt.Errorf("generate diff3 view: %w", err) + } + + doc, err := markers.Parse(diff3Bytes) + if err != nil { + return markers.Document{}, fmt.Errorf("parse diff3 view: %w", err) + } + + return doc, nil +} diff --git a/internal/tui/session.go b/internal/tui/session.go index 924677f..35b37c2 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -6,8 +6,8 @@ import ( "os" "github.com/chojs23/ec/internal/cli" - "github.com/chojs23/ec/internal/gitmerge" "github.com/chojs23/ec/internal/markers" + "github.com/chojs23/ec/internal/mergeview" ) type resolverDocumentState struct { @@ -18,7 +18,7 @@ type resolverDocumentState struct { } func loadResolverDocumentState(ctx context.Context, opts cli.Options) (resolverDocumentState, error) { - canonicalDoc, err := loadCanonicalDiff3Document(ctx, opts) + canonicalDoc, err := mergeview.LoadCanonicalDocument(ctx, opts) if err != nil { return resolverDocumentState{}, err } @@ -35,11 +35,6 @@ func loadResolverDocumentState(ctx context.Context, opts cli.Options) (resolverD return state, nil } - if mergedDoc, ok := tryBuildMarkerDrivenDocument(canonicalDoc, mergedBytes); ok { - state.doc = mergedDoc - return state, nil - } - updated, manual, labels, known, err := applyMergedResolutions(canonicalDoc, mergedBytes) if err != nil { return resolverDocumentState{}, fmt.Errorf("apply merged resolutions: %w", err) @@ -51,96 +46,3 @@ func loadResolverDocumentState(ctx context.Context, opts cli.Options) (resolverD state.mergedLabelKnown = known return state, nil } - -func loadCanonicalDiff3Document(ctx context.Context, opts cli.Options) (markers.Document, error) { - diff3Bytes, err := gitmerge.MergeFileDiff3(ctx, opts.LocalPath, opts.BasePath, opts.RemotePath) - if err != nil { - return markers.Document{}, fmt.Errorf("generate diff3 view: %w", err) - } - - doc, err := markers.Parse(diff3Bytes) - if err != nil { - return markers.Document{}, fmt.Errorf("parse diff3 view: %w", err) - } - - return doc, nil -} - -func tryBuildMarkerDrivenDocument(canonicalDoc markers.Document, mergedBytes []byte) (markers.Document, bool) { - mergedDoc, err := markers.Parse(mergedBytes) - if err != nil { - return markers.Document{}, false - } - if len(mergedDoc.Conflicts) == 0 { - return markers.Document{}, false - } - if len(mergedDoc.Conflicts) != len(canonicalDoc.Conflicts) { - return markers.Document{}, false - } - - enriched, err := enrichMergedDocumentWithBase(canonicalDoc, mergedDoc) - if err != nil { - return markers.Document{}, false - } - - return enriched, true -} - -func enrichMergedDocumentWithBase(canonicalDoc markers.Document, mergedDoc markers.Document) (markers.Document, error) { - if len(mergedDoc.Conflicts) != len(canonicalDoc.Conflicts) { - return markers.Document{}, fmt.Errorf("conflict count mismatch: merged=%d canonical=%d", len(mergedDoc.Conflicts), len(canonicalDoc.Conflicts)) - } - - out := markers.Document{ - Segments: make([]markers.Segment, 0, len(mergedDoc.Segments)), - Conflicts: make([]markers.ConflictRef, 0, len(mergedDoc.Conflicts)), - } - - conflictIndex := 0 - for _, seg := range mergedDoc.Segments { - switch s := seg.(type) { - case markers.TextSegment: - out.Segments = append(out.Segments, markers.TextSegment{Bytes: append([]byte(nil), s.Bytes...)}) - - case markers.ConflictSegment: - if conflictIndex >= len(canonicalDoc.Conflicts) { - return markers.Document{}, fmt.Errorf("merged conflict index %d out of bounds", conflictIndex) - } - - ref := canonicalDoc.Conflicts[conflictIndex] - canonicalSeg, ok := canonicalDoc.Segments[ref.SegmentIndex].(markers.ConflictSegment) - if !ok { - return markers.Document{}, fmt.Errorf("canonical conflict %d is not a conflict segment", conflictIndex) - } - - enriched := canonicalSeg - enriched.Ours = append([]byte(nil), s.Ours...) - enriched.Theirs = append([]byte(nil), s.Theirs...) - if len(s.Base) > 0 || s.BaseLabel != "" { - enriched.Base = append([]byte(nil), s.Base...) - enriched.BaseLabel = s.BaseLabel - } - if s.OursLabel != "" { - enriched.OursLabel = s.OursLabel - } - if s.TheirsLabel != "" { - enriched.TheirsLabel = s.TheirsLabel - } - enriched.Resolution = markers.ResolutionUnset - - segIndex := len(out.Segments) - out.Segments = append(out.Segments, enriched) - out.Conflicts = append(out.Conflicts, markers.ConflictRef{SegmentIndex: segIndex}) - conflictIndex++ - - default: - return markers.Document{}, fmt.Errorf("unknown merged segment type %T", seg) - } - } - - if conflictIndex != len(canonicalDoc.Conflicts) { - return markers.Document{}, fmt.Errorf("merged conflict count mismatch after enrichment: got %d want %d", conflictIndex, len(canonicalDoc.Conflicts)) - } - - return out, nil -} diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 978a677..866de54 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -18,6 +18,7 @@ import ( "github.com/chojs23/ec/internal/engine" "github.com/chojs23/ec/internal/gitmerge" "github.com/chojs23/ec/internal/markers" + "github.com/chojs23/ec/internal/mergeview" ) func TestModelQuitBackToSelector(t *testing.T) { @@ -268,7 +269,7 @@ func TestReloadFromFilePreservesManualResolution(t *testing.T) { } } -func TestLoadResolverDocumentStatePrefersValidMergedMarkers(t *testing.T) { +func TestLoadResolverDocumentStateKeepsCanonicalConflictStructureWithMergedMarkers(t *testing.T) { if testing.Short() { t.Skip("skipping integration-style test in short mode") } @@ -327,17 +328,20 @@ func TestLoadResolverDocumentStatePrefersValidMergedMarkers(t *testing.T) { } seg := conflictSegment(t, state.doc, 0) - if string(seg.Ours) != "local from merged\n" { + if string(seg.Ours) != "local line\n" { t.Fatalf("seg.Ours = %q", string(seg.Ours)) } if string(seg.Base) != "base line\n" { t.Fatalf("seg.Base = %q", string(seg.Base)) } - if string(seg.Theirs) != "remote from merged\n" { + if string(seg.Theirs) != "remote line\n" { t.Fatalf("seg.Theirs = %q", string(seg.Theirs)) } - if seg.OursLabel != "ours-label" || seg.TheirsLabel != "theirs-label" { - t.Fatalf("labels = %q/%q", seg.OursLabel, seg.TheirsLabel) + if !state.mergedLabelKnown[0] { + t.Fatalf("mergedLabelKnown[0] = false, want true") + } + if state.mergedLabels[0].OursLabel != "ours-label" || state.mergedLabels[0].TheirsLabel != "theirs-label" { + t.Fatalf("mergedLabels[0] = %+v", state.mergedLabels[0]) } outro, ok := state.doc.Segments[2].(markers.TextSegment) @@ -368,7 +372,7 @@ func TestLoadResolverDocumentStateFallsBackForMixedResolvedMergedFile(t *testing baseContent := "top\nbase1\nmiddle\nbase2\nbottom\n" localContent := "top\nlocal1\nmiddle\nlocal2\nbottom\n" remoteContent := "top\nremote1\nmiddle\nremote2\nbottom\n" - mergedContent := "top\nlocal1\nmiddle edited\n<<<<<<< ours\nlocal2\n=======\nremote2\n>>>>>>> theirs\nbottom edited\n" + mergedContent := "top\nlocal1\nmiddle\n<<<<<<< ours\nlocal2\n=======\nremote2\n>>>>>>> theirs\nbottom\n" if err := os.WriteFile(basePath, []byte(baseContent), 0o644); err != nil { t.Fatal(err) @@ -395,10 +399,25 @@ func TestLoadResolverDocumentStateFallsBackForMixedResolvedMergedFile(t *testing if len(state.doc.Conflicts) != 2 { t.Fatalf("conflicts = %d, want 2", len(state.doc.Conflicts)) } + first := conflictSegment(t, state.doc, 0) + if first.Resolution != markers.ResolutionOurs { + t.Fatalf("first resolution = %q, want %q", first.Resolution, markers.ResolutionOurs) + } + middleText, ok := state.doc.Segments[2].(markers.TextSegment) + if !ok { + t.Fatalf("segment 2 = %T, want TextSegment", state.doc.Segments[2]) + } + if string(middleText.Bytes) != "middle\n" { + t.Fatalf("middle text = %q", string(middleText.Bytes)) + } + second := conflictSegment(t, state.doc, 1) + if second.Resolution != markers.ResolutionUnset { + t.Fatalf("second resolution = %q, want unset", second.Resolution) + } } -func TestReloadFromFilePrefersValidMergedMarkers(t *testing.T) { +func TestReloadFromFileKeepsCanonicalConflictStructureWithMergedMarkers(t *testing.T) { if testing.Short() { t.Skip("skipping integration-style test in short mode") } @@ -432,14 +451,14 @@ func TestReloadFromFilePrefersValidMergedMarkers(t *testing.T) { t.Fatal(err) } - canonicalDoc, err := loadCanonicalDiff3Document(ctx, cli.Options{ + canonicalDoc, err := mergeview.LoadCanonicalDocument(ctx, cli.Options{ BasePath: basePath, LocalPath: localPath, RemotePath: remotePath, MergedPath: mergedPath, }) if err != nil { - t.Fatalf("loadCanonicalDiff3Document error = %v", err) + t.Fatalf("LoadCanonicalDocument error = %v", err) } resolverState, err := engine.NewState(canonicalDoc) if err != nil { @@ -466,11 +485,17 @@ func TestReloadFromFilePrefersValidMergedMarkers(t *testing.T) { } seg := conflictSegment(t, m.doc, 0) - if string(seg.Ours) != "local from merged\n" { + if string(seg.Ours) != "local line\n" { t.Fatalf("seg.Ours = %q", string(seg.Ours)) } - if seg.OursLabel != "ours-label" || seg.TheirsLabel != "theirs-label" { - t.Fatalf("labels = %q/%q", seg.OursLabel, seg.TheirsLabel) + if string(seg.Theirs) != "remote line\n" { + t.Fatalf("seg.Theirs = %q", string(seg.Theirs)) + } + if !m.mergedLabelKnown[0] { + t.Fatalf("mergedLabelKnown[0] = false, want true") + } + if m.mergedLabels[0].OursLabel != "ours-label" || m.mergedLabels[0].TheirsLabel != "theirs-label" { + t.Fatalf("mergedLabels[0] = %+v", m.mergedLabels[0]) } if len(m.manualResolved) != 0 { t.Fatalf("manualResolved = %d, want 0", len(m.manualResolved)) From 3eac30bf562b8db7391e163f0671826e6279eefd Mon Sep 17 00:00:00 2001 From: neo Date: Tue, 10 Mar 2026 10:50:25 +0900 Subject: [PATCH 6/8] Use a model-owned runtime merge state instead of merged-file reparse heuristics in the active TUI --- internal/engine/state.go | 574 +++++++++++++++++-- internal/engine/state_test.go | 22 + internal/tui/merge_apply_test.go | 738 ------------------------ internal/tui/render_helpers_test.go | 25 - internal/tui/session.go | 43 +- internal/tui/tui.go | 831 ++-------------------------- internal/tui/tui_test.go | 71 ++- 7 files changed, 648 insertions(+), 1656 deletions(-) delete mode 100644 internal/tui/merge_apply_test.go diff --git a/internal/engine/state.go b/internal/engine/state.go index 0ede146..83bf96c 100644 --- a/internal/engine/state.go +++ b/internal/engine/state.go @@ -1,106 +1,562 @@ package engine import ( + "bytes" "fmt" "github.com/chojs23/ec/internal/markers" ) -// State manages resolution state for a conflict document. +type ConflictLabels struct { + OursLabel string + BaseLabel string + TheirsLabel string +} + +type conflictState struct { + canonical markers.ConflictSegment + output []byte + resolution markers.Resolution + manual bool + labels ConflictLabels + labelKnown bool + resolvedOurs bool + resolvedTheirs bool + onesideApplied bool +} + +type segmentState struct { + text []byte + conflict *conflictState +} + type State struct { - doc markers.Document + canonical markers.Document + segments []segmentState + doc markers.Document } -// NewState creates a new State from a parsed document. func NewState(doc markers.Document) (*State, error) { - return &State{ - doc: doc, - }, nil + return newStateFromDocument(doc), nil +} + +func newStateFromDocument(doc markers.Document) *State { + canonical := markers.CloneDocument(doc) + segments := make([]segmentState, 0, len(canonical.Segments)) + for _, seg := range canonical.Segments { + switch s := seg.(type) { + case markers.TextSegment: + segments = append(segments, segmentState{text: append([]byte(nil), s.Bytes...)}) + case markers.ConflictSegment: + cs := newConflictState(s) + segments = append(segments, segmentState{conflict: &cs}) + } + } + state := &State{canonical: canonical, segments: segments} + state.syncDocument() + return state +} + +func newConflictState(seg markers.ConflictSegment) conflictState { + state := conflictState{ + canonical: seg, + labels: ConflictLabels{ + OursLabel: seg.OursLabel, + BaseLabel: seg.BaseLabel, + TheirsLabel: seg.TheirsLabel, + }, + } + if seg.Resolution == markers.ResolutionUnset { + state.output = renderConflictMarkers(seg, state.labels) + state.applyClassification(markers.ResolutionUnset, false, false, ConflictLabels{}, false) + return state + } + state.setResolved(seg.Resolution) + return state } -// ApplyResolution sets the resolution for a conflict at the given index. -// conflictIndex is an index into doc.Conflicts (NOT doc.Segments). -// Returns error if index is out of bounds or resolution is invalid. func (s *State) ApplyResolution(conflictIndex int, resolution markers.Resolution) error { - if conflictIndex < 0 || conflictIndex >= len(s.doc.Conflicts) { - return fmt.Errorf("conflict index %d out of bounds [0, %d)", conflictIndex, len(s.doc.Conflicts)) + if conflictIndex < 0 || conflictIndex >= len(s.canonical.Conflicts) { + return fmt.Errorf("conflict index %d out of bounds [0, %d)", conflictIndex, len(s.canonical.Conflicts)) } + if !isSupportedResolution(resolution) { + return fmt.Errorf("invalid resolution: %q", resolution) + } + segIndex := s.canonical.Conflicts[conflictIndex].SegmentIndex + conflict := s.segments[segIndex].conflict + if conflict == nil { + return fmt.Errorf("internal: conflict index %d points to non-ConflictSegment", conflictIndex) + } + conflict.setResolved(resolution) + s.syncDocument() + return nil +} - // Validate resolution - switch resolution { - case markers.ResolutionOurs, markers.ResolutionTheirs, markers.ResolutionBoth, markers.ResolutionNone: - // Valid - default: +func (s *State) ApplyAll(resolution markers.Resolution) error { + if !isSupportedResolution(resolution) { return fmt.Errorf("invalid resolution: %q", resolution) } + for _, ref := range s.canonical.Conflicts { + conflict := s.segments[ref.SegmentIndex].conflict + if conflict == nil { + return fmt.Errorf("internal: conflict points to non-ConflictSegment") + } + conflict.setResolved(resolution) + } + s.syncDocument() + return nil +} - ref := s.doc.Conflicts[conflictIndex] - seg, ok := s.doc.Segments[ref.SegmentIndex].(markers.ConflictSegment) - if !ok { - return fmt.Errorf("internal: conflict index %d points to non-ConflictSegment", conflictIndex) +func (s *State) ReplaceDocument(doc markers.Document) { + next := newStateFromDocument(doc) + s.canonical = next.canonical + s.segments = next.segments + s.doc = next.doc +} + +func (s *State) Preview() ([]byte, error) { + if s.HasUnresolvedConflicts() { + return nil, fmt.Errorf("%w: conflict without resolution", markers.ErrUnresolved) } - if seg.Resolution == resolution { + return s.RenderMerged(), nil +} + +func (s *State) Document() markers.Document { + return markers.CloneDocument(s.doc) +} + +func (s *State) syncDocument() { + doc := markers.CloneDocument(s.canonical) + for i, segment := range s.segments { + switch seg := doc.Segments[i].(type) { + case markers.TextSegment: + seg.Bytes = append([]byte(nil), segment.text...) + doc.Segments[i] = seg + case markers.ConflictSegment: + conflict := segment.conflict + seg.OursLabel = conflict.canonical.OursLabel + seg.BaseLabel = conflict.canonical.BaseLabel + seg.TheirsLabel = conflict.canonical.TheirsLabel + if conflict.labelKnown { + seg.OursLabel = conflict.labels.OursLabel + seg.BaseLabel = conflict.labels.BaseLabel + seg.TheirsLabel = conflict.labels.TheirsLabel + } + seg.Resolution = conflict.resolution + doc.Segments[i] = seg + } + } + s.doc = doc +} + +func (s *State) Clone() *State { + clone := &State{canonical: markers.CloneDocument(s.canonical), doc: markers.CloneDocument(s.doc)} + clone.segments = make([]segmentState, len(s.segments)) + for i, segment := range s.segments { + if segment.conflict == nil { + clone.segments[i] = segmentState{text: append([]byte(nil), segment.text...)} + continue + } + conflict := *segment.conflict + conflict.output = append([]byte(nil), segment.conflict.output...) + clone.segments[i] = segmentState{conflict: &conflict} + } + return clone +} + +func (s *State) RenderMerged() []byte { + var out bytes.Buffer + for _, segment := range s.segments { + if segment.conflict == nil { + out.Write(segment.text) + continue + } + out.Write(segment.conflict.output) + } + return out.Bytes() +} + +func (s *State) HasUnresolvedConflicts() bool { + for _, ref := range s.canonical.Conflicts { + conflict := s.segments[ref.SegmentIndex].conflict + if conflict != nil && conflict.resolution == markers.ResolutionUnset && !conflict.manual { + return true + } + } + return false +} + +func (s *State) ManualResolved() map[int][]byte { + manual := map[int][]byte{} + for idx, ref := range s.canonical.Conflicts { + conflict := s.segments[ref.SegmentIndex].conflict + if conflict != nil && conflict.manual { + manual[idx] = append([]byte(nil), conflict.output...) + } + } + return manual +} + +func (s *State) MergedLabels() ([]ConflictLabels, []bool) { + labels := make([]ConflictLabels, len(s.canonical.Conflicts)) + known := make([]bool, len(s.canonical.Conflicts)) + for idx, ref := range s.canonical.Conflicts { + conflict := s.segments[ref.SegmentIndex].conflict + if conflict == nil { + continue + } + labels[idx] = conflict.labels + known[idx] = conflict.labelKnown + } + return labels, known +} + +func (s *State) ImportMerged(merged []byte) error { + parsed, err := markers.Parse(merged) + if err == nil && len(parsed.Conflicts) == len(s.canonical.Conflicts) && s.canImportParsedDocument(parsed) { + s.importParsedDocument(parsed) return nil } - seg.Resolution = resolution - s.doc.Segments[ref.SegmentIndex] = seg + oldLines := markers.SplitLinesKeepEOL(s.RenderMerged()) + newLines := markers.SplitLinesKeepEOL(merged) + segmentLines, lineToSegment, boundaryOwner := s.segmentLineOwnership() + _ = segmentLines + ops := diffLines(oldLines, newLines) + assigned := make([][][]byte, len(s.segments)) + oldCursor := 0 + pendingDeletedOwner := -1 + for _, op := range ops { + switch op.kind { + case diffInsert: + target := boundaryOwner[oldCursor] + if pendingDeletedOwner != -1 { + target = pendingDeletedOwner + } + assigned[target] = append(assigned[target], op.newLines...) + pendingDeletedOwner = -1 + case diffEqual: + for _, line := range op.newLines { + if oldCursor >= len(lineToSegment) { + break + } + target := lineToSegment[oldCursor] + assigned[target] = append(assigned[target], line) + oldCursor++ + } + pendingDeletedOwner = -1 + case diffDelete: + if len(op.oldLines) > 0 && oldCursor < len(lineToSegment) { + pendingDeletedOwner = lineToSegment[oldCursor] + } + oldCursor += len(op.oldLines) + } + } + + for i, segment := range s.segments { + updated := joinLines(assigned[i]) + if segment.conflict == nil { + s.segments[i].text = updated + continue + } + conflict := s.segments[i].conflict + conflict.output = updated + conflict.classifyUpdatedOutput() + } + s.syncDocument() return nil } -// ApplyAll sets the resolution for all conflicts. -func (s *State) ApplyAll(resolution markers.Resolution) error { - // Validate resolution - switch resolution { - case markers.ResolutionOurs, markers.ResolutionTheirs, markers.ResolutionBoth, markers.ResolutionNone: - // Valid - default: - return fmt.Errorf("invalid resolution: %q", resolution) +func (s *State) canImportParsedDocument(doc markers.Document) bool { + if len(doc.Segments) != len(s.canonical.Segments) { + return false + } + for i, seg := range s.canonical.Segments { + switch seg.(type) { + case markers.TextSegment: + if _, ok := doc.Segments[i].(markers.TextSegment); !ok { + return false + } + case markers.ConflictSegment: + if _, ok := doc.Segments[i].(markers.ConflictSegment); !ok { + return false + } + } } + return true +} - hasChange := false - for _, ref := range s.doc.Conflicts { - seg, ok := s.doc.Segments[ref.SegmentIndex].(markers.ConflictSegment) - if !ok { - return fmt.Errorf("internal: conflict points to non-ConflictSegment") +func (s *State) importParsedDocument(doc markers.Document) { + for i, parsed := range doc.Segments { + switch seg := parsed.(type) { + case markers.TextSegment: + s.segments[i].text = append([]byte(nil), seg.Bytes...) + case markers.ConflictSegment: + conflict := s.segments[i].conflict + if conflict == nil { + continue + } + conflict.output = renderConflictMarkers(seg, ConflictLabels{ + OursLabel: seg.OursLabel, + BaseLabel: seg.BaseLabel, + TheirsLabel: seg.TheirsLabel, + }) + conflict.classifyUpdatedOutput() } - if seg.Resolution != resolution { - hasChange = true - break + } + s.syncDocument() +} + +func (s *State) segmentLineOwnership() ([]int, []int, []int) { + segmentLines := make([]int, len(s.segments)) + totalLines := 0 + for i, segment := range s.segments { + lines := markers.SplitLinesKeepEOL(s.segmentBytes(segment)) + segmentLines[i] = len(lines) + totalLines += len(lines) + } + + lineToSegment := make([]int, totalLines) + boundaryOwner := make([]int, totalLines+1) + for i := range boundaryOwner { + boundaryOwner[i] = -1 + } + + cursor := 0 + for i, count := range segmentLines { + boundaryOwner[cursor] = i + for j := 0; j < count; j++ { + lineToSegment[cursor+j] = i + } + cursor += count + } + if len(s.segments) > 0 && boundaryOwner[totalLines] == -1 { + boundaryOwner[totalLines] = len(s.segments) - 1 + } + for i := range boundaryOwner { + if boundaryOwner[i] != -1 { + continue } + if i > 0 { + boundaryOwner[i] = lineToSegment[i-1] + continue + } + boundaryOwner[i] = 0 + } + return segmentLines, lineToSegment, boundaryOwner +} + +func (s *State) segmentBytes(segment segmentState) []byte { + if segment.conflict == nil { + return segment.text } - if !hasChange { + return segment.conflict.output +} + +func (c *conflictState) setResolved(resolution markers.Resolution) { + c.output = renderResolution(c.canonical, resolution) + c.applyClassification(resolution, resolution == markers.ResolutionUnset, false, ConflictLabels{}, false) +} + +func (c *conflictState) classifyUpdatedOutput() { + resolution, unresolved, manual, labels, known := classifyConflictOutput(c.canonical, c.output) + c.applyClassification(resolution, unresolved, manual, labels, known) +} + +func (c *conflictState) applyClassification(resolution markers.Resolution, unresolved bool, manual bool, labels ConflictLabels, known bool) { + c.resolution = resolution + c.manual = manual + c.labelKnown = known + if known { + c.labels = labels + } else { + c.labels = ConflictLabels{ + OursLabel: c.canonical.OursLabel, + BaseLabel: c.canonical.BaseLabel, + TheirsLabel: c.canonical.TheirsLabel, + } + } + c.resolvedOurs, c.resolvedTheirs, c.onesideApplied = classifyResolvedSides(c.canonical, resolution, unresolved, manual) +} + +func classifyResolvedSides(seg markers.ConflictSegment, resolution markers.Resolution, unresolved bool, manual bool) (bool, bool, bool) { + if unresolved { + return false, false, false + } + if manual { + return true, true, false + } + switch resolution { + case markers.ResolutionOurs: + resolvedTheirs := len(seg.Theirs) == 0 + return true, resolvedTheirs, !resolvedTheirs + case markers.ResolutionTheirs: + resolvedOurs := len(seg.Ours) == 0 + return resolvedOurs, true, !resolvedOurs + case markers.ResolutionBoth, markers.ResolutionNone: + return true, true, false + default: + return false, false, false + } +} + +func renderResolution(seg markers.ConflictSegment, resolution markers.Resolution) []byte { + switch resolution { + case markers.ResolutionOurs: + return append([]byte(nil), seg.Ours...) + case markers.ResolutionTheirs: + return append([]byte(nil), seg.Theirs...) + case markers.ResolutionBoth: + return append(append([]byte(nil), seg.Ours...), seg.Theirs...) + case markers.ResolutionNone: return nil + default: + return renderConflictMarkers(seg, ConflictLabels{OursLabel: seg.OursLabel, BaseLabel: seg.BaseLabel, TheirsLabel: seg.TheirsLabel}) } +} - for _, ref := range s.doc.Conflicts { - seg, ok := s.doc.Segments[ref.SegmentIndex].(markers.ConflictSegment) - if !ok { - return fmt.Errorf("internal: conflict points to non-ConflictSegment") +func renderConflictMarkers(seg markers.ConflictSegment, labels ConflictLabels) []byte { + copySeg := seg + copySeg.Resolution = markers.ResolutionUnset + var out bytes.Buffer + markers.AppendConflictSegment(&out, copySeg, labels.OursLabel, labels.BaseLabel, labels.TheirsLabel) + return out.Bytes() +} + +func classifyConflictOutput(seg markers.ConflictSegment, output []byte) (markers.Resolution, bool, bool, ConflictLabels, bool) { + both := append(append([][]byte{}, markers.SplitLinesKeepEOL(seg.Ours)...), markers.SplitLinesKeepEOL(seg.Theirs)...) + bothBytes := joinLines(both) + switch { + case bytes.Equal(output, seg.Ours): + return markers.ResolutionOurs, false, false, ConflictLabels{}, false + case bytes.Equal(output, seg.Theirs): + return markers.ResolutionTheirs, false, false, ConflictLabels{}, false + case bytes.Equal(output, bothBytes): + return markers.ResolutionBoth, false, false, ConflictLabels{}, false + case len(output) == 0: + return markers.ResolutionNone, false, false, ConflictLabels{}, false + } + + parsed, err := markers.Parse(output) + if err == nil && len(parsed.Conflicts) == 1 && len(parsed.Segments) == 1 { + if unresolved, ok := parsed.Segments[parsed.Conflicts[0].SegmentIndex].(markers.ConflictSegment); ok { + return markers.ResolutionUnset, true, false, ConflictLabels{ + OursLabel: unresolved.OursLabel, + BaseLabel: unresolved.BaseLabel, + TheirsLabel: unresolved.TheirsLabel, + }, true } - seg.Resolution = resolution - s.doc.Segments[ref.SegmentIndex] = seg } - return nil + return markers.ResolutionUnset, false, true, ConflictLabels{}, false } -// ReplaceDocument replaces the current document. -func (s *State) ReplaceDocument(doc markers.Document) { - if markers.DocumentsEqual(s.doc, doc) { - return +func isSupportedResolution(resolution markers.Resolution) bool { + switch resolution { + case markers.ResolutionOurs, markers.ResolutionTheirs, markers.ResolutionBoth, markers.ResolutionNone: + return true + default: + return false } - s.doc = markers.CloneDocument(doc) } -// Preview generates the resolved output by concatenating segments with resolutions applied. -// Uses markers.RenderResolved to produce the final bytes. -// Returns error if any conflict is unresolved. -func (s *State) Preview() ([]byte, error) { - return markers.RenderResolved(s.doc) +func joinLines(lines [][]byte) []byte { + if len(lines) == 0 { + return nil + } + joined := make([]byte, 0) + for _, line := range lines { + joined = append(joined, line...) + } + return joined } -func (s *State) Document() markers.Document { - return markers.CloneDocument(s.doc) +type diffKind int + +const ( + diffEqual diffKind = iota + diffDelete + diffInsert +) + +type diffOp struct { + kind diffKind + oldLines [][]byte + newLines [][]byte +} + +func diffLines(oldLines [][]byte, newLines [][]byte) []diffOp { + n := len(oldLines) + m := len(newLines) + dp := make([][]int, n+1) + for i := range dp { + dp[i] = make([]int, m+1) + } + for i := n - 1; i >= 0; i-- { + for j := m - 1; j >= 0; j-- { + if bytes.Equal(oldLines[i], newLines[j]) { + dp[i][j] = dp[i+1][j+1] + 1 + continue + } + if dp[i+1][j] >= dp[i][j+1] { + dp[i][j] = dp[i+1][j] + } else { + dp[i][j] = dp[i][j+1] + } + } + } + + var ops []diffOp + appendOp := func(kind diffKind, oldLine []byte, newLine []byte) { + if len(ops) > 0 && ops[len(ops)-1].kind == kind { + switch kind { + case diffEqual: + ops[len(ops)-1].oldLines = append(ops[len(ops)-1].oldLines, oldLine) + ops[len(ops)-1].newLines = append(ops[len(ops)-1].newLines, newLine) + case diffDelete: + ops[len(ops)-1].oldLines = append(ops[len(ops)-1].oldLines, oldLine) + case diffInsert: + ops[len(ops)-1].newLines = append(ops[len(ops)-1].newLines, newLine) + } + return + } + op := diffOp{kind: kind} + switch kind { + case diffEqual: + op.oldLines = [][]byte{oldLine} + op.newLines = [][]byte{newLine} + case diffDelete: + op.oldLines = [][]byte{oldLine} + case diffInsert: + op.newLines = [][]byte{newLine} + } + ops = append(ops, op) + } + + i, j := 0, 0 + for i < n && j < m { + if bytes.Equal(oldLines[i], newLines[j]) { + appendOp(diffEqual, oldLines[i], newLines[j]) + i++ + j++ + continue + } + if dp[i+1][j] >= dp[i][j+1] { + appendOp(diffDelete, oldLines[i], nil) + i++ + continue + } + appendOp(diffInsert, nil, newLines[j]) + j++ + } + for i < n { + appendOp(diffDelete, oldLines[i], nil) + i++ + } + for j < m { + appendOp(diffInsert, nil, newLines[j]) + j++ + } + return ops } diff --git a/internal/engine/state_test.go b/internal/engine/state_test.go index 02818ef..7bb339e 100644 --- a/internal/engine/state_test.go +++ b/internal/engine/state_test.go @@ -301,6 +301,28 @@ line2 } } +func TestImportMergedManualConflict(t *testing.T) { + input := []byte("line1\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nline2\n") + doc, err := markers.Parse(input) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + state, err := NewState(doc) + if err != nil { + t.Fatalf("NewState failed: %v", err) + } + if err := state.ImportMerged([]byte("line1\nmanual\nline2\n")); err != nil { + t.Fatalf("ImportMerged failed: %v", err) + } + manual := state.ManualResolved() + if got := string(manual[0]); got != "manual\n" { + t.Fatalf("manual[0] = %q, want %q", got, "manual\\n") + } + if got := string(state.RenderMerged()); got != "line1\nmanual\nline2\n" { + t.Fatalf("RenderMerged = %q", got) + } +} + func TestPreviewDeterministic(t *testing.T) { input := []byte(`line1 <<<<<<< HEAD diff --git a/internal/tui/merge_apply_test.go b/internal/tui/merge_apply_test.go deleted file mode 100644 index d00d97e..0000000 --- a/internal/tui/merge_apply_test.go +++ /dev/null @@ -1,738 +0,0 @@ -package tui - -import ( - "bytes" - "testing" - - "github.com/chojs23/ec/internal/markers" -) - -func parseSingleConflictDoc(t *testing.T) markers.Document { - t.Helper() - data := []byte("start\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nend\n") - doc, err := markers.Parse(data) - if err != nil { - t.Fatalf("Parse error: %v", err) - } - return doc -} - -func conflictSegment(t *testing.T, doc markers.Document, index int) markers.ConflictSegment { - t.Helper() - ref := doc.Conflicts[index] - seg, ok := doc.Segments[ref.SegmentIndex].(markers.ConflictSegment) - if !ok { - t.Fatalf("expected conflict segment") - } - return seg -} - -func setConflictResolution(doc *markers.Document, index int, res markers.Resolution) { - ref := doc.Conflicts[index] - seg := doc.Segments[ref.SegmentIndex].(markers.ConflictSegment) - seg.Resolution = res - doc.Segments[ref.SegmentIndex] = seg -} - -func TestApplyMergedResolutionsMatchesSelections(t *testing.T) { - testCases := []struct { - name string - merged string - resolution markers.Resolution - }{ - {name: "ours", merged: "start\nours\nend\n", resolution: markers.ResolutionOurs}, - {name: "theirs", merged: "start\ntheirs\nend\n", resolution: markers.ResolutionTheirs}, - {name: "both", merged: "start\nours\ntheirs\nend\n", resolution: markers.ResolutionBoth}, - {name: "none", merged: "start\nend\n", resolution: markers.ResolutionNone}, - } - - for _, tc := range testCases { - doc := parseSingleConflictDoc(t) - updated, manual, _, _, err := applyMergedResolutions(doc, []byte(tc.merged)) - if err != nil { - t.Fatalf("%s: applyMergedResolutions error: %v", tc.name, err) - } - if len(manual) != 0 { - t.Fatalf("%s: expected no manual resolutions", tc.name) - } - seg := conflictSegment(t, updated, 0) - if seg.Resolution != tc.resolution { - t.Fatalf("%s: resolution = %q, want %q", tc.name, seg.Resolution, tc.resolution) - } - } -} - -func TestApplyMergedResolutionsManualEdit(t *testing.T) { - doc := parseSingleConflictDoc(t) - updated, manual, _, _, err := applyMergedResolutions(doc, []byte("start\nmanual\nend\n")) - if err != nil { - t.Fatalf("applyMergedResolutions error: %v", err) - } - seg := conflictSegment(t, updated, 0) - if seg.Resolution != markers.ResolutionUnset { - t.Fatalf("resolution = %q, want unset", seg.Resolution) - } - if got := string(manual[0]); got != "manual\n" { - t.Fatalf("manual resolution = %q, want %q", got, "manual\n") - } -} - -func TestApplyMergedResolutionsSkipsConflictMarkers(t *testing.T) { - merged := "start\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nend\n" - doc := parseSingleConflictDoc(t) - updated, manual, labels, known, err := applyMergedResolutions(doc, []byte(merged)) - if err != nil { - t.Fatalf("applyMergedResolutions error: %v", err) - } - if len(manual) != 0 { - t.Fatalf("expected no manual resolutions when markers are present") - } - seg := conflictSegment(t, updated, 0) - if seg.Resolution != markers.ResolutionUnset { - t.Fatalf("resolution = %q, want unset", seg.Resolution) - } - if labels[0].OursLabel != "HEAD" || labels[0].TheirsLabel != "branch" { - t.Fatalf("labels[0] = %+v, want HEAD/branch", labels[0]) - } - if !known[0] { - t.Fatalf("known[0] = false, want true") - } -} - -func TestApplyMergedResolutionsAllowsNonConflictDeletion(t *testing.T) { - doc := parseSingleConflictDoc(t) - 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 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 TestApplyMergedResolutionsKeepsEditedAndSkippedTextOutsideConflict(t *testing.T) { - data := []byte("intro\ngone\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("intro!\nkeep\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" + - " 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") -} - -func TestApplyMergedResolutionsWitrLicenseScenario(t *testing.T) { - diff3 := witrLicenseDiff3Fixture() - - 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:\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) - } - if unresolved { - t.Fatalf("expected fully resolved output") - } - if string(rendered) != string(merged) { - t.Fatalf("rendered merged output mismatch") - } -} - -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) - 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)) - } -} - -func TestApplyMergedResolutionsAlignsLabelsToOriginalConflictIndex(t *testing.T) { - doc := parseMultiConflictDoc(t) - merged := []byte("start\nmanual1\nmid\n<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>> branch\nend\n") - _, manual, labels, known, err := applyMergedResolutions(doc, merged) - if err != nil { - t.Fatalf("applyMergedResolutions error: %v", err) - } - if got := string(manual[0]); got != "manual1\n" { - t.Fatalf("manual[0] = %q, want %q", got, "manual1\\n") - } - if labels[0].OursLabel != "" || labels[0].TheirsLabel != "" { - t.Fatalf("labels[0] = %+v, want empty labels for manually resolved conflict", labels[0]) - } - if labels[1].OursLabel != "HEAD" || labels[1].TheirsLabel != "branch" { - t.Fatalf("labels[1] = %+v, want HEAD/branch", labels[1]) - } - if known[0] { - t.Fatalf("known[0] = true, want false") - } - if !known[1] { - t.Fatalf("known[1] = false, want true") - } -} - -func TestAllResolvedWithManualOverride(t *testing.T) { - data := []byte("start\n<<<<<<< HEAD\nours1\n=======\ntheirs1\n>>>>>>> branch\nmid\n<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>> branch\nend\n") - doc, err := markers.Parse(data) - if err != nil { - t.Fatalf("Parse error: %v", err) - } - - setConflictResolution(&doc, 0, markers.ResolutionOurs) - if allResolved(doc, map[int][]byte{}) { - t.Fatalf("expected unresolved without manual override") - } - - manual := map[int][]byte{1: []byte("manual\n")} - if !allResolved(doc, manual) { - t.Fatalf("expected resolved with manual override") - } -} - -func TestContainsConflictMarkers(t *testing.T) { - if !containsConflictMarkers([][]byte{[]byte("<<<<<<< HEAD\n")}) { - t.Fatalf("expected conflict markers to be detected") - } - if containsConflictMarkers([][]byte{[]byte("ok\n"), []byte("line\n")}) { - t.Fatalf("did not expect conflict markers") - } -} diff --git a/internal/tui/render_helpers_test.go b/internal/tui/render_helpers_test.go index 01b93c7..50804dc 100644 --- a/internal/tui/render_helpers_test.go +++ b/internal/tui/render_helpers_test.go @@ -44,31 +44,6 @@ func TestBuildResultLinesManualResolved(t *testing.T) { } } -func TestApplyMergedResolutionsManualHunk(t *testing.T) { - diff3 := []byte("header\n<<<<<<< HEAD\nours1\n||||||| base\nbase1\n=======\ntheirs1\n>>>>>>> branch\nmid\n<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>> branch\nfooter\n") - doc, err := markers.Parse(diff3) - if err != nil { - t.Fatalf("Parse error = %v", err) - } - - merged := []byte("header\nmanual1\nmid\n<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>> branch\nfooter\n") - updated, manual, _, _, err := applyMergedResolutions(doc, merged) - if err != nil { - t.Fatalf("applyMergedResolutions error = %v", err) - } - if len(manual) != 1 { - t.Fatalf("manualResolved count = %d, want 1", len(manual)) - } - if _, ok := manual[0]; !ok { - t.Fatalf("manualResolved missing conflict 0") - } - ref := updated.Conflicts[0] - seg := updated.Segments[ref.SegmentIndex].(markers.ConflictSegment) - if seg.Resolution != markers.ResolutionUnset { - t.Fatalf("conflict 0 resolution = %q, want unset", seg.Resolution) - } -} - func TestDiffEntriesCategories(t *testing.T) { base := []string{"line1", "line2"} side := []string{"line1", "line2-mod"} diff --git a/internal/tui/session.go b/internal/tui/session.go index 35b37c2..19e829c 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -2,15 +2,16 @@ package tui import ( "context" - "fmt" "os" "github.com/chojs23/ec/internal/cli" + "github.com/chojs23/ec/internal/engine" "github.com/chojs23/ec/internal/markers" "github.com/chojs23/ec/internal/mergeview" ) type resolverDocumentState struct { + state *engine.State doc markers.Document manualResolved map[int][]byte mergedLabels []conflictLabels @@ -22,27 +23,39 @@ func loadResolverDocumentState(ctx context.Context, opts cli.Options) (resolverD if err != nil { return resolverDocumentState{}, err } - - state := resolverDocumentState{ - doc: canonicalDoc, - manualResolved: map[int][]byte{}, - mergedLabels: make([]conflictLabels, len(canonicalDoc.Conflicts)), - mergedLabelKnown: make([]bool, len(canonicalDoc.Conflicts)), + runtimeState, err := engine.NewState(canonicalDoc) + if err != nil { + return resolverDocumentState{}, err } + state := buildResolverDocumentState(runtimeState) + mergedBytes, err := os.ReadFile(opts.MergedPath) if err != nil { return state, nil } - updated, manual, labels, known, err := applyMergedResolutions(canonicalDoc, mergedBytes) - if err != nil { - return resolverDocumentState{}, fmt.Errorf("apply merged resolutions: %w", err) + if err := runtimeState.ImportMerged(mergedBytes); err != nil { + return resolverDocumentState{}, err } + return buildResolverDocumentState(runtimeState), nil +} - state.doc = updated - state.manualResolved = manual - state.mergedLabels = labels - state.mergedLabelKnown = known - return state, nil +func buildResolverDocumentState(state *engine.State) resolverDocumentState { + labels, known := state.MergedLabels() + mergedLabels := make([]conflictLabels, len(labels)) + for i, label := range labels { + mergedLabels[i] = conflictLabels{ + OursLabel: label.OursLabel, + BaseLabel: label.BaseLabel, + TheirsLabel: label.TheirsLabel, + } + } + return resolverDocumentState{ + state: state, + doc: state.Document(), + manualResolved: state.ManualResolved(), + mergedLabels: mergedLabels, + mergedLabelKnown: known, + } } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 6b6aba1..b1adefb 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -8,7 +8,6 @@ import ( "os" "os/exec" "path/filepath" - "slices" "strings" "time" @@ -200,8 +199,7 @@ type conflictLabels struct { } type resolverSnapshot struct { - doc markers.Document - manualResolved map[int][]byte + state *engine.State } const ( @@ -233,17 +231,12 @@ func Run(ctx context.Context, opts cli.Options) error { } // Initialize state - state, err := engine.NewState(doc) - if err != nil { - return fmt.Errorf("failed to create state: %w", err) - } - baseLines, oursLines, theirsLines, ranges, useFullDiff := prepareFullDiff(doc, opts) m := model{ ctx: ctx, opts: opts, - state: state, + state: resolverState.state, doc: doc, baseLines: baseLines, oursLines: oursLines, @@ -316,12 +309,7 @@ func (m *model) openEditor() tea.Cmd { } } - resolved, _, err := renderMergedOutput(m.state.Document(), m.manualResolved, m.mergedLabels, m.mergedLabelKnown) - if err != nil { - return func() tea.Msg { - return editorFinishedMsg{err: fmt.Errorf("cannot generate preview for editor: %w", err)} - } - } + resolved := m.state.RenderMerged() if m.opts.Backup { bak := m.opts.MergedPath + ".ec.bak" @@ -354,12 +342,16 @@ func (m *model) openEditor() tea.Cmd { } func (m *model) reloadFromFile() error { - resolverState, err := loadResolverDocumentState(m.ctx, m.opts) + mergedBytes, err := os.ReadFile(m.opts.MergedPath) if err != nil { return err } + nextState := m.state.Clone() + if err := nextState.ImportMerged(mergedBytes); err != nil { + return err + } - doc := resolverState.doc + doc := nextState.Document() if !m.opts.AllowMissingBase { if err := engine.ValidateBaseCompleteness(doc); err != nil { @@ -372,11 +364,8 @@ func (m *model) reloadFromFile() error { } return m.applyResolverMutation(func() error { - m.state.ReplaceDocument(doc) - m.doc = m.state.Document() - m.manualResolved = resolverState.manualResolved - m.mergedLabels = resolverState.mergedLabels - m.mergedLabelKnown = resolverState.mergedLabelKnown + m.state = nextState + m.refreshResolverCaches() if m.currentConflict >= len(m.doc.Conflicts) { m.currentConflict = len(m.doc.Conflicts) - 1 @@ -458,11 +447,17 @@ func isTrulyMissingBaseStage(ctx context.Context, mergedPath string) (bool, bool if err != nil { return false, false } + if resolvedMergedPath, err := filepath.EvalSymlinks(absMergedPath); err == nil { + absMergedPath = resolvedMergedPath + } repoRoot, err := gitutil.RepoRoot(ctx, filepath.Dir(absMergedPath)) if err != nil { return false, false } + if resolvedRepoRoot, err := filepath.EvalSymlinks(repoRoot); err == nil { + repoRoot = resolvedRepoRoot + } relPath, err := filepath.Rel(repoRoot, absMergedPath) if err != nil { @@ -757,8 +752,7 @@ func (m *model) applySelectedSide() error { if err := m.state.ApplyResolution(m.currentConflict, resolution); err != nil { return err } - delete(m.manualResolved, m.currentConflict) - m.doc = m.state.Document() + m.refreshResolverCaches() return nil }) } @@ -768,8 +762,7 @@ func (m *model) applyResolution(resolution markers.Resolution) error { if err := m.state.ApplyResolution(m.currentConflict, resolution); err != nil { return err } - delete(m.manualResolved, m.currentConflict) - m.doc = m.state.Document() + m.refreshResolverCaches() return nil }) } @@ -779,8 +772,7 @@ func (m *model) applyAll(resolution markers.Resolution) error { if err := m.state.ApplyAll(resolution); err != nil { return err } - m.manualResolved = map[int][]byte{} - m.doc = m.state.Document() + m.refreshResolverCaches() return nil }) } @@ -942,7 +934,7 @@ func (m *model) handleWrite() (tea.Cmd, error) { if err := m.writeResolved(); err != nil { return nil, fmt.Errorf("failed to write resolved: %w", err) } - m.doc = m.state.Document() + m.refreshResolverCaches() m.updateViewports() return m.showToast("Saved", 2), nil } @@ -1122,10 +1114,8 @@ func (m *model) scrollVertical(delta int) { } func (m *model) writeResolved() error { - resolved, allowUnresolved, err := renderMergedOutput(m.state.Document(), m.manualResolved, m.mergedLabels, m.mergedLabelKnown) - if err != nil { - return fmt.Errorf("cannot write: %w", err) - } + resolved := m.state.RenderMerged() + allowUnresolved := m.state.HasUnresolvedConflicts() // Read original merged file for backup mergedBytes, err := os.ReadFile(m.opts.MergedPath) @@ -1176,40 +1166,6 @@ func allResolved(doc markers.Document, manualResolved map[int][]byte) bool { return true } -func renderMergedOutput(doc markers.Document, manualResolved map[int][]byte, mergedLabels []conflictLabels, mergedLabelKnown []bool) ([]byte, bool, error) { - var out bytes.Buffer - hasUnresolved := false - conflictIndex := -1 - - for _, seg := range doc.Segments { - switch s := seg.(type) { - case markers.TextSegment: - out.Write(s.Bytes) - case markers.ConflictSegment: - conflictIndex++ - if manualBytes, ok := manualResolved[conflictIndex]; ok { - out.Write(manualBytes) - continue - } - labels := conflictLabels{ - OursLabel: s.OursLabel, - BaseLabel: s.BaseLabel, - TheirsLabel: s.TheirsLabel, - } - if conflictIndex < len(mergedLabels) && conflictIndex < len(mergedLabelKnown) && mergedLabelKnown[conflictIndex] { - labels = mergedLabels[conflictIndex] - } - if markers.AppendConflictSegment(&out, s, labels.OursLabel, labels.BaseLabel, labels.TheirsLabel) { - hasUnresolved = true - } - default: - return nil, false, fmt.Errorf("unknown segment type %T", seg) - } - } - - return out.Bytes(), hasUnresolved, nil -} - func formatLabel(label string) string { if label == "" { return "" @@ -1266,6 +1222,21 @@ func renderResultPaneTitle(statusText string, paneWidth int, titleStyle lipgloss return titleStyle.Render(prefix + statusStyle.Render(trimmedStatus)) } +func (m *model) refreshResolverCaches() { + m.doc = m.state.Document() + m.manualResolved = m.state.ManualResolved() + labels, known := m.state.MergedLabels() + m.mergedLabels = make([]conflictLabels, len(labels)) + for i, label := range labels { + m.mergedLabels[i] = conflictLabels{ + OursLabel: label.OursLabel, + BaseLabel: label.BaseLabel, + TheirsLabel: label.TheirsLabel, + } + } + m.mergedLabelKnown = known +} + func truncateDisplayWidth(value string, maxWidth int) string { if maxWidth <= 0 { return "" @@ -1336,734 +1307,32 @@ func isHexByte(b byte) bool { return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F') } -func applyMergedResolutions(doc markers.Document, mergedBytes []byte) (markers.Document, map[int][]byte, []conflictLabels, []bool, error) { - mergedLines := markers.SplitLinesKeepEOL(mergedBytes) - pos := 0 - manualResolved := map[int][]byte{} - alignedLabels := make([]conflictLabels, len(doc.Conflicts)) - 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: - _ = 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, searchPos, nextTextLines) - 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) - } - - 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[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(conflictPos + start); err != nil { - return doc, manualResolved, alignedLabels, alignedLabelKnown, err - } - - if labelsKnown { - alignedLabels[conflictIndex] = labels - alignedLabelKnown[conflictIndex] = true - } - - if manualBytes != nil { - manualResolved[conflictIndex] = manualBytes - } else { - s.Resolution = resolution - doc.Segments[i] = s - } - - pos = conflictPos + 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 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, 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) { - 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 -} - -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, bool) { - if len(spanLines) == 0 || len(pendingTextLines) == 0 { - return 0, false - } - - if idx := findSubslice(spanLines, 0, pendingTextLines); idx != -1 { - start := idx + len(pendingTextLines) - if start > len(spanLines) { - return len(spanLines), true - } - return start, true - } - - 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, false - } - - return 0, false -} - -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 - } - - candidateStart := idx - offset - if candidateStart < start { - continue - } - - return candidateStart - } - } - - 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 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 { - return false - } - - if idx := findSubslice(pendingTextLines, 0, alignedLines); idx != -1 { - return true - } - - if idx := findApproxSubslice(pendingTextLines, 0, alignedLines); idx != -1 { - return true - } - - if canAlignPreservedText(alignedLines, pendingTextLines) { - return true - } - - return false -} - -func canAlignPreservedText(alignedLines [][]byte, pendingTextLines [][]byte) bool { - alignedIndex := 0 - pendingIndex := 0 - - for alignedIndex < len(alignedLines) && pendingIndex < len(pendingTextLines) { - if linesEquivalentForAlignment(alignedLines[alignedIndex], pendingTextLines[pendingIndex]) { - alignedIndex++ - pendingIndex++ - continue - } - - if alignedIndex+1 < len(alignedLines) && linesEquivalentForAlignment(alignedLines[alignedIndex+1], pendingTextLines[pendingIndex]) { - alignedIndex++ - continue - } - - if pendingIndex+1 < len(pendingTextLines) && linesEquivalentForAlignment(alignedLines[alignedIndex], pendingTextLines[pendingIndex+1]) { - pendingIndex++ - continue - } - - if lineSimilarityPercent(alignedLines[alignedIndex], pendingTextLines[pendingIndex]) >= 70 { - alignedIndex++ - pendingIndex++ - continue - } - - return false - } - - return alignedIndex == len(alignedLines) -} - -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 { - var labels conflictLabels - for _, line := range lines { - text := strings.TrimRight(string(line), "\r\n") - switch { - case strings.HasPrefix(text, "<<<<<<<"): - labels.OursLabel = strings.TrimSpace(strings.TrimPrefix(text, "<<<<<<<")) - case strings.HasPrefix(text, "|||||||"): - labels.BaseLabel = strings.TrimSpace(strings.TrimPrefix(text, "|||||||")) - case strings.HasPrefix(text, ">>>>>>>"): - labels.TheirsLabel = strings.TrimSpace(strings.TrimPrefix(text, ">>>>>>>")) - } - } - 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 { - lines := markers.SplitLinesKeepEOL(text.Bytes) - if len(lines) > 0 { - return lines - } - } - } - return nil -} - -func matchResolution(lines [][]byte, seg markers.ConflictSegment) (markers.Resolution, bool) { - ours := markers.SplitLinesKeepEOL(seg.Ours) - theirs := markers.SplitLinesKeepEOL(seg.Theirs) - both := append(append([][]byte{}, ours...), theirs...) - - if slices.EqualFunc(lines, ours, bytes.Equal) { - return markers.ResolutionOurs, true - } - if slices.EqualFunc(lines, theirs, bytes.Equal) { - return markers.ResolutionTheirs, true - } - if slices.EqualFunc(lines, both, bytes.Equal) { - return markers.ResolutionBoth, true - } - if len(lines) == 0 { - return markers.ResolutionNone, true - } - return markers.ResolutionUnset, false -} - -func containsConflictMarkers(lines [][]byte) bool { - for _, line := range lines { - if bytes.HasPrefix(line, []byte("<<<<<<<")) || - bytes.HasPrefix(line, []byte("|||||||")) || - bytes.HasPrefix(line, []byte("=======")) || - bytes.HasPrefix(line, []byte(">>>>>>>")) { - return true - } - } - return false -} - -func findSubslice(haystack [][]byte, start int, needle [][]byte) int { - if len(needle) == 0 { - return start - } - if start < 0 { - start = 0 - } - for i := start; i+len(needle) <= len(haystack); i++ { - matched := true - for j := range needle { - if !bytes.Equal(haystack[i+j], needle[j]) { - matched = false - break - } - } - if matched { - return i - } - } - return -1 -} - -func cloneManualResolved(src map[int][]byte) map[int][]byte { - if len(src) == 0 { - return map[int][]byte{} - } - cloned := make(map[int][]byte, len(src)) - for key, value := range src { - cloned[key] = append([]byte(nil), value...) +func resolverSnapshotsEqual(left resolverSnapshot, right resolverSnapshot) bool { + if left.state == nil || right.state == nil { + return left.state == nil && right.state == nil } - return cloned -} - -func manualResolvedEqual(left map[int][]byte, right map[int][]byte) bool { - if len(left) != len(right) { + leftLabels, leftKnown := left.state.MergedLabels() + rightLabels, rightKnown := right.state.MergedLabels() + if len(leftLabels) != len(rightLabels) || len(leftKnown) != len(rightKnown) { return false } - for key, leftValue := range left { - rightValue, ok := right[key] - if !ok || !bytes.Equal(leftValue, rightValue) { + for i := range leftLabels { + if leftLabels[i] != rightLabels[i] || leftKnown[i] != rightKnown[i] { return false } } - return true -} - -func resolverSnapshotsEqual(left resolverSnapshot, right resolverSnapshot) bool { - return markers.DocumentsEqual(left.doc, right.doc) && manualResolvedEqual(left.manualResolved, right.manualResolved) + return markers.DocumentsEqual(left.state.Document(), right.state.Document()) && bytes.Equal(left.state.RenderMerged(), right.state.RenderMerged()) } func (m *model) captureResolverSnapshot() resolverSnapshot { return resolverSnapshot{ - doc: markers.CloneDocument(m.state.Document()), - manualResolved: cloneManualResolved(m.manualResolved), + state: m.state.Clone(), } } func (m *model) restoreResolverSnapshot(snapshot resolverSnapshot) { - m.state.ReplaceDocument(snapshot.doc) - m.doc = m.state.Document() - m.manualResolved = cloneManualResolved(snapshot.manualResolved) + m.state = snapshot.state.Clone() + m.refreshResolverCaches() } func (m *model) pushResolverUndo(snapshot resolverSnapshot) { diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index 866de54..acb9049 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -21,6 +21,26 @@ import ( "github.com/chojs23/ec/internal/mergeview" ) +func parseSingleConflictDoc(t *testing.T) markers.Document { + t.Helper() + data := []byte("start\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\nend\n") + doc, err := markers.Parse(data) + if err != nil { + t.Fatalf("Parse error: %v", err) + } + return doc +} + +func conflictSegment(t *testing.T, doc markers.Document, index int) markers.ConflictSegment { + t.Helper() + ref := doc.Conflicts[index] + seg, ok := doc.Segments[ref.SegmentIndex].(markers.ConflictSegment) + if !ok { + t.Fatalf("expected conflict segment") + } + return seg +} + func TestModelQuitBackToSelector(t *testing.T) { m := model{} updated, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'q'}}) @@ -101,6 +121,9 @@ func TestOpenEditorWithUnresolvedConflicts(t *testing.T) { if err != nil { t.Fatalf("NewState error = %v", err) } + if err := state.ImportMerged([]byte("line1\nmanual\nline2\n")); err != nil { + t.Fatalf("ImportMerged error = %v", err) + } editorPath := filepath.Join(tmpDir, "editor.sh") if err := os.WriteFile(editorPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { @@ -144,6 +167,9 @@ func TestOpenEditorUsesManualResolvedPreview(t *testing.T) { if err != nil { t.Fatalf("NewState error = %v", err) } + if err := state.ImportMerged([]byte("line1\nmanual\nline2\n")); err != nil { + t.Fatalf("ImportMerged error = %v", err) + } editorPath := filepath.Join(tmpDir, "editor.sh") if err := os.WriteFile(editorPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { @@ -157,11 +183,10 @@ func TestOpenEditorUsesManualResolvedPreview(t *testing.T) { defer os.Setenv("EDITOR", originalEditor) m := model{ - state: state, - doc: doc, - manualResolved: map[int][]byte{0: []byte("manual\n")}, - opts: cliOptionsWithMergedPath(mergedPath), + state: state, + opts: cliOptionsWithMergedPath(mergedPath), } + m.refreshResolverCaches() msg := m.openEditor()() if !strings.Contains(fmt.Sprintf("%T", msg), "execMsg") { @@ -885,35 +910,6 @@ func TestModelViewNoLabelsWithoutMergedLabels(t *testing.T) { } } -func TestLabelsFromConflictSpan(t *testing.T) { - lines := markers.SplitLinesKeepEOL([]byte("<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> feature/add-auth\n")) - labels := labelsFromConflictSpan(lines) - if labels.OursLabel != "HEAD" { - t.Fatalf("OursLabel = %q, want HEAD", labels.OursLabel) - } - if labels.TheirsLabel != "feature/add-auth" { - t.Fatalf("TheirsLabel = %q, want feature/add-auth", labels.TheirsLabel) - } -} - -func TestLabelsFromConflictSpanWithHash(t *testing.T) { - lines := markers.SplitLinesKeepEOL([]byte("<<<<<<< HEAD\nours\n||||||| abc1234\nbase\n=======\ntheirs\n>>>>>>> abc1234def5678901234 (main change)\n")) - labels := labelsFromConflictSpan(lines) - if labels.BaseLabel != "abc1234" { - t.Fatalf("BaseLabel = %q, want abc1234", labels.BaseLabel) - } - if labels.TheirsLabel != "abc1234def5678901234 (main change)" { - t.Fatalf("TheirsLabel = %q, want raw label", labels.TheirsLabel) - } -} - -func TestLabelsFromConflictSpanInvalid(t *testing.T) { - labels := labelsFromConflictSpan(markers.SplitLinesKeepEOL([]byte("no conflicts here"))) - if labels != (conflictLabels{}) { - t.Fatalf("expected zero labels for no-conflict input, got %+v", labels) - } -} - func TestRenderToastLine(t *testing.T) { m := model{width: 20, toastMessage: "Saved"} if !strings.Contains(m.renderToastLine(), "Saved") { @@ -1803,16 +1799,15 @@ func TestWriteResolvedPreservesMergedLabelsForUnresolved(t *testing.T) { if err != nil { t.Fatalf("NewState error = %v", err) } + if err := state.ImportMerged([]byte("<<<<<<< ec\nours\n=======\ntheirs\n>>>>>>> main\n")); err != nil { + t.Fatalf("ImportMerged error = %v", err) + } m := model{ state: state, opts: cli.Options{MergedPath: mergedPath}, - mergedLabels: []conflictLabels{{ - OursLabel: "ec", - TheirsLabel: "main", - }}, - mergedLabelKnown: []bool{true}, } + m.refreshResolverCaches() if err := m.writeResolved(); err != nil { t.Fatalf("writeResolved error = %v", err) From b16e2c300d489075ac076cdf800262cc1562213b Mon Sep 17 00:00:00 2001 From: neo Date: Tue, 10 Mar 2026 13:21:48 +0900 Subject: [PATCH 7/8] Fix merge-state importer to preserve boundary text --- internal/engine/state.go | 191 +++++++++++++++++++--------- internal/engine/state_test.go | 79 ++++++++++++ internal/tui/render_helpers.go | 26 +++- internal/tui/render_helpers_test.go | 6 +- internal/tui/session.go | 2 + internal/tui/tui.go | 7 +- internal/tui/tui_test.go | 82 ++++++++++++ 7 files changed, 327 insertions(+), 66 deletions(-) diff --git a/internal/engine/state.go b/internal/engine/state.go index 83bf96c..72113fa 100644 --- a/internal/engine/state.go +++ b/internal/engine/state.go @@ -30,10 +30,23 @@ type segmentState struct { conflict *conflictState } +type renderSlotKind int + +const ( + slotBoundary renderSlotKind = iota + slotSegment +) + +type renderSlot struct { + kind renderSlotKind + index int +} + type State struct { - canonical markers.Document - segments []segmentState - doc markers.Document + canonical markers.Document + segments []segmentState + boundaries [][]byte + doc markers.Document } func NewState(doc markers.Document) (*State, error) { @@ -52,7 +65,7 @@ func newStateFromDocument(doc markers.Document) *State { segments = append(segments, segmentState{conflict: &cs}) } } - state := &State{canonical: canonical, segments: segments} + state := &State{canonical: canonical, segments: segments, boundaries: make([][]byte, len(segments)+1)} state.syncDocument() return state } @@ -152,6 +165,10 @@ func (s *State) syncDocument() { func (s *State) Clone() *State { clone := &State{canonical: markers.CloneDocument(s.canonical), doc: markers.CloneDocument(s.doc)} clone.segments = make([]segmentState, len(s.segments)) + clone.boundaries = make([][]byte, len(s.boundaries)) + for i, boundary := range s.boundaries { + clone.boundaries[i] = append([]byte(nil), boundary...) + } for i, segment := range s.segments { if segment.conflict == nil { clone.segments[i] = segmentState{text: append([]byte(nil), segment.text...)} @@ -166,16 +183,28 @@ func (s *State) Clone() *State { func (s *State) RenderMerged() []byte { var out bytes.Buffer - for _, segment := range s.segments { + for i, segment := range s.segments { + out.Write(s.boundaries[i]) if segment.conflict == nil { out.Write(segment.text) continue } out.Write(segment.conflict.output) } + if len(s.boundaries) > 0 { + out.Write(s.boundaries[len(s.boundaries)-1]) + } return out.Bytes() } +func (s *State) BoundaryText() [][]byte { + boundaries := make([][]byte, len(s.boundaries)) + for i, boundary := range s.boundaries { + boundaries[i] = append([]byte(nil), boundary...) + } + return boundaries +} + func (s *State) HasUnresolvedConflicts() bool { for _, ref := range s.canonical.Conflicts { conflict := s.segments[ref.SegmentIndex].conflict @@ -220,49 +249,59 @@ func (s *State) ImportMerged(merged []byte) error { oldLines := markers.SplitLinesKeepEOL(s.RenderMerged()) newLines := markers.SplitLinesKeepEOL(merged) - segmentLines, lineToSegment, boundaryOwner := s.segmentLineOwnership() - _ = segmentLines + slots := s.renderSlots() + lineToSlot, boundarySlotAtCursor := s.slotLineOwnership(slots) ops := diffLines(oldLines, newLines) - assigned := make([][][]byte, len(s.segments)) + assigned := make([][][]byte, len(slots)) oldCursor := 0 - pendingDeletedOwner := -1 + pendingDeletedSlot := -1 for _, op := range ops { switch op.kind { case diffInsert: - target := boundaryOwner[oldCursor] - if pendingDeletedOwner != -1 { - target = pendingDeletedOwner + target := pendingDeletedSlot + if target == -1 { + target = slotIndexAtCursor(lineToSlot, boundarySlotAtCursor, oldCursor) + } + if target == -1 { + target = 0 } assigned[target] = append(assigned[target], op.newLines...) - pendingDeletedOwner = -1 + pendingDeletedSlot = -1 case diffEqual: for _, line := range op.newLines { - if oldCursor >= len(lineToSegment) { + if oldCursor >= len(lineToSlot) { break } - target := lineToSegment[oldCursor] + target := lineToSlot[oldCursor] assigned[target] = append(assigned[target], line) oldCursor++ } - pendingDeletedOwner = -1 + pendingDeletedSlot = -1 case diffDelete: - if len(op.oldLines) > 0 && oldCursor < len(lineToSegment) { - pendingDeletedOwner = lineToSegment[oldCursor] + if len(op.oldLines) > 0 && oldCursor < len(lineToSlot) { + pendingDeletedSlot = lineToSlot[oldCursor] } oldCursor += len(op.oldLines) } } - for i, segment := range s.segments { + for i, slot := range slots { updated := joinLines(assigned[i]) - if segment.conflict == nil { - s.segments[i].text = updated + switch slot.kind { + case slotBoundary: + s.boundaries[slot.index] = updated continue + case slotSegment: + segment := s.segments[slot.index] + if segment.conflict == nil { + s.segments[slot.index].text = updated + continue + } + conflict := s.segments[slot.index].conflict + conflict.output = updated + conflict.classifyUpdatedOutput() } - conflict := s.segments[i].conflict - conflict.output = updated - conflict.classifyUpdatedOutput() } s.syncDocument() return nil @@ -279,7 +318,11 @@ func (s *State) canImportParsedDocument(doc markers.Document) bool { return false } case markers.ConflictSegment: - if _, ok := doc.Segments[i].(markers.ConflictSegment); !ok { + parsedConflict, ok := doc.Segments[i].(markers.ConflictSegment) + if !ok { + return false + } + if isAdjacentConflictSegment(s.canonical.Segments, i) && !sameConflictIdentity(seg, parsedConflict) { return false } } @@ -288,6 +331,9 @@ func (s *State) canImportParsedDocument(doc markers.Document) bool { } func (s *State) importParsedDocument(doc markers.Document) { + for i := range s.boundaries { + s.boundaries[i] = nil + } for i, parsed := range doc.Segments { switch seg := parsed.(type) { case markers.TextSegment: @@ -308,46 +354,42 @@ func (s *State) importParsedDocument(doc markers.Document) { s.syncDocument() } -func (s *State) segmentLineOwnership() ([]int, []int, []int) { - segmentLines := make([]int, len(s.segments)) - totalLines := 0 - for i, segment := range s.segments { - lines := markers.SplitLinesKeepEOL(s.segmentBytes(segment)) - segmentLines[i] = len(lines) - totalLines += len(lines) - } - - lineToSegment := make([]int, totalLines) - boundaryOwner := make([]int, totalLines+1) - for i := range boundaryOwner { - boundaryOwner[i] = -1 +func (s *State) renderSlots() []renderSlot { + slots := make([]renderSlot, 0, len(s.segments)*2+1) + for i := range s.boundaries { + slots = append(slots, renderSlot{kind: slotBoundary, index: i}) + if i < len(s.segments) { + slots = append(slots, renderSlot{kind: slotSegment, index: i}) + } } + return slots +} +func (s *State) slotLineOwnership(slots []renderSlot) ([]int, map[int]int) { + lineToSlot := make([]int, 0) + boundarySlotAtCursor := map[int]int{} cursor := 0 - for i, count := range segmentLines { - boundaryOwner[cursor] = i - for j := 0; j < count; j++ { - lineToSegment[cursor+j] = i + for slotIndex, slot := range slots { + lines := markers.SplitLinesKeepEOL(s.slotBytes(slot)) + start := cursor + for range lines { + lineToSlot = append(lineToSlot, slotIndex) + cursor++ } - cursor += count - } - if len(s.segments) > 0 && boundaryOwner[totalLines] == -1 { - boundaryOwner[totalLines] = len(s.segments) - 1 - } - for i := range boundaryOwner { - if boundaryOwner[i] != -1 { - continue - } - if i > 0 { - boundaryOwner[i] = lineToSegment[i-1] - continue + if slot.kind == slotBoundary { + for pos := start; pos <= cursor; pos++ { + boundarySlotAtCursor[pos] = slotIndex + } } - boundaryOwner[i] = 0 } - return segmentLines, lineToSegment, boundaryOwner + return lineToSlot, boundarySlotAtCursor } -func (s *State) segmentBytes(segment segmentState) []byte { +func (s *State) slotBytes(slot renderSlot) []byte { + if slot.kind == slotBoundary { + return s.boundaries[slot.index] + } + segment := s.segments[slot.index] if segment.conflict == nil { return segment.text } @@ -424,6 +466,28 @@ func renderConflictMarkers(seg markers.ConflictSegment, labels ConflictLabels) [ return out.Bytes() } +func sameConflictIdentity(left markers.Segment, right markers.ConflictSegment) bool { + canonical, ok := left.(markers.ConflictSegment) + if !ok { + return false + } + return bytes.Equal(canonical.Ours, right.Ours) && bytes.Equal(canonical.Base, right.Base) && bytes.Equal(canonical.Theirs, right.Theirs) +} + +func isAdjacentConflictSegment(segments []markers.Segment, index int) bool { + if index > 0 { + if _, ok := segments[index-1].(markers.ConflictSegment); ok { + return true + } + } + if index+1 < len(segments) { + if _, ok := segments[index+1].(markers.ConflictSegment); ok { + return true + } + } + return false +} + func classifyConflictOutput(seg markers.ConflictSegment, output []byte) (markers.Resolution, bool, bool, ConflictLabels, bool) { both := append(append([][]byte{}, markers.SplitLinesKeepEOL(seg.Ours)...), markers.SplitLinesKeepEOL(seg.Theirs)...) bothBytes := joinLines(both) @@ -461,6 +525,19 @@ func isSupportedResolution(resolution markers.Resolution) bool { } } +func slotIndexAtCursor(lineToSlot []int, boundarySlotAtCursor map[int]int, cursor int) int { + if slot, ok := boundarySlotAtCursor[cursor]; ok { + return slot + } + if cursor < len(lineToSlot) { + return lineToSlot[cursor] + } + if cursor > 0 && cursor-1 < len(lineToSlot) { + return lineToSlot[cursor-1] + } + return -1 +} + func joinLines(lines [][]byte) []byte { if len(lines) == 0 { return nil diff --git a/internal/engine/state_test.go b/internal/engine/state_test.go index 7bb339e..770cc64 100644 --- a/internal/engine/state_test.go +++ b/internal/engine/state_test.go @@ -430,3 +430,82 @@ line2 t.Fatalf("Resolution = %q, want %q after replace", seg.Resolution, markers.ResolutionOurs) } } + +func TestImportMergedPreservesLeadingBoundaryTextAfterResolve(t *testing.T) { + input := []byte("<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\ntail\n") + doc, err := markers.Parse(input) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + state, err := NewState(doc) + if err != nil { + t.Fatalf("NewState failed: %v", err) + } + merged := []byte("header\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\ntail\n") + if err := state.ImportMerged(merged); err != nil { + t.Fatalf("ImportMerged failed: %v", err) + } + boundaries := state.BoundaryText() + if got := string(boundaries[0]); got != "header\n" { + t.Fatalf("BoundaryText()[0] = %q, want %q", got, "header\\n") + } + if err := state.ApplyResolution(0, markers.ResolutionOurs); err != nil { + t.Fatalf("ApplyResolution failed: %v", err) + } + if got := string(state.RenderMerged()); got != "header\nours\ntail\n" { + t.Fatalf("RenderMerged = %q, want %q", got, "header\\nours\\ntail\\n") + } +} + +func TestImportMergedPreservesTextBetweenAdjacentConflictsAfterResolve(t *testing.T) { + input := []byte("<<<<<<< HEAD\nours1\n=======\ntheirs1\n>>>>>>> one\n<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>> two\n") + doc, err := markers.Parse(input) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + state, err := NewState(doc) + if err != nil { + t.Fatalf("NewState failed: %v", err) + } + merged := []byte("<<<<<<< HEAD\nours1\n=======\ntheirs1\n>>>>>>> one\nbetween\n<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>> two\n") + if err := state.ImportMerged(merged); err != nil { + t.Fatalf("ImportMerged failed: %v", err) + } + boundaries := state.BoundaryText() + if got := string(boundaries[1]); got != "between\n" { + t.Fatalf("BoundaryText()[1] = %q, want %q", got, "between\\n") + } + if err := state.ApplyResolution(0, markers.ResolutionOurs); err != nil { + t.Fatalf("ApplyResolution failed: %v", err) + } + expected := "ours1\nbetween\n<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>> two\n" + if got := string(state.RenderMerged()); got != expected { + t.Fatalf("RenderMerged = %q, want %q", got, expected) + } +} + +func TestCanImportParsedDocumentRejectsReorderedConflicts(t *testing.T) { + input := []byte("<<<<<<< left-one\nours1\n=======\ntheirs1\n>>>>>>> right-one\n<<<<<<< left-two\nours2\n=======\ntheirs2\n>>>>>>> right-two\n") + doc, err := markers.Parse(input) + if err != nil { + t.Fatalf("Parse failed: %v", err) + } + state, err := NewState(doc) + if err != nil { + t.Fatalf("NewState failed: %v", err) + } + swapped := []byte("<<<<<<< left-two\nours2\n=======\ntheirs2\n>>>>>>> right-two\n<<<<<<< left-one\nours1\n=======\ntheirs1\n>>>>>>> right-one\n") + parsed, err := markers.Parse(swapped) + if err != nil { + t.Fatalf("Parse swapped failed: %v", err) + } + if state.canImportParsedDocument(parsed) { + t.Fatal("canImportParsedDocument should reject reordered conflicts") + } + if err := state.ImportMerged(swapped); err != nil { + t.Fatalf("ImportMerged failed: %v", err) + } + if got := string(state.RenderMerged()); got != string(swapped) { + t.Fatalf("RenderMerged = %q, want %q", got, string(swapped)) + } +} diff --git a/internal/tui/render_helpers.go b/internal/tui/render_helpers.go index eb7ee10..0f29944 100644 --- a/internal/tui/render_helpers.go +++ b/internal/tui/render_helpers.go @@ -433,12 +433,21 @@ func findSequence(lines []string, seq []string, start int) (int, int, bool) { return -1, -1, false } -func buildResultLines(doc markers.Document, highlightConflict int, selectedSide selectionSide, manualResolved map[int][]byte) ([]lineInfo, int) { +func buildResultLines(doc markers.Document, highlightConflict int, selectedSide selectionSide, manualResolved map[int][]byte, boundaryText [][]byte) ([]lineInfo, int) { var lines []lineInfo conflictIndex := -1 currentStart := -1 - for _, seg := range doc.Segments { + appendBoundary := func(index int) { + if index < 0 || index >= len(boundaryText) { + return + } + boundaryLines := splitLines(boundaryText[index]) + lines = append(lines, makeLineInfos(boundaryLines, categoryDefault, false, false, false, false, "")...) + } + + appendBoundary(0) + for segIndex, seg := range doc.Segments { switch s := seg.(type) { case markers.TextSegment: segmentLines := splitLines(s.Bytes) @@ -535,6 +544,7 @@ func buildResultLines(doc markers.Document, highlightConflict int, selectedSide } } + appendBoundary(segIndex + 1) } if currentStart == -1 { @@ -543,7 +553,7 @@ func buildResultLines(doc markers.Document, highlightConflict int, selectedSide return lines, currentStart } -func buildResultPreviewLines(doc markers.Document, selectedSide selectionSide, manualResolved map[int][]byte, highlightConflict int) ([]string, map[int]lineCategory, []resultRange) { +func buildResultPreviewLines(doc markers.Document, selectedSide selectionSide, manualResolved map[int][]byte, highlightConflict int, boundaryText [][]byte) ([]string, map[int]lineCategory, []resultRange) { var lines []string forced := map[int]lineCategory{} ranges := make([]resultRange, 0, len(doc.Conflicts)) @@ -555,8 +565,15 @@ func buildResultPreviewLines(doc markers.Document, selectedSide selectionSide, m } lines = append(lines, newLines...) } + appendBoundary := func(index int) { + if index < 0 || index >= len(boundaryText) { + return + } + appendLines(splitLines(boundaryText[index])) + } - for _, seg := range doc.Segments { + appendBoundary(0) + for segIndex, seg := range doc.Segments { switch s := seg.(type) { case markers.TextSegment: appendLines(splitLines(s.Bytes)) @@ -598,6 +615,7 @@ func buildResultPreviewLines(doc markers.Document, selectedSide selectionSide, m ranges = append(ranges, resultRange{start: start, end: len(lines), resolved: resolved}) } + appendBoundary(segIndex + 1) } return lines, forced, ranges diff --git a/internal/tui/render_helpers_test.go b/internal/tui/render_helpers_test.go index 50804dc..94ac02c 100644 --- a/internal/tui/render_helpers_test.go +++ b/internal/tui/render_helpers_test.go @@ -25,7 +25,7 @@ func TestBuildResultLinesManualResolved(t *testing.T) { t.Fatalf("Parse error = %v", err) } manual := map[int][]byte{0: []byte("manual\n")} - lines, _ := buildResultLines(doc, 0, selectedOurs, manual) + lines, _ := buildResultLines(doc, 0, selectedOurs, manual, nil) if len(lines) == 0 { t.Fatalf("expected lines") } @@ -207,7 +207,7 @@ func TestBuildResultPreviewLinesUsesSelection(t *testing.T) { Conflicts: []markers.ConflictRef{{SegmentIndex: 1}}, } - lines, forced, ranges := buildResultPreviewLines(doc, selectedTheirs, nil, 0) + lines, forced, ranges := buildResultPreviewLines(doc, selectedTheirs, nil, 0, nil) if len(forced) != 0 { t.Fatalf("forced len = %d, want 0", len(forced)) } @@ -243,7 +243,7 @@ func TestBuildResultPreviewLinesManualAndNone(t *testing.T) { } manual := map[int][]byte{0: []byte("manual\n")} - lines, forced, ranges := buildResultPreviewLines(doc, selectedOurs, manual, 1) + lines, forced, ranges := buildResultPreviewLines(doc, selectedOurs, manual, 1, nil) if len(lines) != 5 { t.Fatalf("lines len = %d, want 5", len(lines)) } diff --git a/internal/tui/session.go b/internal/tui/session.go index 19e829c..89b35b7 100644 --- a/internal/tui/session.go +++ b/internal/tui/session.go @@ -13,6 +13,7 @@ import ( type resolverDocumentState struct { state *engine.State doc markers.Document + boundaryText [][]byte manualResolved map[int][]byte mergedLabels []conflictLabels mergedLabelKnown []bool @@ -54,6 +55,7 @@ func buildResolverDocumentState(state *engine.State) resolverDocumentState { return resolverDocumentState{ state: state, doc: state.Document(), + boundaryText: state.BoundaryText(), manualResolved: state.ManualResolved(), mergedLabels: mergedLabels, mergedLabelKnown: known, diff --git a/internal/tui/tui.go b/internal/tui/tui.go index b1adefb..32cb0cb 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -172,6 +172,7 @@ type model struct { selectedSide selectionSide mergedLabels []conflictLabels mergedLabelKnown []bool + resultBoundaries [][]byte manualResolved map[int][]byte resolverUndo []resolverSnapshot resolverRedo []resolverSnapshot @@ -247,6 +248,7 @@ func Run(ctx context.Context, opts cli.Options) error { selectedSide: selectedOurs, mergedLabels: resolverState.mergedLabels, mergedLabelKnown: resolverState.mergedLabelKnown, + resultBoundaries: resolverState.boundaryText, manualResolved: resolverState.manualResolved, pendingScroll: true, } @@ -1013,11 +1015,11 @@ func (m *model) updateViewports() { var resultLines []lineInfo var resultStart int if useFullDiff { - previewLines, forced, resultRanges := buildResultPreviewLines(m.doc, m.selectedSide, m.manualResolved, m.currentConflict) + previewLines, forced, resultRanges := buildResultPreviewLines(m.doc, m.selectedSide, m.manualResolved, m.currentConflict, m.resultBoundaries) resultEntries := diffEntries(m.baseLines, previewLines) resultLines, resultStart = buildResultLinesFromEntries(resultEntries, resultRanges, m.currentConflict, forced) } else { - resultLines, resultStart = buildResultLines(m.doc, m.currentConflict, m.selectedSide, m.manualResolved) + resultLines, resultStart = buildResultLines(m.doc, m.currentConflict, m.selectedSide, m.manualResolved, m.resultBoundaries) } resultContent := renderLines(resultLines, lineNumberStyle, baseStyles, highlightStyles, selectedStyles, connectorStyles, true) m.viewportResult.SetContent(resultContent) @@ -1224,6 +1226,7 @@ func renderResultPaneTitle(statusText string, paneWidth int, titleStyle lipgloss func (m *model) refreshResolverCaches() { m.doc = m.state.Document() + m.resultBoundaries = m.state.BoundaryText() m.manualResolved = m.state.ManualResolved() labels, known := m.state.MergedLabels() m.mergedLabels = make([]conflictLabels, len(labels)) diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index acb9049..aed63c2 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -442,6 +442,88 @@ func TestLoadResolverDocumentStateFallsBackForMixedResolvedMergedFile(t *testing } +func TestInitialLoadRenderUsesModelOwnedMergeState(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration-style test in short mode") + } + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found in PATH") + } + + ctx := context.Background() + tmpDir := t.TempDir() + + basePath := filepath.Join(tmpDir, "base.txt") + localPath := filepath.Join(tmpDir, "local.txt") + remotePath := filepath.Join(tmpDir, "remote.txt") + mergedPath := filepath.Join(tmpDir, "merged.txt") + + baseContent := "start\nbase\nend\n" + localContent := "start\nours\nend\n" + remoteContent := "start\ntheirs\nend\n" + mergedContent := "start\nmanual\nend\n" + + for path, content := range map[string]string{ + basePath: baseContent, + localPath: localContent, + remotePath: remoteContent, + mergedPath: mergedContent, + } { + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + + resolverState, err := loadResolverDocumentState(ctx, cli.Options{ + BasePath: basePath, + LocalPath: localPath, + RemotePath: remotePath, + MergedPath: mergedPath, + }) + if err != nil { + t.Fatalf("loadResolverDocumentState error = %v", err) + } + if resolverState.state == nil { + t.Fatal("resolverState.state = nil") + } + if got := string(resolverState.state.RenderMerged()); got != mergedContent { + t.Fatalf("RenderMerged = %q, want %q", got, mergedContent) + } + manual, ok := resolverState.manualResolved[0] + if !ok { + t.Fatal("expected manual resolution for conflict 0") + } + if string(manual) != "manual\n" { + t.Fatalf("manual resolution = %q, want %q", string(manual), "manual\\n") + } + + m := model{ + ready: true, + ctx: ctx, + opts: cli.Options{BasePath: basePath, LocalPath: localPath, RemotePath: remotePath, MergedPath: mergedPath}, + state: resolverState.state, + doc: resolverState.doc, + manualResolved: resolverState.manualResolved, + mergedLabels: resolverState.mergedLabels, + mergedLabelKnown: resolverState.mergedLabelKnown, + currentConflict: 0, + selectedSide: selectedOurs, + viewportOurs: viewport.New(40, 5), + viewportResult: viewport.New(40, 5), + viewportTheirs: viewport.New(40, 5), + width: 100, + height: 20, + } + m.updateViewports() + + if !strings.Contains(m.viewportResult.View(), "manual") { + t.Fatalf("expected rendered result pane to include manual text, got:\n%s", m.viewportResult.View()) + } + if !strings.Contains(m.View(), "RESULT") { + t.Fatalf("expected overall view to include RESULT header, got:\n%s", m.View()) + } +} + func TestReloadFromFileKeepsCanonicalConflictStructureWithMergedMarkers(t *testing.T) { if testing.Short() { t.Skip("skipping integration-style test in short mode") From 726e418fff3564da665eac6bec9c1f83ef5f0565 Mon Sep 17 00:00:00 2001 From: neo Date: Tue, 10 Mar 2026 13:41:10 +0900 Subject: [PATCH 8/8] Fix result-pane blank lines --- internal/engine/state.go | 52 +++++++++++++++++------------ internal/engine/state_test.go | 18 ++++++---- internal/tui/render_helpers.go | 6 ++++ internal/tui/render_helpers_test.go | 48 ++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 29 deletions(-) diff --git a/internal/engine/state.go b/internal/engine/state.go index 72113fa..3a9e6de 100644 --- a/internal/engine/state.go +++ b/internal/engine/state.go @@ -242,9 +242,14 @@ func (s *State) MergedLabels() ([]ConflictLabels, []bool) { func (s *State) ImportMerged(merged []byte) error { parsed, err := markers.Parse(merged) - if err == nil && len(parsed.Conflicts) == len(s.canonical.Conflicts) && s.canImportParsedDocument(parsed) { - s.importParsedDocument(parsed) - return nil + if err == nil && len(parsed.Conflicts) == len(s.canonical.Conflicts) && len(parsed.Segments) == len(s.canonical.Segments) { + if hasUnsafe, detail := s.findUnsafeParsedConflictReorder(parsed); hasUnsafe { + return fmt.Errorf("unsafe conflict reorder during import: %s", detail) + } + if s.canImportParsedDocument(parsed) { + s.importParsedDocument(parsed) + return nil + } } oldLines := markers.SplitLinesKeepEOL(s.RenderMerged()) @@ -318,11 +323,7 @@ func (s *State) canImportParsedDocument(doc markers.Document) bool { return false } case markers.ConflictSegment: - parsedConflict, ok := doc.Segments[i].(markers.ConflictSegment) - if !ok { - return false - } - if isAdjacentConflictSegment(s.canonical.Segments, i) && !sameConflictIdentity(seg, parsedConflict) { + if _, ok := doc.Segments[i].(markers.ConflictSegment); !ok { return false } } @@ -330,6 +331,27 @@ func (s *State) canImportParsedDocument(doc markers.Document) bool { return true } +func (s *State) findUnsafeParsedConflictReorder(doc markers.Document) (bool, string) { + for i, seg := range doc.Segments { + parsedConflict, ok := seg.(markers.ConflictSegment) + if !ok { + continue + } + if sameConflictIdentity(s.canonical.Segments[i], parsedConflict) { + continue + } + for canonicalIndex, canonicalSeg := range s.canonical.Segments { + if canonicalIndex == i { + continue + } + if sameConflictIdentity(canonicalSeg, parsedConflict) { + return true, fmt.Sprintf("parsed conflict at segment %d matches canonical segment %d", i, canonicalIndex) + } + } + } + return false, "" +} + func (s *State) importParsedDocument(doc markers.Document) { for i := range s.boundaries { s.boundaries[i] = nil @@ -474,20 +496,6 @@ func sameConflictIdentity(left markers.Segment, right markers.ConflictSegment) b return bytes.Equal(canonical.Ours, right.Ours) && bytes.Equal(canonical.Base, right.Base) && bytes.Equal(canonical.Theirs, right.Theirs) } -func isAdjacentConflictSegment(segments []markers.Segment, index int) bool { - if index > 0 { - if _, ok := segments[index-1].(markers.ConflictSegment); ok { - return true - } - } - if index+1 < len(segments) { - if _, ok := segments[index+1].(markers.ConflictSegment); ok { - return true - } - } - return false -} - func classifyConflictOutput(seg markers.ConflictSegment, output []byte) (markers.Resolution, bool, bool, ConflictLabels, bool) { both := append(append([][]byte{}, markers.SplitLinesKeepEOL(seg.Ours)...), markers.SplitLinesKeepEOL(seg.Theirs)...) bothBytes := joinLines(both) diff --git a/internal/engine/state_test.go b/internal/engine/state_test.go index 770cc64..916a91f 100644 --- a/internal/engine/state_test.go +++ b/internal/engine/state_test.go @@ -484,7 +484,7 @@ func TestImportMergedPreservesTextBetweenAdjacentConflictsAfterResolve(t *testin } } -func TestCanImportParsedDocumentRejectsReorderedConflicts(t *testing.T) { +func TestImportMergedRejectsReorderedSeparatedConflicts(t *testing.T) { input := []byte("<<<<<<< left-one\nours1\n=======\ntheirs1\n>>>>>>> right-one\n<<<<<<< left-two\nours2\n=======\ntheirs2\n>>>>>>> right-two\n") doc, err := markers.Parse(input) if err != nil { @@ -499,13 +499,17 @@ func TestCanImportParsedDocumentRejectsReorderedConflicts(t *testing.T) { if err != nil { t.Fatalf("Parse swapped failed: %v", err) } - if state.canImportParsedDocument(parsed) { - t.Fatal("canImportParsedDocument should reject reordered conflicts") + unsafe, detail := state.findUnsafeParsedConflictReorder(parsed) + if !unsafe { + t.Fatalf("findUnsafeParsedConflictReorder should detect reorder") } - if err := state.ImportMerged(swapped); err != nil { - t.Fatalf("ImportMerged failed: %v", err) + if detail == "" { + t.Fatalf("expected reorder detail") + } + if err := state.ImportMerged(swapped); err == nil { + t.Fatal("ImportMerged should reject reordered separated conflicts") } - if got := string(state.RenderMerged()); got != string(swapped) { - t.Fatalf("RenderMerged = %q, want %q", got, string(swapped)) + if got := string(state.RenderMerged()); got != string(input) { + t.Fatalf("RenderMerged = %q, want original %q", got, string(input)) } } diff --git a/internal/tui/render_helpers.go b/internal/tui/render_helpers.go index 0f29944..190d8be 100644 --- a/internal/tui/render_helpers.go +++ b/internal/tui/render_helpers.go @@ -442,6 +442,9 @@ func buildResultLines(doc markers.Document, highlightConflict int, selectedSide if index < 0 || index >= len(boundaryText) { return } + if len(boundaryText[index]) == 0 { + return + } boundaryLines := splitLines(boundaryText[index]) lines = append(lines, makeLineInfos(boundaryLines, categoryDefault, false, false, false, false, "")...) } @@ -569,6 +572,9 @@ func buildResultPreviewLines(doc markers.Document, selectedSide selectionSide, m if index < 0 || index >= len(boundaryText) { return } + if len(boundaryText[index]) == 0 { + return + } appendLines(splitLines(boundaryText[index])) } diff --git a/internal/tui/render_helpers_test.go b/internal/tui/render_helpers_test.go index 94ac02c..a0a330d 100644 --- a/internal/tui/render_helpers_test.go +++ b/internal/tui/render_helpers_test.go @@ -44,6 +44,25 @@ func TestBuildResultLinesManualResolved(t *testing.T) { } } +func TestBuildResultLinesSkipsEmptyBoundarySlots(t *testing.T) { + doc := markers.Document{ + Segments: []markers.Segment{ + markers.TextSegment{Bytes: []byte("start\n")}, + markers.ConflictSegment{Ours: []byte("ours\n"), Theirs: []byte("theirs\n")}, + markers.TextSegment{Bytes: []byte("end\n")}, + }, + Conflicts: []markers.ConflictRef{{SegmentIndex: 1}}, + } + + lines, _ := buildResultLines(doc, 0, selectedTheirs, nil, make([][]byte, len(doc.Segments)+1)) + if len(lines) != 3 { + t.Fatalf("lines len = %d, want 3", len(lines)) + } + if lines[0].text != "start" || lines[1].text != "theirs" || lines[2].text != "end" { + t.Fatalf("lines = %+v", lines) + } +} + func TestDiffEntriesCategories(t *testing.T) { base := []string{"line1", "line2"} side := []string{"line1", "line2-mod"} @@ -222,6 +241,35 @@ func TestBuildResultPreviewLinesUsesSelection(t *testing.T) { } } +func TestBuildResultPreviewLinesSkipsEmptyBoundarySlots(t *testing.T) { + doc := markers.Document{ + Segments: []markers.Segment{ + markers.TextSegment{Bytes: []byte("start\n")}, + markers.ConflictSegment{ + Ours: []byte("ours\n"), + Base: []byte("base\n"), + Theirs: []byte("theirs\n"), + }, + markers.TextSegment{Bytes: []byte("end\n")}, + }, + Conflicts: []markers.ConflictRef{{SegmentIndex: 1}}, + } + + lines, forced, ranges := buildResultPreviewLines(doc, selectedTheirs, nil, 0, make([][]byte, len(doc.Segments)+1)) + if len(forced) != 0 { + t.Fatalf("forced len = %d, want 0", len(forced)) + } + if len(ranges) != 1 { + t.Fatalf("ranges len = %d, want 1", len(ranges)) + } + if len(lines) != 3 { + t.Fatalf("lines len = %d, want 3", len(lines)) + } + if lines[0] != "start" || lines[1] != "theirs" || lines[2] != "end" { + t.Fatalf("lines = %v", lines) + } +} + func TestBuildResultPreviewLinesManualAndNone(t *testing.T) { doc := markers.Document{ Segments: []markers.Segment{