Files
beads/internal/merge/merge_test.go
quartz 563f7e875d fix(merge): preserve close_reason field during merge/sync (GH#891)
The close_reason and closed_by_session fields were being silently dropped
during 3-way merge operations because the simplified Issue struct in
internal/merge/merge.go was missing these fields.

Changes:
- Add CloseReason and ClosedBySession fields to merge.Issue struct
- Implement merge logic that preserves these fields when status is closed
- Use timestamp-based conflict resolution (later closed_at wins)
- Clear close metadata when status becomes non-closed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:33:32 -08:00

2725 lines
79 KiB
Go

package merge
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
)
// TestMergeStatus tests the status merging logic with special rules
func TestMergeStatus(t *testing.T) {
tests := []struct {
name string
base string
left string
right string
expected string
}{
{
name: "no changes",
base: "open",
left: "open",
right: "open",
expected: "open",
},
{
name: "left closed, right open - closed wins",
base: "open",
left: "closed",
right: "open",
expected: "closed",
},
{
name: "left open, right closed - closed wins",
base: "open",
left: "open",
right: "closed",
expected: "closed",
},
{
name: "both closed",
base: "open",
left: "closed",
right: "closed",
expected: "closed",
},
{
name: "base closed, left open, right open - open (standard merge)",
base: "closed",
left: "open",
right: "open",
expected: "open",
},
{
name: "base closed, left open, right closed - closed wins",
base: "closed",
left: "open",
right: "closed",
expected: "closed",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mergeStatus(tt.base, tt.left, tt.right)
if result != tt.expected {
t.Errorf("mergeStatus(%q, %q, %q) = %q, want %q",
tt.base, tt.left, tt.right, result, tt.expected)
}
})
}
}
// TestMergeField tests the basic field merging logic
func TestMergeField(t *testing.T) {
tests := []struct {
name string
base string
left string
right string
expected string
}{
{
name: "no changes",
base: "original",
left: "original",
right: "original",
expected: "original",
},
{
name: "left changed",
base: "original",
left: "left-changed",
right: "original",
expected: "left-changed",
},
{
name: "right changed",
base: "original",
left: "original",
right: "right-changed",
expected: "right-changed",
},
{
name: "both changed to same value",
base: "original",
left: "both-changed",
right: "both-changed",
expected: "both-changed",
},
{
name: "both changed to different values - prefers left",
base: "original",
left: "left-value",
right: "right-value",
expected: "left-value",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mergeField(tt.base, tt.left, tt.right)
if result != tt.expected {
t.Errorf("mergeField() = %q, want %q", result, tt.expected)
}
})
}
}
// TestMergeDependencies tests 3-way dependency merge with removal semantics (bd-ndye)
func TestMergeDependencies(t *testing.T) {
tests := []struct {
name string
base []Dependency
left []Dependency
right []Dependency
expected []Dependency
}{
{
name: "empty all sides",
base: []Dependency{},
left: []Dependency{},
right: []Dependency{},
expected: []Dependency{},
},
{
name: "left adds dep (not in base)",
base: []Dependency{},
left: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
right: []Dependency{},
expected: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
},
{
name: "right adds dep (not in base)",
base: []Dependency{},
left: []Dependency{},
right: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
},
expected: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
},
},
{
name: "both add different deps (not in base)",
base: []Dependency{},
left: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
right: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
},
expected: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"},
},
},
{
name: "both add same dep (not in base) - no duplicates",
base: []Dependency{},
left: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
right: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-02T00:00:00Z"},
},
expected: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"}, // Left preferred
},
},
{
name: "left removes dep from base - REMOVAL WINS",
base: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
left: []Dependency{}, // Left removed it
right: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
expected: []Dependency{}, // Should be empty - removal wins
},
{
name: "right removes dep from base - REMOVAL WINS",
base: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
left: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
right: []Dependency{}, // Right removed it
expected: []Dependency{}, // Should be empty - removal wins
},
{
name: "both keep dep from base",
base: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
left: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
right: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-02T00:00:00Z"},
},
expected: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
},
{
name: "complex: left removes one, right adds one",
base: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
},
left: []Dependency{}, // Left removed bd-2
right: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks", CreatedAt: "2024-01-01T00:00:00Z"},
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"}, // Right added bd-3
},
expected: []Dependency{
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "related", CreatedAt: "2024-01-01T00:00:00Z"}, // Only the new one
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mergeDependencies(tt.base, tt.left, tt.right)
if len(result) != len(tt.expected) {
t.Errorf("mergeDependencies() returned %d deps, want %d", len(result), len(tt.expected))
return
}
// Check each expected dep is present
for _, exp := range tt.expected {
found := false
for _, res := range result {
if res.IssueID == exp.IssueID &&
res.DependsOnID == exp.DependsOnID &&
res.Type == exp.Type {
found = true
break
}
}
if !found {
t.Errorf("expected dependency %+v not found in result", exp)
}
}
})
}
}
// TestMaxTime tests timestamp merging (max wins)
func TestMaxTime(t *testing.T) {
tests := []struct {
name string
t1 string
t2 string
expected string
}{
{
name: "both empty",
t1: "",
t2: "",
expected: "",
},
{
name: "t1 empty",
t1: "",
t2: "2024-01-02T00:00:00Z",
expected: "2024-01-02T00:00:00Z",
},
{
name: "t2 empty",
t1: "2024-01-01T00:00:00Z",
t2: "",
expected: "2024-01-01T00:00:00Z",
},
{
name: "t1 newer",
t1: "2024-01-02T00:00:00Z",
t2: "2024-01-01T00:00:00Z",
expected: "2024-01-02T00:00:00Z",
},
{
name: "t2 newer",
t1: "2024-01-01T00:00:00Z",
t2: "2024-01-02T00:00:00Z",
expected: "2024-01-02T00:00:00Z",
},
{
name: "identical timestamps",
t1: "2024-01-01T00:00:00Z",
t2: "2024-01-01T00:00:00Z",
expected: "2024-01-01T00:00:00Z",
},
{
name: "with fractional seconds (RFC3339Nano)",
t1: "2024-01-01T00:00:00.123456Z",
t2: "2024-01-01T00:00:00.123455Z",
expected: "2024-01-01T00:00:00.123456Z",
},
{
name: "invalid timestamps - returns t2 as fallback",
t1: "invalid",
t2: "also-invalid",
expected: "also-invalid",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := maxTime(tt.t1, tt.t2)
if result != tt.expected {
t.Errorf("maxTime() = %q, want %q", result, tt.expected)
}
})
}
}
// 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 - left wins (bd-8nz)",
t1: "2024-01-01T00:00:00Z",
t2: "2024-01-01T00:00:00Z",
expected: true,
},
{
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{
{
ID: "bd-abc123",
Title: "Original title",
Status: "open",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Original title","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
t.Run("left updates title", func(t *testing.T) {
left := []Issue{
{
ID: "bd-abc123",
Title: "Updated title",
Status: "open",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Updated title","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}`,
},
}
right := base
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Title != "Updated title" {
t.Errorf("expected title 'Updated title', got %q", result[0].Title)
}
})
t.Run("right updates status", func(t *testing.T) {
left := base
right := []Issue{
{
ID: "bd-abc123",
Title: "Original title",
Status: "in_progress",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Original title","status":"in_progress","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}`,
},
}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Status != "in_progress" {
t.Errorf("expected status 'in_progress', got %q", result[0].Status)
}
})
t.Run("both update different fields", func(t *testing.T) {
left := []Issue{
{
ID: "bd-abc123",
Title: "Updated title",
Status: "open",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Updated title","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}`,
},
}
right := []Issue{
{
ID: "bd-abc123",
Title: "Original title",
Status: "in_progress",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Original title","status":"in_progress","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}`,
},
}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Title != "Updated title" {
t.Errorf("expected title 'Updated title', got %q", result[0].Title)
}
if result[0].Status != "in_progress" {
t.Errorf("expected status 'in_progress', got %q", result[0].Status)
}
})
}
// TestMergePriority tests priority merging including bd-d0t fix
func TestMergePriority(t *testing.T) {
tests := []struct {
name string
base int
left int
right int
expected int
}{
{
name: "no changes",
base: 2,
left: 2,
right: 2,
expected: 2,
},
{
name: "left changed",
base: 2,
left: 1,
right: 2,
expected: 1,
},
{
name: "right changed",
base: 2,
left: 2,
right: 3,
expected: 3,
},
{
name: "both changed to same value",
base: 2,
left: 1,
right: 1,
expected: 1,
},
{
name: "conflict - higher priority wins (lower number)",
base: 2,
left: 3,
right: 1,
expected: 1,
},
// bd-d0t fix: 0 is treated as "unset"
{
name: "bd-d0t: left unset (0), right has explicit priority",
base: 2,
left: 0,
right: 3,
expected: 3, // explicit priority wins over unset
},
{
name: "bd-d0t: left has explicit priority, right unset (0)",
base: 2,
left: 3,
right: 0,
expected: 3, // explicit priority wins over unset
},
{
name: "bd-d0t: both unset (0)",
base: 2,
left: 0,
right: 0,
expected: 0,
},
{
name: "bd-d0t: base unset, left sets priority, right unchanged",
base: 0,
left: 1,
right: 0,
expected: 1, // left changed from 0 to 1
},
{
name: "bd-d0t: base unset, right sets priority, left unchanged",
base: 0,
left: 0,
right: 2,
expected: 2, // right changed from 0 to 2
},
// bd-1kf fix: negative priorities should be handled consistently
{
name: "bd-1kf: negative priority should win over unset (0)",
base: 2,
left: 0,
right: -1,
expected: -1, // negative priority is explicit, should win over unset
},
{
name: "bd-1kf: negative priority on left should win over unset (0) on right",
base: 2,
left: -1,
right: 0,
expected: -1, // negative priority is explicit, should win over unset
},
{
name: "bd-1kf: conflict between negative priorities - lower wins",
base: 2,
left: -2,
right: -1,
expected: -2, // -2 is higher priority (more urgent) than -1
},
{
name: "bd-1kf: negative vs positive priority conflict",
base: 2,
left: -1,
right: 1,
expected: -1, // -1 is higher priority (lower number) than 1
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mergePriority(tt.base, tt.left, tt.right)
if result != tt.expected {
t.Errorf("mergePriority(%d, %d, %d) = %d, want %d",
tt.base, tt.left, tt.right, result, tt.expected)
}
})
}
}
// 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","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","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","updated_at":"2024-01-03T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
result, conflicts := merge3Way(base, left, right, false)
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))
}
// 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 - higher priority wins (lower number)", func(t *testing.T) {
base := []Issue{
{
ID: "bd-abc123",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
left := []Issue{
{
ID: "bd-abc123",
Priority: 3, // Lower priority (higher number)
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","priority":3,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
right := []Issue{
{
ID: "bd-abc123",
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"}`,
},
}
result, conflicts := merge3Way(base, left, right, false)
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))
}
// 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, false)
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, false)
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)
}
})
}
// TestMerge3Way_Deletions tests deletion detection scenarios
func TestMerge3Way_Deletions(t *testing.T) {
t.Run("deleted in left, unchanged in right", func(t *testing.T) {
base := []Issue{
{
ID: "bd-abc123",
Title: "Will be deleted",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Will be deleted","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
left := []Issue{} // Deleted in left
right := base // Unchanged in right
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 0 {
t.Errorf("expected deletion to be accepted, got %d issues", len(result))
}
})
t.Run("deleted in right, unchanged in left", func(t *testing.T) {
base := []Issue{
{
ID: "bd-abc123",
Title: "Will be deleted",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Will be deleted","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
left := base // Unchanged in left
right := []Issue{} // Deleted in right
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 0 {
t.Errorf("expected deletion to be accepted, got %d issues", len(result))
}
})
t.Run("deleted in left, modified in right - deletion wins", func(t *testing.T) {
base := []Issue{
{
ID: "bd-abc123",
Title: "Original",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Original","status":"open","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
left := []Issue{} // Deleted in left
right := []Issue{ // Modified in right
{
ID: "bd-abc123",
Title: "Modified",
Status: "in_progress",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Modified","status":"in_progress","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("expected no conflicts, got %d", len(conflicts))
}
if len(result) != 0 {
t.Errorf("expected deletion to win (0 results), got %d", len(result))
}
})
t.Run("deleted in right, modified in left - deletion wins", func(t *testing.T) {
base := []Issue{
{
ID: "bd-abc123",
Title: "Original",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Original","status":"open","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
left := []Issue{ // Modified in left
{
ID: "bd-abc123",
Title: "Modified",
Status: "in_progress",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"Modified","status":"in_progress","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
right := []Issue{} // Deleted in right
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("expected no conflicts, got %d", len(conflicts))
}
if len(result) != 0 {
t.Errorf("expected deletion to win (0 results), got %d", len(result))
}
})
}
// TestMerge3Way_Additions tests issue addition scenarios
func TestMerge3Way_Additions(t *testing.T) {
t.Run("added only in left", func(t *testing.T) {
base := []Issue{}
left := []Issue{
{
ID: "bd-abc123",
Title: "New issue",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"New issue","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
right := []Issue{}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Title != "New issue" {
t.Errorf("expected title 'New issue', got %q", result[0].Title)
}
})
t.Run("added only in right", func(t *testing.T) {
base := []Issue{}
left := []Issue{}
right := []Issue{
{
ID: "bd-abc123",
Title: "New issue",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"New issue","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Title != "New issue" {
t.Errorf("expected title 'New issue', got %q", result[0].Title)
}
})
t.Run("added in both with identical content", func(t *testing.T) {
base := []Issue{}
issueData := Issue{
ID: "bd-abc123",
Title: "New issue",
Status: "open",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-abc123","title":"New issue","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
}
left := []Issue{issueData}
right := []Issue{issueData}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
})
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","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","updated_at":"2024-01-03T00:00:00Z","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
result, conflicts := merge3Way(base, left, right, false)
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))
}
// 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)
}
})
}
// TestMerge3Way_ResurrectionPrevention tests bd-hv01 regression
func TestMerge3Way_ResurrectionPrevention(t *testing.T) {
t.Run("bd-pq5k: no invalid state (status=open with closed_at)", func(t *testing.T) {
// Simulate the broken merge case that was creating invalid data
// Base: issue is closed
base := []Issue{
{
ID: "bd-test",
Title: "Test issue",
Status: "closed",
ClosedAt: "2024-01-02T00:00:00Z",
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-test","title":"Test issue","status":"closed","closed_at":"2024-01-02T00:00:00Z","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}`,
},
}
// Left: still closed with closed_at
left := base
// Right: somehow got reopened but WITHOUT removing closed_at (the bug scenario)
right := []Issue{
{
ID: "bd-test",
Title: "Test issue",
Status: "open", // reopened
ClosedAt: "", // correctly removed
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-03T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-test","title":"Test issue","status":"open","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-03T00:00:00Z","created_by":"user1"}`,
},
}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
// CRITICAL: Status should be closed (closed wins over open)
if result[0].Status != "closed" {
t.Errorf("expected status 'closed', got %q", result[0].Status)
}
// CRITICAL: If status is closed, closed_at MUST be set
if result[0].Status == "closed" && result[0].ClosedAt == "" {
t.Error("INVALID STATE: status='closed' but closed_at is empty")
}
// CRITICAL: If status is open, closed_at MUST be empty
if result[0].Status == "open" && result[0].ClosedAt != "" {
t.Errorf("INVALID STATE: status='open' but closed_at='%s'", result[0].ClosedAt)
}
})
t.Run("bd-hv01 regression: closed issue not resurrected", func(t *testing.T) {
// Base: issue is open
base := []Issue{
{
ID: "bd-hv01",
Title: "Test issue",
Status: "open",
ClosedAt: "",
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-hv01","title":"Test issue","status":"open","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","created_by":"user1"}`,
},
}
// Left: issue is closed (newer)
left := []Issue{
{
ID: "bd-hv01",
Title: "Test issue",
Status: "closed",
ClosedAt: "2024-01-02T00:00:00Z",
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
CreatedBy: "user1",
RawLine: `{"id":"bd-hv01","title":"Test issue","status":"closed","closed_at":"2024-01-02T00:00:00Z","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}`,
},
}
// Right: issue is still open (stale)
right := base
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
// Issue should remain closed (left's version)
if result[0].Status != "closed" {
t.Errorf("expected status 'closed', got %q - issue was resurrected!", result[0].Status)
}
if result[0].ClosedAt == "" {
t.Error("expected closed_at to be set, got empty string")
}
// UpdatedAt should be the max (left's newer timestamp)
if result[0].UpdatedAt != "2024-01-02T00:00:00Z" {
t.Errorf("expected updated_at '2024-01-02T00:00:00Z', got %q", result[0].UpdatedAt)
}
})
}
// TestMerge3Way_Integration tests full merge scenarios with file I/O
func TestMerge3Way_Integration(t *testing.T) {
t.Run("full merge workflow", func(t *testing.T) {
tmpDir := t.TempDir()
// Create test files
baseFile := filepath.Join(tmpDir, "base.jsonl")
leftFile := filepath.Join(tmpDir, "left.jsonl")
rightFile := filepath.Join(tmpDir, "right.jsonl")
outputFile := filepath.Join(tmpDir, "output.jsonl")
// Base: two issues
baseData := `{"id":"bd-1","title":"Issue 1","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
{"id":"bd-2","title":"Issue 2","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
`
if err := os.WriteFile(baseFile, []byte(baseData), 0644); err != nil {
t.Fatalf("failed to write base file: %v", err)
}
// Left: update bd-1 title, add bd-3
leftData := `{"id":"bd-1","title":"Updated Issue 1","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}
{"id":"bd-2","title":"Issue 2","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
{"id":"bd-3","title":"New Issue 3","status":"open","priority":1,"created_at":"2024-01-02T00:00:00Z","created_by":"user1"}
`
if err := os.WriteFile(leftFile, []byte(leftData), 0644); err != nil {
t.Fatalf("failed to write left file: %v", err)
}
// Right: update bd-2 status, add bd-4
rightData := `{"id":"bd-1","title":"Issue 1","status":"open","priority":2,"created_at":"2024-01-01T00:00:00Z","created_by":"user1"}
{"id":"bd-2","title":"Issue 2","status":"in_progress","priority":2,"created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-02T00:00:00Z","created_by":"user1"}
{"id":"bd-4","title":"New Issue 4","status":"open","priority":3,"created_at":"2024-01-02T00:00:00Z","created_by":"user1"}
`
if err := os.WriteFile(rightFile, []byte(rightData), 0644); err != nil {
t.Fatalf("failed to write right file: %v", err)
}
// Perform merge
err := Merge3Way(outputFile, baseFile, leftFile, rightFile, false)
if err != nil {
t.Fatalf("merge failed: %v", err)
}
// Read result
content, err := os.ReadFile(outputFile)
if err != nil {
t.Fatalf("failed to read output file: %v", err)
}
// Parse result
var results []Issue
for _, line := range splitLines(string(content)) {
if line == "" {
continue
}
var issue Issue
if err := json.Unmarshal([]byte(line), &issue); err != nil {
t.Fatalf("failed to parse output line: %v", err)
}
results = append(results, issue)
}
// Should have 4 issues: bd-1 (updated), bd-2 (updated), bd-3 (new), bd-4 (new)
if len(results) != 4 {
t.Fatalf("expected 4 issues, got %d", len(results))
}
// Verify bd-1 has updated title from left
found1 := false
for _, issue := range results {
if issue.ID == "bd-1" {
found1 = true
if issue.Title != "Updated Issue 1" {
t.Errorf("bd-1 title: expected 'Updated Issue 1', got %q", issue.Title)
}
}
}
if !found1 {
t.Error("bd-1 not found in results")
}
// Verify bd-2 has updated status from right
found2 := false
for _, issue := range results {
if issue.ID == "bd-2" {
found2 = true
if issue.Status != "in_progress" {
t.Errorf("bd-2 status: expected 'in_progress', got %q", issue.Status)
}
}
}
if !found2 {
t.Error("bd-2 not found in results")
}
})
}
// TestIsTombstone tests the tombstone detection helper
func TestIsTombstone(t *testing.T) {
tests := []struct {
name string
status string
expected bool
}{
{
name: "tombstone status",
status: "tombstone",
expected: true,
},
{
name: "open status",
status: "open",
expected: false,
},
{
name: "closed status",
status: "closed",
expected: false,
},
{
name: "in_progress status",
status: "in_progress",
expected: false,
},
{
name: "empty status",
status: "",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
issue := Issue{Status: tt.status}
result := IsTombstone(issue)
if result != tt.expected {
t.Errorf("IsTombstone() = %v, want %v", result, tt.expected)
}
})
}
}
// TestMergeTombstones tests merging two tombstones
func TestMergeTombstones(t *testing.T) {
tests := []struct {
name string
leftDeletedAt string
rightDeletedAt string
expectedSide string // "left" or "right"
}{
{
name: "left deleted later",
leftDeletedAt: "2024-01-02T00:00:00Z",
rightDeletedAt: "2024-01-01T00:00:00Z",
expectedSide: "left",
},
{
name: "right deleted later",
leftDeletedAt: "2024-01-01T00:00:00Z",
rightDeletedAt: "2024-01-02T00:00:00Z",
expectedSide: "right",
},
{
name: "same timestamp - left wins (tie breaker)",
leftDeletedAt: "2024-01-01T00:00:00Z",
rightDeletedAt: "2024-01-01T00:00:00Z",
expectedSide: "left",
},
{
name: "with fractional seconds",
leftDeletedAt: "2024-01-01T00:00:00.123456Z",
rightDeletedAt: "2024-01-01T00:00:00.123455Z",
expectedSide: "left",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
left := Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: tt.leftDeletedAt,
DeletedBy: "user-left",
}
right := Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: tt.rightDeletedAt,
DeletedBy: "user-right",
}
result := mergeTombstones(left, right)
if tt.expectedSide == "left" && result.DeletedBy != "user-left" {
t.Errorf("expected left tombstone to win, got right")
}
if tt.expectedSide == "right" && result.DeletedBy != "user-right" {
t.Errorf("expected right tombstone to win, got left")
}
})
}
}
// TestMerge3Way_TombstoneVsLive tests tombstone vs live issue scenarios
func TestMerge3Way_TombstoneVsLive(t *testing.T) {
// Base issue (live)
baseIssue := Issue{
ID: "bd-abc123",
Title: "Original title",
Status: "open",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
}
// Recent tombstone (not expired)
recentTombstone := Issue{
ID: "bd-abc123",
Title: "Original title",
Status: StatusTombstone,
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
CreatedBy: "user1",
DeletedAt: time.Now().Add(-24 * time.Hour).Format(time.RFC3339), // 1 day ago
DeletedBy: "user2",
DeleteReason: "Duplicate issue",
OriginalType: "task",
}
// Expired tombstone (older than TTL)
expiredTombstone := Issue{
ID: "bd-abc123",
Title: "Original title",
Status: StatusTombstone,
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-02T00:00:00Z",
CreatedBy: "user1",
DeletedAt: time.Now().Add(-60 * 24 * time.Hour).Format(time.RFC3339), // 60 days ago
DeletedBy: "user2",
DeleteReason: "Duplicate issue",
OriginalType: "task",
}
// Modified live issue
modifiedLive := Issue{
ID: "bd-abc123",
Title: "Updated title",
Status: "in_progress",
Priority: 1,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-03T00:00:00Z",
CreatedBy: "user1",
}
t.Run("recent tombstone in left wins over live in right", func(t *testing.T) {
base := []Issue{baseIssue}
left := []Issue{recentTombstone}
right := []Issue{modifiedLive}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Status != StatusTombstone {
t.Errorf("expected tombstone to win, got status %q", result[0].Status)
}
})
t.Run("recent tombstone in right wins over live in left", func(t *testing.T) {
base := []Issue{baseIssue}
left := []Issue{modifiedLive}
right := []Issue{recentTombstone}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Status != StatusTombstone {
t.Errorf("expected tombstone to win, got status %q", result[0].Status)
}
})
t.Run("expired tombstone in left loses to live in right (resurrection)", func(t *testing.T) {
base := []Issue{baseIssue}
left := []Issue{expiredTombstone}
right := []Issue{modifiedLive}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Status != "in_progress" {
t.Errorf("expected live issue to win over expired tombstone, got status %q", result[0].Status)
}
if result[0].Title != "Updated title" {
t.Errorf("expected live issue's title, got %q", result[0].Title)
}
})
t.Run("expired tombstone in right loses to live in left (resurrection)", func(t *testing.T) {
base := []Issue{baseIssue}
left := []Issue{modifiedLive}
right := []Issue{expiredTombstone}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Status != "in_progress" {
t.Errorf("expected live issue to win over expired tombstone, got status %q", result[0].Status)
}
})
}
// TestMerge3Way_TombstoneVsTombstone tests merging two tombstones
func TestMerge3Way_TombstoneVsTombstone(t *testing.T) {
baseIssue := Issue{
ID: "bd-abc123",
Title: "Original title",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
}
t.Run("later tombstone wins", func(t *testing.T) {
leftTombstone := Issue{
ID: "bd-abc123",
Title: "Original title",
Status: StatusTombstone,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
DeletedAt: "2024-01-02T00:00:00Z",
DeletedBy: "user-left",
DeleteReason: "Left reason",
}
rightTombstone := Issue{
ID: "bd-abc123",
Title: "Original title",
Status: StatusTombstone,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
DeletedAt: "2024-01-03T00:00:00Z", // Later
DeletedBy: "user-right",
DeleteReason: "Right reason",
}
base := []Issue{baseIssue}
left := []Issue{leftTombstone}
right := []Issue{rightTombstone}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].DeletedBy != "user-right" {
t.Errorf("expected right tombstone to win (later deleted_at), got DeletedBy %q", result[0].DeletedBy)
}
if result[0].DeleteReason != "Right reason" {
t.Errorf("expected right tombstone's reason, got %q", result[0].DeleteReason)
}
})
}
// TestMerge3Way_TombstoneNoBase tests tombstone scenarios without a base
func TestMerge3Way_TombstoneNoBase(t *testing.T) {
t.Run("tombstone added only in left", func(t *testing.T) {
tombstone := Issue{
ID: "bd-abc123",
Title: "New tombstone",
Status: StatusTombstone,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
DeletedAt: "2024-01-02T00:00:00Z",
DeletedBy: "user1",
}
result, conflicts := merge3Way([]Issue{}, []Issue{tombstone}, []Issue{}, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Status != StatusTombstone {
t.Errorf("expected tombstone, got status %q", result[0].Status)
}
})
t.Run("tombstone added only in right", func(t *testing.T) {
tombstone := Issue{
ID: "bd-abc123",
Title: "New tombstone",
Status: StatusTombstone,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
DeletedAt: "2024-01-02T00:00:00Z",
DeletedBy: "user1",
}
result, conflicts := merge3Way([]Issue{}, []Issue{}, []Issue{tombstone}, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Status != StatusTombstone {
t.Errorf("expected tombstone, got status %q", result[0].Status)
}
})
t.Run("tombstone in left vs live in right (no base)", func(t *testing.T) {
recentTombstone := Issue{
ID: "bd-abc123",
Title: "Issue",
Status: StatusTombstone,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
DeletedAt: time.Now().Add(-24 * time.Hour).Format(time.RFC3339),
DeletedBy: "user1",
}
live := Issue{
ID: "bd-abc123",
Title: "Issue",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
}
result, conflicts := merge3Way([]Issue{}, []Issue{recentTombstone}, []Issue{live}, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
// Recent tombstone should win
if result[0].Status != StatusTombstone {
t.Errorf("expected tombstone to win, got status %q", result[0].Status)
}
})
}
// TestMerge3WayWithTTL tests the TTL-configurable merge function
func TestMerge3WayWithTTL(t *testing.T) {
baseIssue := Issue{
ID: "bd-abc123",
Title: "Original",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
}
// Tombstone deleted 10 days ago
tombstone := Issue{
ID: "bd-abc123",
Title: "Original",
Status: StatusTombstone,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
DeletedAt: time.Now().Add(-10 * 24 * time.Hour).Format(time.RFC3339),
DeletedBy: "user2",
}
liveIssue := Issue{
ID: "bd-abc123",
Title: "Updated",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
}
t.Run("with short TTL tombstone is expired", func(t *testing.T) {
// 7 day TTL + 1 hour grace = tombstone (10 days old) is expired
shortTTL := 7 * 24 * time.Hour
base := []Issue{baseIssue}
left := []Issue{tombstone}
right := []Issue{liveIssue}
result, _ := Merge3WayWithTTL(base, left, right, shortTTL, false)
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
// With short TTL, tombstone is expired, live issue wins
if result[0].Status != "open" {
t.Errorf("expected live issue to win with short TTL, got status %q", result[0].Status)
}
})
t.Run("with long TTL tombstone is not expired", func(t *testing.T) {
// 30 day TTL = tombstone (10 days old) is NOT expired
longTTL := 30 * 24 * time.Hour
base := []Issue{baseIssue}
left := []Issue{tombstone}
right := []Issue{liveIssue}
result, _ := Merge3WayWithTTL(base, left, right, longTTL, false)
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
// With long TTL, tombstone is NOT expired, tombstone wins
if result[0].Status != StatusTombstone {
t.Errorf("expected tombstone to win with long TTL, got status %q", result[0].Status)
}
})
}
// TestMergeStatus_Tombstone tests status merging with tombstone
func TestMergeStatus_Tombstone(t *testing.T) {
tests := []struct {
name string
base string
left string
right string
expected string
}{
{
name: "tombstone in left wins over open in right",
base: "open",
left: StatusTombstone,
right: "open",
expected: StatusTombstone,
},
{
name: "tombstone in right wins over open in left",
base: "open",
left: "open",
right: StatusTombstone,
expected: StatusTombstone,
},
{
name: "tombstone in left wins over closed in right",
base: "open",
left: StatusTombstone,
right: "closed",
expected: StatusTombstone,
},
{
name: "both tombstone",
base: "open",
left: StatusTombstone,
right: StatusTombstone,
expected: StatusTombstone,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := mergeStatus(tt.base, tt.left, tt.right)
if result != tt.expected {
t.Errorf("mergeStatus(%q, %q, %q) = %q, want %q",
tt.base, tt.left, tt.right, result, tt.expected)
}
})
}
}
// TestMerge3Way_TombstoneWithImplicitDeletion tests bd-ki14 fix:
// tombstones should be preserved even when the other side implicitly deleted
func TestMerge3Way_TombstoneWithImplicitDeletion(t *testing.T) {
baseIssue := Issue{
ID: "bd-abc123",
Title: "Original",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
}
tombstone := Issue{
ID: "bd-abc123",
Title: "Original",
Status: StatusTombstone,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
DeletedAt: time.Now().Add(-24 * time.Hour).Format(time.RFC3339),
DeletedBy: "user2",
DeleteReason: "Duplicate",
}
t.Run("bd-ki14: tombstone in left preserved when right implicitly deleted", func(t *testing.T) {
base := []Issue{baseIssue}
left := []Issue{tombstone}
right := []Issue{} // Implicitly deleted in right
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue (tombstone preserved), got %d", len(result))
}
if result[0].Status != StatusTombstone {
t.Errorf("expected tombstone to be preserved, got status %q", result[0].Status)
}
if result[0].DeletedBy != "user2" {
t.Errorf("expected tombstone fields preserved, got DeletedBy %q", result[0].DeletedBy)
}
})
t.Run("bd-ki14: tombstone in right preserved when left implicitly deleted", func(t *testing.T) {
base := []Issue{baseIssue}
left := []Issue{} // Implicitly deleted in left
right := []Issue{tombstone}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue (tombstone preserved), got %d", len(result))
}
if result[0].Status != StatusTombstone {
t.Errorf("expected tombstone to be preserved, got status %q", result[0].Status)
}
})
t.Run("bd-ki14: live issue in left still deleted when right implicitly deleted", func(t *testing.T) {
base := []Issue{baseIssue}
modifiedLive := Issue{
ID: "bd-abc123",
Title: "Modified",
Status: "in_progress",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
}
left := []Issue{modifiedLive}
right := []Issue{} // Implicitly deleted in right
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
// Live issue should be deleted (implicit deletion wins for non-tombstones)
if len(result) != 0 {
t.Errorf("expected implicit deletion to win for live issue, got %d results", len(result))
}
})
}
// TestMergeTombstones_EmptyDeletedAt tests bd-6x5 fix:
// handling empty DeletedAt timestamps in tombstone merging
func TestMergeTombstones_EmptyDeletedAt(t *testing.T) {
tests := []struct {
name string
leftDeletedAt string
rightDeletedAt string
expectedSide string // "left" or "right"
}{
{
name: "bd-6x5: both empty - left wins as tie-breaker",
leftDeletedAt: "",
rightDeletedAt: "",
expectedSide: "left",
},
{
name: "bd-6x5: left empty, right valid - right wins",
leftDeletedAt: "",
rightDeletedAt: "2024-01-01T00:00:00Z",
expectedSide: "right",
},
{
name: "bd-6x5: left valid, right empty - left wins",
leftDeletedAt: "2024-01-01T00:00:00Z",
rightDeletedAt: "",
expectedSide: "left",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
left := Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: tt.leftDeletedAt,
DeletedBy: "user-left",
}
right := Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: tt.rightDeletedAt,
DeletedBy: "user-right",
}
result := mergeTombstones(left, right)
if tt.expectedSide == "left" && result.DeletedBy != "user-left" {
t.Errorf("expected left tombstone to win, got DeletedBy %q", result.DeletedBy)
}
if tt.expectedSide == "right" && result.DeletedBy != "user-right" {
t.Errorf("expected right tombstone to win, got DeletedBy %q", result.DeletedBy)
}
})
}
}
// TestMergeIssue_TombstoneFields tests bd-1sn fix:
// tombstone fields should be copied when status becomes tombstone via safety fallback
func TestMergeIssue_TombstoneFields(t *testing.T) {
t.Run("bd-1sn: tombstone fields copied from left when tombstone via mergeStatus", func(t *testing.T) {
base := Issue{
ID: "bd-test",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
}
left := Issue{
ID: "bd-test",
Status: StatusTombstone,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
DeletedAt: "2024-01-02T00:00:00Z",
DeletedBy: "user2",
DeleteReason: "Duplicate",
OriginalType: "task",
}
right := Issue{
ID: "bd-test",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
}
result, _ := mergeIssue(base, left, right)
if result.Status != StatusTombstone {
t.Errorf("expected tombstone status, got %q", result.Status)
}
if result.DeletedAt != "2024-01-02T00:00:00Z" {
t.Errorf("expected DeletedAt to be copied, got %q", result.DeletedAt)
}
if result.DeletedBy != "user2" {
t.Errorf("expected DeletedBy to be copied, got %q", result.DeletedBy)
}
if result.DeleteReason != "Duplicate" {
t.Errorf("expected DeleteReason to be copied, got %q", result.DeleteReason)
}
if result.OriginalType != "task" {
t.Errorf("expected OriginalType to be copied, got %q", result.OriginalType)
}
})
t.Run("bd-1sn: tombstone fields copied from right when it has later deleted_at", func(t *testing.T) {
base := Issue{
ID: "bd-test",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
}
left := Issue{
ID: "bd-test",
Status: StatusTombstone,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
DeletedAt: "2024-01-02T00:00:00Z",
DeletedBy: "user-left",
DeleteReason: "Left reason",
}
right := Issue{
ID: "bd-test",
Status: StatusTombstone,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
DeletedAt: "2024-01-03T00:00:00Z", // Later
DeletedBy: "user-right",
DeleteReason: "Right reason",
}
result, _ := mergeIssue(base, left, right)
if result.Status != StatusTombstone {
t.Errorf("expected tombstone status, got %q", result.Status)
}
// Right has later deleted_at, so right's fields should be used
if result.DeletedBy != "user-right" {
t.Errorf("expected DeletedBy from right (later), got %q", result.DeletedBy)
}
if result.DeleteReason != "Right reason" {
t.Errorf("expected DeleteReason from right, got %q", result.DeleteReason)
}
})
}
// TestIsExpiredTombstone tests edge cases for the IsExpiredTombstone function (bd-fmo)
func TestIsExpiredTombstone(t *testing.T) {
now := time.Now()
tests := []struct {
name string
issue Issue
ttl time.Duration
expected bool
}{
{
name: "non-tombstone returns false",
issue: Issue{
ID: "bd-test",
Status: "open",
DeletedAt: now.Add(-100 * 24 * time.Hour).Format(time.RFC3339),
},
ttl: 24 * time.Hour,
expected: false,
},
{
name: "closed status returns false",
issue: Issue{
ID: "bd-test",
Status: "closed",
DeletedAt: now.Add(-100 * 24 * time.Hour).Format(time.RFC3339),
},
ttl: 24 * time.Hour,
expected: false,
},
{
name: "tombstone with empty deleted_at returns false",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: "",
},
ttl: 24 * time.Hour,
expected: false,
},
{
name: "tombstone with invalid timestamp returns false (safety)",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: "not-a-valid-date",
},
ttl: 24 * time.Hour,
expected: false,
},
{
name: "tombstone with malformed RFC3339 returns false",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: "2024-13-45T99:99:99Z",
},
ttl: 24 * time.Hour,
expected: false,
},
{
name: "recent tombstone (within TTL) returns false",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: now.Add(-1 * time.Hour).Format(time.RFC3339),
},
ttl: 24 * time.Hour,
expected: false,
},
{
name: "old tombstone (beyond TTL) returns true",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: now.Add(-48 * time.Hour).Format(time.RFC3339),
},
ttl: 24 * time.Hour,
expected: true,
},
{
name: "tombstone just inside TTL boundary (with clock skew grace) returns false",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: now.Add(-24 * time.Hour).Format(time.RFC3339),
},
ttl: 24 * time.Hour,
expected: false,
},
{
name: "tombstone just past TTL boundary (with clock skew grace) returns true",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: now.Add(-26 * time.Hour).Format(time.RFC3339),
},
ttl: 24 * time.Hour,
expected: true,
},
{
name: "ttl=0 falls back to DefaultTombstoneTTL (30 days)",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: now.Add(-20 * 24 * time.Hour).Format(time.RFC3339),
},
ttl: 0,
expected: false,
},
{
name: "ttl=0 with old tombstone (beyond default TTL) returns true",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: now.Add(-60 * 24 * time.Hour).Format(time.RFC3339),
},
ttl: 0,
expected: true,
},
{
name: "RFC3339Nano format is supported",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: now.Add(-48 * time.Hour).Format(time.RFC3339Nano),
},
ttl: 24 * time.Hour,
expected: true,
},
{
name: "very short TTL (1 minute) works correctly",
issue: Issue{
ID: "bd-test",
Status: StatusTombstone,
DeletedAt: now.Add(-2 * time.Hour).Format(time.RFC3339),
},
ttl: 1 * time.Minute,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := IsExpiredTombstone(tt.issue, tt.ttl)
if result != tt.expected {
t.Errorf("IsExpiredTombstone() = %v, want %v (deleted_at=%q, ttl=%v)",
result, tt.expected, tt.issue.DeletedAt, tt.ttl)
}
})
}
}
// TestMerge3Way_TombstoneBaseBothLiveResurrection tests the scenario where
// the base version is a tombstone but both left and right have live versions.
// This can happen if Clone A deletes an issue, Clones B and C sync (getting tombstone),
// then both B and C independently recreate an issue with same ID. (bd-bob)
func TestMerge3Way_TombstoneBaseBothLiveResurrection(t *testing.T) {
// Base is a tombstone (issue was deleted)
baseTombstone := Issue{
ID: "bd-abc123",
Title: "Original title",
Status: StatusTombstone,
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-05T00:00:00Z",
CreatedBy: "user1",
DeletedAt: time.Now().Add(-10 * 24 * time.Hour).Format(time.RFC3339), // 10 days ago
DeletedBy: "user2",
DeleteReason: "Obsolete",
OriginalType: "task",
}
// Left resurrects the issue with new content
leftLive := Issue{
ID: "bd-abc123",
Title: "Resurrected by left",
Status: "open",
Priority: 2,
IssueType: "task",
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-10T00:00:00Z", // Left is older
CreatedBy: "user1",
}
// Right also resurrects with different content
rightLive := Issue{
ID: "bd-abc123",
Title: "Resurrected by right",
Status: "in_progress",
Priority: 1, // Higher priority (lower number)
IssueType: "bug",
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-15T00:00:00Z", // Right is newer
CreatedBy: "user1",
}
t.Run("both sides resurrect with different content - standard merge applies", func(t *testing.T) {
base := []Issue{baseTombstone}
left := []Issue{leftLive}
right := []Issue{rightLive}
result, conflicts := merge3Way(base, left, right, false)
// Should not have conflicts - merge rules apply
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
merged := result[0]
// Issue should be live (not tombstone)
if merged.Status == StatusTombstone {
t.Error("expected live issue after both sides resurrected, got tombstone")
}
// Title: right wins because it has later UpdatedAt
if merged.Title != "Resurrected by right" {
t.Errorf("expected title from right (later UpdatedAt), got %q", merged.Title)
}
// Priority: higher priority wins (lower number = more urgent)
if merged.Priority != 1 {
t.Errorf("expected priority 1 (higher), got %d", merged.Priority)
}
// Status: standard 3-way merge applies. When both sides changed from base,
// left wins (standard merge conflict resolution). Note: status does NOT use
// UpdatedAt tiebreaker like title does - it uses mergeField which picks left.
if merged.Status != "open" {
t.Errorf("expected status 'open' from left (both changed from base), got %q", merged.Status)
}
// Tombstone fields should NOT be present on merged result
if merged.DeletedAt != "" {
t.Errorf("expected empty DeletedAt on resurrected issue, got %q", merged.DeletedAt)
}
if merged.DeletedBy != "" {
t.Errorf("expected empty DeletedBy on resurrected issue, got %q", merged.DeletedBy)
}
})
t.Run("both resurrect with same status - no conflict", func(t *testing.T) {
leftOpen := leftLive
leftOpen.Status = "open"
rightOpen := rightLive
rightOpen.Status = "open"
base := []Issue{baseTombstone}
left := []Issue{leftOpen}
right := []Issue{rightOpen}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
if result[0].Status != "open" {
t.Errorf("expected status 'open', got %q", result[0].Status)
}
})
t.Run("one side closes after resurrection", func(t *testing.T) {
// Left resurrects and keeps open
leftOpen := leftLive
leftOpen.Status = "open"
// Right resurrects and then closes
rightClosed := rightLive
rightClosed.Status = "closed"
rightClosed.ClosedAt = "2024-01-16T00:00:00Z"
base := []Issue{baseTombstone}
left := []Issue{leftOpen}
right := []Issue{rightClosed}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 1 {
t.Fatalf("expected 1 issue, got %d", len(result))
}
// Closed should win over open
if result[0].Status != "closed" {
t.Errorf("expected closed to win over open, got %q", result[0].Status)
}
})
}
// TestMerge3Way_TombstoneVsLiveTimestampPrecisionMismatch tests bd-ncwo:
// When the same issue has different CreatedAt timestamp precision (e.g., with/without nanoseconds),
// the tombstone should still win over the live version.
func TestMerge3Way_TombstoneVsLiveTimestampPrecisionMismatch(t *testing.T) {
// This test simulates the ghost resurrection bug where timestamp precision
// differences caused the same issue to be treated as two different issues.
// The key fix (bd-ncwo) adds ID-based fallback matching when keys don't match.
t.Run("tombstone wins despite different CreatedAt precision", func(t *testing.T) {
// Base: issue with status=closed
baseIssue := Issue{
ID: "bd-ghost1",
Title: "Original title",
Status: "closed",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z", // No fractional seconds
UpdatedAt: "2024-01-10T00:00:00Z",
CreatedBy: "user1",
}
// Left: tombstone with DIFFERENT timestamp precision (has microseconds)
tombstone := Issue{
ID: "bd-ghost1",
Title: "(deleted)",
Status: StatusTombstone,
Priority: 2,
CreatedAt: "2024-01-01T00:00:00.000000Z", // WITH fractional seconds
UpdatedAt: "2024-01-15T00:00:00Z",
CreatedBy: "user1",
DeletedAt: time.Now().Add(-24 * time.Hour).Format(time.RFC3339),
DeletedBy: "user2",
DeleteReason: "Duplicate issue",
}
// Right: same closed issue (same precision as base)
closedIssue := Issue{
ID: "bd-ghost1",
Title: "Original title",
Status: "closed",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z", // No fractional seconds
UpdatedAt: "2024-01-12T00:00:00Z",
CreatedBy: "user1",
}
base := []Issue{baseIssue}
left := []Issue{tombstone}
right := []Issue{closedIssue}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
// CRITICAL: Should have exactly 1 issue, not 2 (no duplicates)
if len(result) != 1 {
t.Fatalf("expected 1 issue (no duplicates), got %d - this suggests ID-based matching failed", len(result))
}
// Tombstone should win over closed
if result[0].Status != StatusTombstone {
t.Errorf("expected tombstone to win, got status %q", result[0].Status)
}
if result[0].DeletedBy != "user2" {
t.Errorf("expected tombstone fields preserved, got DeletedBy %q", result[0].DeletedBy)
}
})
t.Run("tombstone wins with CreatedBy mismatch", func(t *testing.T) {
// Test case where CreatedBy differs (e.g., empty vs populated)
tombstone := Issue{
ID: "bd-ghost2",
Title: "(deleted)",
Status: StatusTombstone,
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "", // Empty CreatedBy
DeletedAt: time.Now().Add(-24 * time.Hour).Format(time.RFC3339),
DeletedBy: "user2",
DeleteReason: "Cleanup",
}
closedIssue := Issue{
ID: "bd-ghost2",
Title: "Original title",
Status: "closed",
Priority: 2,
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1", // Non-empty CreatedBy
}
base := []Issue{}
left := []Issue{tombstone}
right := []Issue{closedIssue}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
// Should have exactly 1 issue
if len(result) != 1 {
t.Fatalf("expected 1 issue (no duplicates), got %d", len(result))
}
// Tombstone should win
if result[0].Status != StatusTombstone {
t.Errorf("expected tombstone to win despite CreatedBy mismatch, got status %q", result[0].Status)
}
})
t.Run("no duplicates when both have same ID but different keys", func(t *testing.T) {
// Ensure we don't create duplicate entries
liveLeft := Issue{
ID: "bd-ghost3",
Title: "Left version",
Status: "open",
CreatedAt: "2024-01-01T00:00:00.123456Z", // With nanoseconds
CreatedBy: "user1",
}
liveRight := Issue{
ID: "bd-ghost3",
Title: "Right version",
Status: "in_progress",
CreatedAt: "2024-01-01T00:00:00Z", // Without nanoseconds
CreatedBy: "user1",
}
base := []Issue{}
left := []Issue{liveLeft}
right := []Issue{liveRight}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
// CRITICAL: Should have exactly 1 issue, not 2
if len(result) != 1 {
t.Fatalf("expected 1 issue (no duplicates for same ID), got %d", len(result))
}
})
}
// TestMerge3Way_DeterministicOutputOrder verifies that merge output is sorted by ID
// for consistent, reproducible results regardless of input order or map iteration.
// This is important for:
// - Reproducible git diffs between merges
// - Cross-machine consistency
// - Matching bd export behavior
func TestMerge3Way_DeterministicOutputOrder(t *testing.T) {
// Create issues with IDs that would appear in different orders
// if map iteration order determined output order
issueA := Issue{ID: "beads-aaa", Title: "A", Status: "open", CreatedAt: "2024-01-01T00:00:00Z"}
issueB := Issue{ID: "beads-bbb", Title: "B", Status: "open", CreatedAt: "2024-01-02T00:00:00Z"}
issueC := Issue{ID: "beads-ccc", Title: "C", Status: "open", CreatedAt: "2024-01-03T00:00:00Z"}
issueZ := Issue{ID: "beads-zzz", Title: "Z", Status: "open", CreatedAt: "2024-01-04T00:00:00Z"}
issueM := Issue{ID: "beads-mmm", Title: "M", Status: "open", CreatedAt: "2024-01-05T00:00:00Z"}
t.Run("output is sorted by ID", func(t *testing.T) {
// Input in arbitrary (non-sorted) order
base := []Issue{}
left := []Issue{issueZ, issueA, issueM}
right := []Issue{issueC, issueB}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("unexpected conflicts: %v", conflicts)
}
if len(result) != 5 {
t.Fatalf("expected 5 issues, got %d", len(result))
}
// Verify output is sorted by ID
expectedOrder := []string{"beads-aaa", "beads-bbb", "beads-ccc", "beads-mmm", "beads-zzz"}
for i, expected := range expectedOrder {
if result[i].ID != expected {
t.Errorf("result[%d].ID = %q, want %q", i, result[i].ID, expected)
}
}
})
t.Run("deterministic across multiple runs", func(t *testing.T) {
// Run merge multiple times to verify consistent ordering
base := []Issue{}
left := []Issue{issueZ, issueA, issueM}
right := []Issue{issueC, issueB}
var firstRunIDs []string
for run := 0; run < 10; run++ {
result, _ := merge3Way(base, left, right, false)
var ids []string
for _, issue := range result {
ids = append(ids, issue.ID)
}
if run == 0 {
firstRunIDs = ids
} else {
// Compare to first run
for i, id := range ids {
if id != firstRunIDs[i] {
t.Errorf("run %d: result[%d].ID = %q, want %q (non-deterministic output)", run, i, id, firstRunIDs[i])
}
}
}
}
})
}
// TestMerge3Way_CloseReasonPreservation tests that close_reason and closed_by_session
// are preserved during merge/sync operations (GH#891)
func TestMerge3Way_CloseReasonPreservation(t *testing.T) {
t.Run("close_reason preserved when both sides closed - later closed_at wins", func(t *testing.T) {
base := []Issue{
{
ID: "bd-close1",
Title: "Test Issue",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
left := []Issue{
{
ID: "bd-close1",
Title: "Test Issue",
Status: "closed",
ClosedAt: "2024-01-02T00:00:00Z", // Earlier
CloseReason: "Fixed in commit abc",
ClosedBySession: "session-left",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
right := []Issue{
{
ID: "bd-close1",
Title: "Test Issue",
Status: "closed",
ClosedAt: "2024-01-03T00:00:00Z", // Later - should win
CloseReason: "Fixed in commit xyz",
ClosedBySession: "session-right",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("expected no conflicts, got %d", len(conflicts))
}
if len(result) != 1 {
t.Fatalf("expected 1 merged issue, got %d", len(result))
}
// Right has later closed_at, so right's close_reason should win
if result[0].CloseReason != "Fixed in commit xyz" {
t.Errorf("expected close_reason 'Fixed in commit xyz', got %q", result[0].CloseReason)
}
if result[0].ClosedBySession != "session-right" {
t.Errorf("expected closed_by_session 'session-right', got %q", result[0].ClosedBySession)
}
})
t.Run("close_reason preserved when left has later closed_at", func(t *testing.T) {
base := []Issue{
{
ID: "bd-close2",
Title: "Test Issue",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
left := []Issue{
{
ID: "bd-close2",
Title: "Test Issue",
Status: "closed",
ClosedAt: "2024-01-03T00:00:00Z", // Later - should win
CloseReason: "Resolved by PR #123",
ClosedBySession: "session-left",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
right := []Issue{
{
ID: "bd-close2",
Title: "Test Issue",
Status: "closed",
ClosedAt: "2024-01-02T00:00:00Z", // Earlier
CloseReason: "Duplicate",
ClosedBySession: "session-right",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("expected no conflicts, got %d", len(conflicts))
}
if len(result) != 1 {
t.Fatalf("expected 1 merged issue, got %d", len(result))
}
// Left has later closed_at, so left's close_reason should win
if result[0].CloseReason != "Resolved by PR #123" {
t.Errorf("expected close_reason 'Resolved by PR #123', got %q", result[0].CloseReason)
}
if result[0].ClosedBySession != "session-left" {
t.Errorf("expected closed_by_session 'session-left', got %q", result[0].ClosedBySession)
}
})
t.Run("close_reason cleared when status becomes open", func(t *testing.T) {
base := []Issue{
{
ID: "bd-close3",
Title: "Test Issue",
Status: "closed",
ClosedAt: "2024-01-02T00:00:00Z",
CloseReason: "Fixed",
ClosedBySession: "session-old",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
left := []Issue{
{
ID: "bd-close3",
Title: "Test Issue",
Status: "open", // Reopened
ClosedAt: "",
CloseReason: "", // Should be cleared
ClosedBySession: "",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
right := []Issue{
{
ID: "bd-close3",
Title: "Test Issue",
Status: "open", // Both reopened
ClosedAt: "",
CloseReason: "",
ClosedBySession: "",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("expected no conflicts, got %d", len(conflicts))
}
if len(result) != 1 {
t.Fatalf("expected 1 merged issue, got %d", len(result))
}
if result[0].Status != "open" {
t.Errorf("expected status 'open', got %q", result[0].Status)
}
if result[0].CloseReason != "" {
t.Errorf("expected empty close_reason when reopened, got %q", result[0].CloseReason)
}
if result[0].ClosedBySession != "" {
t.Errorf("expected empty closed_by_session when reopened, got %q", result[0].ClosedBySession)
}
})
t.Run("close_reason from single side preserved", func(t *testing.T) {
base := []Issue{
{
ID: "bd-close4",
Title: "Test Issue",
Status: "open",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
left := []Issue{
{
ID: "bd-close4",
Title: "Test Issue",
Status: "closed",
ClosedAt: "2024-01-02T00:00:00Z",
CloseReason: "Won't fix - by design",
ClosedBySession: "session-abc",
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
right := []Issue{
{
ID: "bd-close4",
Title: "Test Issue",
Status: "open", // Still open on right
CreatedAt: "2024-01-01T00:00:00Z",
CreatedBy: "user1",
},
}
result, conflicts := merge3Way(base, left, right, false)
if len(conflicts) != 0 {
t.Errorf("expected no conflicts, got %d", len(conflicts))
}
if len(result) != 1 {
t.Fatalf("expected 1 merged issue, got %d", len(result))
}
// Closed wins over open
if result[0].Status != "closed" {
t.Errorf("expected status 'closed', got %q", result[0].Status)
}
// Close reason from the closed side should be preserved
if result[0].CloseReason != "Won't fix - by design" {
t.Errorf("expected close_reason 'Won't fix - by design', got %q", result[0].CloseReason)
}
if result[0].ClosedBySession != "session-abc" {
t.Errorf("expected closed_by_session 'session-abc', got %q", result[0].ClosedBySession)
}
})
t.Run("close_reason survives round-trip through JSONL", func(t *testing.T) {
// This tests the full merge pipeline including JSON marshaling/unmarshaling
tmpDir := t.TempDir()
baseContent := `{"id":"bd-jsonl1","title":"Test Issue","status":"open","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`
leftContent := `{"id":"bd-jsonl1","title":"Test Issue","status":"closed","closed_at":"2024-01-02T00:00:00Z","close_reason":"Fixed in commit def456","closed_by_session":"session-jsonl","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`
rightContent := `{"id":"bd-jsonl1","title":"Test Issue","status":"open","created_at":"2024-01-01T00:00:00Z","created_by":"user1"}`
basePath := filepath.Join(tmpDir, "base.jsonl")
leftPath := filepath.Join(tmpDir, "left.jsonl")
rightPath := filepath.Join(tmpDir, "right.jsonl")
outputPath := filepath.Join(tmpDir, "output.jsonl")
if err := os.WriteFile(basePath, []byte(baseContent+"\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(leftPath, []byte(leftContent+"\n"), 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(rightPath, []byte(rightContent+"\n"), 0644); err != nil {
t.Fatal(err)
}
if err := Merge3Way(outputPath, basePath, leftPath, rightPath, false); err != nil {
t.Fatalf("Merge3Way failed: %v", err)
}
// Read output and verify close_reason is preserved
outputData, err := os.ReadFile(outputPath)
if err != nil {
t.Fatal(err)
}
var outputIssue Issue
if err := json.Unmarshal(outputData[:len(outputData)-1], &outputIssue); err != nil {
t.Fatalf("failed to parse output: %v", err)
}
if outputIssue.Status != "closed" {
t.Errorf("expected status 'closed', got %q", outputIssue.Status)
}
if outputIssue.CloseReason != "Fixed in commit def456" {
t.Errorf("expected close_reason 'Fixed in commit def456', got %q", outputIssue.CloseReason)
}
if outputIssue.ClosedBySession != "session-jsonl" {
t.Errorf("expected closed_by_session 'session-jsonl', got %q", outputIssue.ClosedBySession)
}
})
}