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/engine/state.go b/internal/engine/state.go index 0ede146..3a9e6de 100644 --- a/internal/engine/state.go +++ b/internal/engine/state.go @@ -1,106 +1,647 @@ 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 renderSlotKind int + +const ( + slotBoundary renderSlotKind = iota + slotSegment +) + +type renderSlot struct { + kind renderSlotKind + index int +} + type State struct { - doc markers.Document + canonical markers.Document + segments []segmentState + boundaries [][]byte + 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, boundaries: make([][]byte, len(segments)+1)} + 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 nil + 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)) + 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...)} + 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 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 + 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) && 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 + } } - seg.Resolution = resolution - s.doc.Segments[ref.SegmentIndex] = seg + oldLines := markers.SplitLinesKeepEOL(s.RenderMerged()) + newLines := markers.SplitLinesKeepEOL(merged) + slots := s.renderSlots() + lineToSlot, boundarySlotAtCursor := s.slotLineOwnership(slots) + ops := diffLines(oldLines, newLines) + assigned := make([][][]byte, len(slots)) + oldCursor := 0 + pendingDeletedSlot := -1 + + for _, op := range ops { + switch op.kind { + case diffInsert: + target := pendingDeletedSlot + if target == -1 { + target = slotIndexAtCursor(lineToSlot, boundarySlotAtCursor, oldCursor) + } + if target == -1 { + target = 0 + } + assigned[target] = append(assigned[target], op.newLines...) + pendingDeletedSlot = -1 + case diffEqual: + for _, line := range op.newLines { + if oldCursor >= len(lineToSlot) { + break + } + target := lineToSlot[oldCursor] + assigned[target] = append(assigned[target], line) + oldCursor++ + } + pendingDeletedSlot = -1 + case diffDelete: + if len(op.oldLines) > 0 && oldCursor < len(lineToSlot) { + pendingDeletedSlot = lineToSlot[oldCursor] + } + oldCursor += len(op.oldLines) + } + } + for i, slot := range slots { + updated := joinLines(assigned[i]) + 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() + } + } + 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) +func (s *State) findUnsafeParsedConflictReorder(doc markers.Document) (bool, string) { + for i, seg := range doc.Segments { + parsedConflict, ok := seg.(markers.ConflictSegment) if !ok { - return fmt.Errorf("internal: conflict points to non-ConflictSegment") + 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 + } + 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) 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 slotIndex, slot := range slots { + lines := markers.SplitLinesKeepEOL(s.slotBytes(slot)) + start := cursor + for range lines { + lineToSlot = append(lineToSlot, slotIndex) + cursor++ + } + if slot.kind == slotBoundary { + for pos := start; pos <= cursor; pos++ { + boundarySlotAtCursor[pos] = slotIndex + } + } + } + return lineToSlot, boundarySlotAtCursor +} + +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 + } + 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, } } - if !hasChange { + 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 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 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 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 (s *State) Document() markers.Document { - return markers.CloneDocument(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 +} + +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..916a91f 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 @@ -408,3 +430,86 @@ 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 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 { + 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) + } + unsafe, detail := state.findUnsafeParsedConflictReorder(parsed) + if !unsafe { + t.Fatalf("findUnsafeParsedConflictReorder should detect reorder") + } + 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(input) { + t.Fatalf("RenderMerged = %q, want original %q", got, string(input)) + } +} 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/merge_apply_test.go b/internal/tui/merge_apply_test.go deleted file mode 100644 index 38cb4c0..0000000 --- a/internal/tui/merge_apply_test.go +++ /dev/null @@ -1,158 +0,0 @@ -package tui - -import ( - "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 TestApplyMergedResolutionsAlignmentFailure(t *testing.T) { - doc := parseSingleConflictDoc(t) - _, _, _, _, err := applyMergedResolutions(doc, []byte("ours\nend\n")) - if err == nil { - t.Fatalf("expected alignment error") - } -} - -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.go b/internal/tui/render_helpers.go index 8830c90..190d8be 100644 --- a/internal/tui/render_helpers.go +++ b/internal/tui/render_helpers.go @@ -433,12 +433,24 @@ 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 + } + if len(boundaryText[index]) == 0 { + 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) @@ -499,6 +511,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 } @@ -525,6 +547,7 @@ func buildResultLines(doc markers.Document, highlightConflict int, selectedSide } } + appendBoundary(segIndex + 1) } if currentStart == -1 { @@ -533,7 +556,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, boundaryText [][]byte) ([]string, map[int]lineCategory, []resultRange) { var lines []string forced := map[int]lineCategory{} ranges := make([]resultRange, 0, len(doc.Conflicts)) @@ -545,8 +568,18 @@ func buildResultPreviewLines(doc markers.Document, selectedSide selectionSide, m } lines = append(lines, newLines...) } + appendBoundary := func(index int) { + if index < 0 || index >= len(boundaryText) { + return + } + if len(boundaryText[index]) == 0 { + 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)) @@ -575,13 +608,20 @@ 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}) } + appendBoundary(segIndex + 1) } return lines, forced, ranges diff --git a/internal/tui/render_helpers_test.go b/internal/tui/render_helpers_test.go index 89877ba..a0a330d 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") } @@ -44,28 +44,22 @@ 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) +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}}, } - 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) + 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 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) + if lines[0].text != "start" || lines[1].text != "theirs" || lines[2].text != "end" { + t.Fatalf("lines = %+v", lines) } } @@ -232,7 +226,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, nil) if len(forced) != 0 { t.Fatalf("forced len = %d, want 0", len(forced)) } @@ -247,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{ @@ -268,18 +291,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, nil) 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 +316,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/session.go b/internal/tui/session.go new file mode 100644 index 0000000..89b35b7 --- /dev/null +++ b/internal/tui/session.go @@ -0,0 +1,63 @@ +package tui + +import ( + "context" + "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 + boundaryText [][]byte + manualResolved map[int][]byte + mergedLabels []conflictLabels + mergedLabelKnown []bool +} + +func loadResolverDocumentState(ctx context.Context, opts cli.Options) (resolverDocumentState, error) { + canonicalDoc, err := mergeview.LoadCanonicalDocument(ctx, opts) + if err != nil { + return resolverDocumentState{}, err + } + 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 + } + + if err := runtimeState.ImportMerged(mergedBytes); err != nil { + return resolverDocumentState{}, err + } + return buildResolverDocumentState(runtimeState), 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(), + boundaryText: state.BoundaryText(), + manualResolved: state.ManualResolved(), + mergedLabels: mergedLabels, + mergedLabelKnown: known, + } +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go index e64dd14..32cb0cb 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -8,7 +8,6 @@ import ( "os" "os/exec" "path/filepath" - "slices" "strings" "time" @@ -17,7 +16,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" ) @@ -174,6 +172,7 @@ type model struct { selectedSide selectionSide mergedLabels []conflictLabels mergedLabelKnown []bool + resultBoundaries [][]byte manualResolved map[int][]byte resolverUndo []resolverSnapshot resolverRedo []resolverSnapshot @@ -201,8 +200,7 @@ type conflictLabels struct { } type resolverSnapshot struct { - doc markers.Document - manualResolved map[int][]byte + state *engine.State } const ( @@ -215,30 +213,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) + return err } - // Parse conflicts - doc, err := markers.Parse(diff3Bytes) - if err != nil { - return fmt.Errorf("failed to parse conflicts: %w", 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 { @@ -252,17 +232,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, @@ -271,9 +246,10 @@ 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, + resultBoundaries: resolverState.boundaryText, + manualResolved: resolverState.manualResolved, pendingScroll: true, } @@ -335,12 +311,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" @@ -373,20 +344,16 @@ func (m *model) openEditor() tea.Cmd { } func (m *model) reloadFromFile() error { - editedBytes, err := os.ReadFile(m.opts.MergedPath) + mergedBytes, err := os.ReadFile(m.opts.MergedPath) if err != nil { - return fmt.Errorf("read edited file: %w", err) + return err } - - diff3Bytes, err := gitmerge.MergeFileDiff3(m.ctx, m.opts.LocalPath, m.opts.BasePath, m.opts.RemotePath) - if err != nil { - return fmt.Errorf("regenerate diff3 view: %w", err) + nextState := m.state.Clone() + if err := nextState.ImportMerged(mergedBytes); err != nil { + return err } - doc, err := markers.Parse(diff3Bytes) - if err != nil { - return fmt.Errorf("parse diff3 view: %w", err) - } + doc := nextState.Document() if !m.opts.AllowMissingBase { if err := engine.ValidateBaseCompleteness(doc); err != nil { @@ -398,17 +365,9 @@ 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.doc = m.state.Document() - m.manualResolved = manual - m.mergedLabels = labels - m.mergedLabelKnown = known + m.state = nextState + m.refreshResolverCaches() if m.currentConflict >= len(m.doc.Conflicts) { m.currentConflict = len(m.doc.Conflicts) - 1 @@ -490,11 +449,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 { @@ -789,8 +754,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 }) } @@ -800,8 +764,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 }) } @@ -811,8 +774,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 }) } @@ -974,7 +936,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 } @@ -1053,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) + 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) @@ -1154,10 +1116,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) @@ -1208,40 +1168,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 "" @@ -1298,6 +1224,22 @@ 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.resultBoundaries = m.state.BoundaryText() + 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 "" @@ -1368,187 +1310,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 - 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) - - case markers.ConflictSegment: - conflictIndex++ - nextTextLines := nextTextSegmentLines(doc.Segments, i+1) - nextIdx := -1 - if len(nextTextLines) > 0 { - nextIdx = findSubslice(mergedLines, pos, nextTextLines) - } - if nextIdx == -1 { - nextIdx = len(mergedLines) - } - if nextIdx < pos { - return doc, manualResolved, alignedLabels, alignedLabelKnown, fmt.Errorf("failed to align conflict segment") - } - spanLines := mergedLines[pos:nextIdx] - if containsConflictMarkers(spanLines) { - alignedLabels[conflictIndex] = labelsFromConflictSpan(spanLines) - alignedLabelKnown[conflictIndex] = true - pos = nextIdx - continue - } - resolution, matched := matchResolution(spanLines, s) - if matched { - s.Resolution = resolution - doc.Segments[i] = s - pos = nextIdx - continue - } - var manualBytes []byte - if len(spanLines) > 0 { - manualBytes = bytes.Join(spanLines, nil) - } - manualResolved[conflictIndex] = manualBytes - pos = nextIdx - } - } - - return doc, manualResolved, alignedLabels, alignedLabelKnown, nil -} - -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 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 f37bd63..aed63c2 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -18,8 +18,29 @@ 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 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'}}) @@ -100,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 { @@ -143,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 { @@ -156,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") { @@ -268,6 +294,321 @@ func TestReloadFromFilePreservesManualResolution(t *testing.T) { } } +func TestLoadResolverDocumentStateKeepsCanonicalConflictStructureWithMergedMarkers(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 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 line\n" { + t.Fatalf("seg.Theirs = %q", string(seg.Theirs)) + } + 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) + 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\n<<<<<<< ours\nlocal2\n=======\nremote2\n>>>>>>> theirs\nbottom\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)) + } + 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 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") + } + 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 := mergeview.LoadCanonicalDocument(ctx, cli.Options{ + BasePath: basePath, + LocalPath: localPath, + RemotePath: remotePath, + MergedPath: mergedPath, + }) + if err != nil { + t.Fatalf("LoadCanonicalDocument 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 line\n" { + t.Fatalf("seg.Ours = %q", string(seg.Ours)) + } + 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)) + } +} + func TestReloadFromFileKeepsExistingUndoHistory(t *testing.T) { if testing.Short() { t.Skip("skipping integration-style test in short mode") @@ -651,35 +992,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") { @@ -1569,16 +1881,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)