feat(merge): auto-resolve all field conflicts deterministically (bd-6l8)
Implement deterministic auto-resolve rules for all merge conflicts: - Title/Description: side with latest updated_at wins - Notes: concatenate both sides with separator - Priority: higher priority wins (lower number) - IssueType: local (left) wins - Status: closed wins (existing) Also fixed bug in isTimeAfter where invalid t1 incorrectly beat valid t2. Removed unused conflict generation code (hasConflict, makeConflict, makeConflictWithBase, issuesEqual, cmp import). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -33,8 +33,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
)
|
||||
|
||||
// Issue represents a beads issue with all possible fields
|
||||
@@ -289,12 +287,14 @@ func merge3Way(base, left, right []Issue) ([]Issue, []string) {
|
||||
result = append(result, merged)
|
||||
}
|
||||
} else if !inBase && inLeft && inRight {
|
||||
// Added in both - check if identical
|
||||
if issuesEqual(leftIssue, rightIssue) {
|
||||
result = append(result, leftIssue)
|
||||
} else {
|
||||
conflicts = append(conflicts, makeConflict(leftIssue.RawLine, rightIssue.RawLine))
|
||||
// Added in both - merge using deterministic rules with empty base
|
||||
emptyBase := Issue{
|
||||
ID: leftIssue.ID,
|
||||
CreatedAt: leftIssue.CreatedAt,
|
||||
CreatedBy: leftIssue.CreatedBy,
|
||||
}
|
||||
merged, _ := mergeIssue(emptyBase, leftIssue, rightIssue)
|
||||
result = append(result, merged)
|
||||
} else if inBase && inLeft && !inRight {
|
||||
// Deleted in right, maybe modified in left
|
||||
// RULE 2: deletion always wins over modification
|
||||
@@ -324,31 +324,22 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
|
||||
CreatedBy: base.CreatedBy,
|
||||
}
|
||||
|
||||
// Merge title
|
||||
result.Title = mergeField(base.Title, left.Title, right.Title)
|
||||
// Merge title - on conflict, side with latest updated_at wins
|
||||
result.Title = mergeFieldByUpdatedAt(base.Title, left.Title, right.Title, left.UpdatedAt, right.UpdatedAt)
|
||||
|
||||
// Merge description
|
||||
result.Description = mergeField(base.Description, left.Description, right.Description)
|
||||
// Merge description - on conflict, side with latest updated_at wins
|
||||
result.Description = mergeFieldByUpdatedAt(base.Description, left.Description, right.Description, left.UpdatedAt, right.UpdatedAt)
|
||||
|
||||
// Merge notes
|
||||
result.Notes = mergeField(base.Notes, left.Notes, right.Notes)
|
||||
// Merge notes - on conflict, concatenate both sides
|
||||
result.Notes = mergeNotes(base.Notes, left.Notes, right.Notes)
|
||||
|
||||
// Merge status - SPECIAL RULE: closed always wins over open
|
||||
result.Status = mergeStatus(base.Status, left.Status, right.Status)
|
||||
|
||||
// Merge priority (as int)
|
||||
if base.Priority == left.Priority && base.Priority != right.Priority {
|
||||
result.Priority = right.Priority
|
||||
} else if base.Priority == right.Priority && base.Priority != left.Priority {
|
||||
result.Priority = left.Priority
|
||||
} else if left.Priority == right.Priority {
|
||||
result.Priority = left.Priority
|
||||
} else {
|
||||
// Conflict - take left for now
|
||||
result.Priority = left.Priority
|
||||
}
|
||||
// Merge priority - on conflict, higher priority wins (lower number = more urgent)
|
||||
result.Priority = mergePriority(base.Priority, left.Priority, right.Priority)
|
||||
|
||||
// Merge issue_type
|
||||
// Merge issue_type - on conflict, local (left) wins
|
||||
result.IssueType = mergeField(base.IssueType, left.IssueType, right.IssueType)
|
||||
|
||||
// Merge updated_at - take the max
|
||||
@@ -365,11 +356,7 @@ func mergeIssue(base, left, right Issue) (Issue, string) {
|
||||
// Merge dependencies - combine and deduplicate
|
||||
result.Dependencies = mergeDependencies(left.Dependencies, right.Dependencies)
|
||||
|
||||
// Check if we have a real conflict
|
||||
if hasConflict(base, left, right) {
|
||||
return result, makeConflictWithBase(base.RawLine, left.RawLine, right.RawLine)
|
||||
}
|
||||
|
||||
// All field conflicts are now auto-resolved deterministically
|
||||
return result, ""
|
||||
}
|
||||
|
||||
@@ -391,10 +378,110 @@ func mergeField(base, left, right string) string {
|
||||
if base == right && base != left {
|
||||
return left
|
||||
}
|
||||
// Both changed to same value or no change
|
||||
// Both changed to same value or no change - left wins
|
||||
return left
|
||||
}
|
||||
|
||||
// mergeFieldByUpdatedAt resolves conflicts by picking the value from the side
|
||||
// with the latest updated_at timestamp
|
||||
func mergeFieldByUpdatedAt(base, left, right, leftUpdatedAt, rightUpdatedAt string) string {
|
||||
// Standard 3-way merge for non-conflict cases
|
||||
if base == left && base != right {
|
||||
return right
|
||||
}
|
||||
if base == right && base != left {
|
||||
return left
|
||||
}
|
||||
if left == right {
|
||||
return left
|
||||
}
|
||||
// True conflict: both sides changed to different values
|
||||
// Pick the value from the side with the latest updated_at
|
||||
if isTimeAfter(leftUpdatedAt, rightUpdatedAt) {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
// mergeNotes handles notes merging - on conflict, concatenate both sides
|
||||
func mergeNotes(base, left, right string) string {
|
||||
// Standard 3-way merge for non-conflict cases
|
||||
if base == left && base != right {
|
||||
return right
|
||||
}
|
||||
if base == right && base != left {
|
||||
return left
|
||||
}
|
||||
if left == right {
|
||||
return left
|
||||
}
|
||||
// True conflict: both sides changed to different values - concatenate
|
||||
if left == "" {
|
||||
return right
|
||||
}
|
||||
if right == "" {
|
||||
return left
|
||||
}
|
||||
return left + "\n\n---\n\n" + right
|
||||
}
|
||||
|
||||
// mergePriority handles priority merging - on conflict, higher priority wins (lower number)
|
||||
func mergePriority(base, left, right int) int {
|
||||
// Standard 3-way merge for non-conflict cases
|
||||
if base == left && base != right {
|
||||
return right
|
||||
}
|
||||
if base == right && base != left {
|
||||
return left
|
||||
}
|
||||
if left == right {
|
||||
return left
|
||||
}
|
||||
// True conflict: both sides changed to different values
|
||||
// Higher priority wins (lower number = more urgent)
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
// isTimeAfter returns true if t1 is after t2
|
||||
func isTimeAfter(t1, t2 string) bool {
|
||||
if t1 == "" {
|
||||
return false
|
||||
}
|
||||
if t2 == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
time1, err1 := time.Parse(time.RFC3339Nano, t1)
|
||||
if err1 != nil {
|
||||
time1, err1 = time.Parse(time.RFC3339, t1)
|
||||
}
|
||||
|
||||
time2, err2 := time.Parse(time.RFC3339Nano, t2)
|
||||
if err2 != nil {
|
||||
time2, err2 = time.Parse(time.RFC3339, t2)
|
||||
}
|
||||
|
||||
// Handle parse errors consistently with maxTime:
|
||||
// - Valid timestamp beats invalid
|
||||
// - If both invalid, prefer left (t1) for consistency
|
||||
if err1 != nil && err2 != nil {
|
||||
return true // both invalid, prefer left
|
||||
}
|
||||
if err1 != nil {
|
||||
return false // t1 invalid, t2 valid - t2 wins
|
||||
}
|
||||
if err2 != nil {
|
||||
return true // t1 valid, t2 invalid - t1 wins
|
||||
}
|
||||
|
||||
// Both valid - compare. On exact tie, return false (right wins for now)
|
||||
// TODO: Consider preferring left on tie for consistency with IssueType rule
|
||||
return time1.After(time2)
|
||||
}
|
||||
|
||||
func maxTime(t1, t2 string) string {
|
||||
if t1 == "" && t2 == "" {
|
||||
return ""
|
||||
@@ -459,62 +546,3 @@ func mergeDependencies(left, right []Dependency) []Dependency {
|
||||
return result
|
||||
}
|
||||
|
||||
func hasConflict(base, left, right Issue) bool {
|
||||
// Check if any field has conflicting changes
|
||||
if base.Title != left.Title && base.Title != right.Title && left.Title != right.Title {
|
||||
return true
|
||||
}
|
||||
if base.Description != left.Description && base.Description != right.Description && left.Description != right.Description {
|
||||
return true
|
||||
}
|
||||
if base.Notes != left.Notes && base.Notes != right.Notes && left.Notes != right.Notes {
|
||||
return true
|
||||
}
|
||||
if base.Status != left.Status && base.Status != right.Status && left.Status != right.Status {
|
||||
return true
|
||||
}
|
||||
if base.Priority != left.Priority && base.Priority != right.Priority && left.Priority != right.Priority {
|
||||
return true
|
||||
}
|
||||
if base.IssueType != left.IssueType && base.IssueType != right.IssueType && left.IssueType != right.IssueType {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func issuesEqual(a, b Issue) bool {
|
||||
// Use go-cmp for deep equality comparison, ignoring RawLine field
|
||||
return cmp.Equal(a, b, cmp.FilterPath(func(p cmp.Path) bool {
|
||||
return p.String() == "RawLine"
|
||||
}, cmp.Ignore()))
|
||||
}
|
||||
|
||||
func makeConflict(left, right string) string {
|
||||
conflict := "<<<<<<< left\n"
|
||||
if left != "" {
|
||||
conflict += left + "\n"
|
||||
}
|
||||
conflict += "=======\n"
|
||||
if right != "" {
|
||||
conflict += right + "\n"
|
||||
}
|
||||
conflict += ">>>>>>> right\n"
|
||||
return conflict
|
||||
}
|
||||
|
||||
func makeConflictWithBase(base, left, right string) string {
|
||||
conflict := "<<<<<<< left\n"
|
||||
if left != "" {
|
||||
conflict += left + "\n"
|
||||
}
|
||||
conflict += "||||||| base\n"
|
||||
if base != "" {
|
||||
conflict += base + "\n"
|
||||
}
|
||||
conflict += "=======\n"
|
||||
if right != "" {
|
||||
conflict += right + "\n"
|
||||
}
|
||||
conflict += ">>>>>>> right\n"
|
||||
return conflict
|
||||
}
|
||||
|
||||
@@ -298,6 +298,80 @@ func TestMaxTime(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsTimeAfter tests timestamp comparison including error handling
|
||||
func TestIsTimeAfter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
t1 string
|
||||
t2 string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "both empty - prefer left",
|
||||
t1: "",
|
||||
t2: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "t1 empty - t2 wins",
|
||||
t1: "",
|
||||
t2: "2024-01-02T00:00:00Z",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "t2 empty - t1 wins",
|
||||
t1: "2024-01-01T00:00:00Z",
|
||||
t2: "",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "t1 newer",
|
||||
t1: "2024-01-02T00:00:00Z",
|
||||
t2: "2024-01-01T00:00:00Z",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "t2 newer",
|
||||
t1: "2024-01-01T00:00:00Z",
|
||||
t2: "2024-01-02T00:00:00Z",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "identical timestamps - right wins (false)",
|
||||
t1: "2024-01-01T00:00:00Z",
|
||||
t2: "2024-01-01T00:00:00Z",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "t1 invalid, t2 valid - t2 wins",
|
||||
t1: "not-a-timestamp",
|
||||
t2: "2024-01-01T00:00:00Z",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "t1 valid, t2 invalid - t1 wins",
|
||||
t1: "2024-01-01T00:00:00Z",
|
||||
t2: "not-a-timestamp",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "both invalid - prefer left",
|
||||
t1: "invalid1",
|
||||
t2: "invalid2",
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isTimeAfter(tt.t1, tt.t2)
|
||||
if result != tt.expected {
|
||||
t.Errorf("isTimeAfter(%q, %q) = %v, want %v", tt.t1, tt.t2, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMerge3Way_SimpleUpdates tests simple field update scenarios
|
||||
func TestMerge3Way_SimpleUpdates(t *testing.T) {
|
||||
base := []Issue{
|
||||
@@ -409,52 +483,54 @@ func TestMerge3Way_SimpleUpdates(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestMerge3Way_Conflicts tests conflict detection
|
||||
func TestMerge3Way_Conflicts(t *testing.T) {
|
||||
t.Run("conflicting title changes", func(t *testing.T) {
|
||||
// TestMerge3Way_AutoResolve tests auto-resolution of conflicts
|
||||
func TestMerge3Way_AutoResolve(t *testing.T) {
|
||||
t.Run("conflicting title changes - latest updated_at wins", func(t *testing.T) {
|
||||
base := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
Title: "Original",
|
||||
UpdatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","title":"Original","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
RawLine: `{"id":"bd-abc123","title":"Original","updated_at":"2024-01-01T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
left := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
Title: "Left version",
|
||||
UpdatedAt: "2024-01-02T00:00:00Z", // Older
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","title":"Left version","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
RawLine: `{"id":"bd-abc123","title":"Left version","updated_at":"2024-01-02T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
right := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
Title: "Right version",
|
||||
UpdatedAt: "2024-01-03T00:00:00Z", // Newer - this should win
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","title":"Right version","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
RawLine: `{"id":"bd-abc123","title":"Right version","updated_at":"2024-01-03T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
|
||||
result, conflicts := merge3Way(base, left, right)
|
||||
if len(conflicts) == 0 {
|
||||
t.Error("expected conflict for divergent title changes")
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected no merged issues with conflict, got %d", len(result))
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
||||
}
|
||||
if len(conflicts) > 0 {
|
||||
if conflicts[0] == "" {
|
||||
t.Error("conflict marker should not be empty")
|
||||
}
|
||||
// Right has newer updated_at, so right's title wins
|
||||
if result[0].Title != "Right version" {
|
||||
t.Errorf("expected title 'Right version' (newer updated_at), got %q", result[0].Title)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("conflicting priority changes", func(t *testing.T) {
|
||||
t.Run("conflicting priority changes - higher priority wins (lower number)", func(t *testing.T) {
|
||||
base := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
@@ -467,16 +543,16 @@ func TestMerge3Way_Conflicts(t *testing.T) {
|
||||
left := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
Priority: 0,
|
||||
Priority: 3, // Lower priority (higher number)
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","priority":0,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
RawLine: `{"id":"bd-abc123","priority":3,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
right := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
Priority: 1,
|
||||
Priority: 1, // Higher priority (lower number) - this should win
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","priority":1,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
@@ -484,11 +560,100 @@ func TestMerge3Way_Conflicts(t *testing.T) {
|
||||
}
|
||||
|
||||
result, conflicts := merge3Way(base, left, right)
|
||||
if len(conflicts) == 0 {
|
||||
t.Error("expected conflict for divergent priority changes")
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected no merged issues with conflict, got %d", len(result))
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
||||
}
|
||||
// Lower priority number wins
|
||||
if result[0].Priority != 1 {
|
||||
t.Errorf("expected priority 1 (higher priority), got %d", result[0].Priority)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("conflicting notes - concatenated", func(t *testing.T) {
|
||||
base := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
Notes: "Original notes",
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","notes":"Original notes","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
left := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
Notes: "Left notes",
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","notes":"Left notes","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
right := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
Notes: "Right notes",
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","notes":"Right notes","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
|
||||
result, conflicts := merge3Way(base, left, right)
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
||||
}
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
||||
}
|
||||
// Notes should be concatenated
|
||||
expectedNotes := "Left notes\n\n---\n\nRight notes"
|
||||
if result[0].Notes != expectedNotes {
|
||||
t.Errorf("expected notes %q, got %q", expectedNotes, result[0].Notes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("conflicting issue_type - local (left) wins", func(t *testing.T) {
|
||||
base := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
IssueType: "task",
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","issue_type":"task","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
left := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
IssueType: "bug", // Local change - should win
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","issue_type":"bug","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
right := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
IssueType: "feature",
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","issue_type":"feature","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
|
||||
result, conflicts := merge3Way(base, left, right)
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
||||
}
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
||||
}
|
||||
// Local (left) wins for issue_type
|
||||
if result[0].IssueType != "bug" {
|
||||
t.Errorf("expected issue_type 'bug' (local wins), got %q", result[0].IssueType)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -679,33 +844,39 @@ func TestMerge3Way_Additions(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("added in both with different content - conflict", func(t *testing.T) {
|
||||
t.Run("added in both with different content - auto-resolved", func(t *testing.T) {
|
||||
base := []Issue{}
|
||||
left := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
Title: "Left version",
|
||||
UpdatedAt: "2024-01-02T00:00:00Z", // Older
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","title":"Left version","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
RawLine: `{"id":"bd-abc123","title":"Left version","updated_at":"2024-01-02T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
right := []Issue{
|
||||
{
|
||||
ID: "bd-abc123",
|
||||
Title: "Right version",
|
||||
UpdatedAt: "2024-01-03T00:00:00Z", // Newer - should win
|
||||
CreatedAt: "2024-01-01T00:00:00Z",
|
||||
CreatedBy: "user1",
|
||||
RawLine: `{"id":"bd-abc123","title":"Right version","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
RawLine: `{"id":"bd-abc123","title":"Right version","updated_at":"2024-01-03T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
|
||||
},
|
||||
}
|
||||
|
||||
result, conflicts := merge3Way(base, left, right)
|
||||
if len(conflicts) == 0 {
|
||||
t.Error("expected conflict for different additions")
|
||||
if len(conflicts) != 0 {
|
||||
t.Errorf("expected no conflicts with auto-resolution, got %d", len(conflicts))
|
||||
}
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected no merged issues with conflict, got %d", len(result))
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 merged issue, got %d", len(result))
|
||||
}
|
||||
// Right has newer updated_at, so right's title wins
|
||||
if result[0].Title != "Right version" {
|
||||
t.Errorf("expected title 'Right version' (newer updated_at), got %q", result[0].Title)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user