diff --git a/internal/engine/state.go b/internal/engine/state.go index 3a9e6de..49d6d46 100644 --- a/internal/engine/state.go +++ b/internal/engine/state.go @@ -433,6 +433,9 @@ func (c *conflictState) applyClassification(resolution markers.Resolution, unres c.manual = manual c.labelKnown = known if known { + if labels.BaseLabel == "" { + labels.BaseLabel = c.canonical.BaseLabel + } c.labels = labels } else { c.labels = ConflictLabels{ diff --git a/internal/engine/state_test.go b/internal/engine/state_test.go index 916a91f..c9c821d 100644 --- a/internal/engine/state_test.go +++ b/internal/engine/state_test.go @@ -484,6 +484,48 @@ func TestImportMergedPreservesTextBetweenAdjacentConflictsAfterResolve(t *testin } } +func TestImportMergedPreservesCanonicalBaseLabelForTwoWayConflict(t *testing.T) { + input := []byte("intro\n<<<<<<< HEAD\nours line\n||||||| base-commit\n=======\ntheirs line\n>>>>>>> feature\noutro\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("intro\n<<<<<<< ours-label\nours line\n=======\ntheirs line\n>>>>>>> theirs-label\noutro\n") + if err := state.ImportMerged(merged); err != nil { + t.Fatalf("ImportMerged failed: %v", err) + } + + updated := state.Document() + seg, ok := updated.Segments[updated.Conflicts[0].SegmentIndex].(markers.ConflictSegment) + if !ok { + t.Fatalf("segment is %T, want ConflictSegment", updated.Segments[updated.Conflicts[0].SegmentIndex]) + } + if len(seg.Base) != 0 { + t.Fatalf("Base = %q, want empty", string(seg.Base)) + } + if seg.BaseLabel != "base-commit" { + t.Fatalf("BaseLabel = %q, want %q", seg.BaseLabel, "base-commit") + } + if err := ValidateBaseCompleteness(updated); err != nil { + t.Fatalf("ValidateBaseCompleteness failed: %v", err) + } + + labels, known := state.MergedLabels() + if !known[0] { + t.Fatalf("MergedLabels known = false, want true") + } + if labels[0].OursLabel != "ours-label" || labels[0].TheirsLabel != "theirs-label" { + t.Fatalf("MergedLabels = %+v", labels[0]) + } + if labels[0].BaseLabel != "base-commit" { + t.Fatalf("MergedLabels BaseLabel = %q, want %q", labels[0].BaseLabel, "base-commit") + } +} + 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) diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 32cb0cb..beeef74 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -412,6 +412,7 @@ func shouldAllowMissingBaseFallback(ctx context.Context, opts cli.Options, valid if validationErr == nil || !strings.Contains(validationErr.Error(), "missing base chunk") { return false } + if !isTrulyMissingBasePath(opts.BasePath) { return false } diff --git a/internal/tui/tui_test.go b/internal/tui/tui_test.go index e2243a0..fc3e8ef 100644 --- a/internal/tui/tui_test.go +++ b/internal/tui/tui_test.go @@ -845,6 +845,67 @@ func TestReloadFromFileKeepsExistingUndoHistory(t *testing.T) { } } +func TestReloadFromFileAllowsTwoWayMergedConflictWhenCanonicalBaseLabelExists(t *testing.T) { + 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") + + if err := os.WriteFile(basePath, []byte("intro\noutro\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(localPath, []byte("intro\nours line\noutro\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(remotePath, []byte("intro\ntheirs line\noutro\n"), 0o644); err != nil { + t.Fatal(err) + } + mergedContent := "intro\n<<<<<<< ours-label\nours line\n=======\ntheirs line\n>>>>>>> theirs-label\noutro\n" + if err := os.WriteFile(mergedPath, []byte(mergedContent), 0o644); err != nil { + t.Fatal(err) + } + + diff3Bytes, err := gitmerge.MergeFileDiff3(ctx, localPath, basePath, remotePath) + if err != nil { + t.Fatalf("MergeFileDiff3 failed: %v", err) + } + doc, err := markers.Parse(diff3Bytes) + if err != nil { + t.Fatalf("Parse error = %v", err) + } + state, err := engine.NewState(doc) + 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: state, + doc: doc, + } + + if err := m.reloadFromFile(); err != nil { + t.Fatalf("reloadFromFile error = %v", err) + } + seg := conflictSegment(t, m.doc, 0) + if len(seg.Base) != 0 { + t.Fatalf("seg.Base = %q, want empty", string(seg.Base)) + } + if seg.BaseLabel == "" { + t.Fatal("seg.BaseLabel = empty, want preserved canonical base label") + } + 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]) + } +} + func TestModelInitReturnsNil(t *testing.T) { if cmd := (model{}).Init(); cmd != nil { t.Fatalf("Init() = %v, want nil", cmd)