Files
beads/cmd/bd/resolve_conflicts_test.go
fang 9cffdceb4e feat: add bd resolve-conflicts command for JSONL merge conflicts (bd-7e7ddffa.1)
Implements a new command to resolve git merge conflict markers in JSONL files.

Features:
- Mechanical mode (default): deterministic merge using updated_at timestamps
- Closed status wins over open
- Higher priority (lower number) wins
- Notes are concatenated when different
- Dependencies are unioned
- Dry-run mode to preview changes
- JSON output for agent integration
- Automatic backup creation before changes

The command defaults to resolving .beads/beads.jsonl but accepts any file path.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 21:41:29 -08:00

424 lines
10 KiB
Go

package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/steveyegge/beads/internal/merge"
)
func TestParseConflicts(t *testing.T) {
tests := []struct {
name string
content string
wantConflicts int
wantCleanLines int
wantErr bool
}{
{
name: "no conflicts",
content: `{"id":"bd-1","title":"Issue 1"}
{"id":"bd-2","title":"Issue 2"}`,
wantConflicts: 0,
wantCleanLines: 2,
wantErr: false,
},
{
name: "single conflict",
content: `{"id":"bd-1","title":"Issue 1"}
<<<<<<< HEAD
{"id":"bd-2","title":"Issue 2 local"}
=======
{"id":"bd-2","title":"Issue 2 remote"}
>>>>>>> branch
{"id":"bd-3","title":"Issue 3"}`,
wantConflicts: 1,
wantCleanLines: 2,
wantErr: false,
},
{
name: "multiple conflicts",
content: `<<<<<<< HEAD
{"id":"bd-1","title":"Local 1"}
=======
{"id":"bd-1","title":"Remote 1"}
>>>>>>> branch
{"id":"bd-2","title":"Clean line"}
<<<<<<< HEAD
{"id":"bd-3","title":"Local 3"}
=======
{"id":"bd-3","title":"Remote 3"}
>>>>>>> other-branch`,
wantConflicts: 2,
wantCleanLines: 1,
wantErr: false,
},
{
name: "unclosed conflict",
content: `<<<<<<< HEAD
{"id":"bd-1","title":"Local"}
=======
{"id":"bd-1","title":"Remote"}`,
wantConflicts: 0,
wantCleanLines: 0,
wantErr: true,
},
{
name: "nested conflict error",
content: `<<<<<<< HEAD
<<<<<<< HEAD
{"id":"bd-1","title":"Nested"}
=======
{"id":"bd-1","title":"Remote"}
>>>>>>> branch`,
wantConflicts: 0,
wantCleanLines: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
conflicts, cleanLines, err := parseConflicts(tt.content)
if tt.wantErr {
if err == nil {
t.Error("Expected error but got none")
}
return
}
if err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if len(conflicts) != tt.wantConflicts {
t.Errorf("Got %d conflicts, want %d", len(conflicts), tt.wantConflicts)
}
if len(cleanLines) != tt.wantCleanLines {
t.Errorf("Got %d clean lines, want %d", len(cleanLines), tt.wantCleanLines)
}
})
}
}
func TestParseConflictsLabels(t *testing.T) {
content := `<<<<<<< HEAD
{"id":"bd-1","title":"Local"}
=======
{"id":"bd-1","title":"Remote"}
>>>>>>> feature-branch`
conflicts, _, err := parseConflicts(content)
if err != nil {
t.Fatalf("Unexpected error: %v", err)
}
if len(conflicts) != 1 {
t.Fatalf("Expected 1 conflict, got %d", len(conflicts))
}
if conflicts[0].LeftLabel != "HEAD" {
t.Errorf("Expected left label 'HEAD', got %q", conflicts[0].LeftLabel)
}
if conflicts[0].RightLabel != "feature-branch" {
t.Errorf("Expected right label 'feature-branch', got %q", conflicts[0].RightLabel)
}
if len(conflicts[0].LeftSide) != 1 {
t.Errorf("Expected 1 left line, got %d", len(conflicts[0].LeftSide))
}
if len(conflicts[0].RightSide) != 1 {
t.Errorf("Expected 1 right line, got %d", len(conflicts[0].RightSide))
}
}
func TestResolveConflict(t *testing.T) {
tests := []struct {
name string
conflict conflictRegion
wantIssueID string
wantResContains string
}{
{
name: "merge same issue different titles",
conflict: conflictRegion{
StartLine: 1,
EndLine: 5,
LeftSide: []string{`{"id":"bd-1","title":"Local Title","updated_at":"2024-01-02T00:00:00Z"}`},
RightSide: []string{`{"id":"bd-1","title":"Remote Title","updated_at":"2024-01-01T00:00:00Z"}`},
LeftLabel: "HEAD",
RightLabel: "branch",
},
wantIssueID: "bd-1",
wantResContains: "merged",
},
{
name: "left only valid JSON",
conflict: conflictRegion{
StartLine: 1,
EndLine: 5,
LeftSide: []string{`{"id":"bd-1","title":"Valid"}`},
RightSide: []string{`not valid json`},
LeftLabel: "HEAD",
RightLabel: "branch",
},
wantIssueID: "bd-1",
wantResContains: "left_only_valid",
},
{
name: "right only valid JSON",
conflict: conflictRegion{
StartLine: 1,
EndLine: 5,
LeftSide: []string{`invalid json here`},
RightSide: []string{`{"id":"bd-2","title":"Valid"}`},
LeftLabel: "HEAD",
RightLabel: "branch",
},
wantIssueID: "bd-2",
wantResContains: "right_only_valid",
},
{
name: "both unparseable",
conflict: conflictRegion{
StartLine: 1,
EndLine: 5,
LeftSide: []string{`not json`},
RightSide: []string{`also not json`},
LeftLabel: "HEAD",
RightLabel: "branch",
},
wantIssueID: "",
wantResContains: "kept_both_unparseable",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, info := resolveConflict(tt.conflict, 1)
if tt.wantIssueID != "" && info.IssueID != tt.wantIssueID {
t.Errorf("Got issue ID %q, want %q", info.IssueID, tt.wantIssueID)
}
if !strings.Contains(info.Resolution, tt.wantResContains) {
t.Errorf("Resolution %q doesn't contain %q", info.Resolution, tt.wantResContains)
}
})
}
}
func TestMergeIssueConflict(t *testing.T) {
t.Run("title picks later updated_at", func(t *testing.T) {
left := merge.Issue{
ID: "bd-1",
Title: "Old Title",
UpdatedAt: "2024-01-01T00:00:00Z",
}
right := merge.Issue{
ID: "bd-1",
Title: "New Title",
UpdatedAt: "2024-01-02T00:00:00Z",
}
result := mergeIssueConflict(left, right)
if result.Title != "New Title" {
t.Errorf("Expected title 'New Title', got %q", result.Title)
}
})
t.Run("closed status wins", func(t *testing.T) {
left := merge.Issue{
ID: "bd-1",
Status: "open",
}
right := merge.Issue{
ID: "bd-1",
Status: "closed",
}
result := mergeIssueConflict(left, right)
if result.Status != "closed" {
t.Errorf("Expected status 'closed', got %q", result.Status)
}
})
t.Run("higher priority wins", func(t *testing.T) {
left := merge.Issue{
ID: "bd-1",
Priority: 2,
}
right := merge.Issue{
ID: "bd-1",
Priority: 1,
}
result := mergeIssueConflict(left, right)
if result.Priority != 1 {
t.Errorf("Expected priority 1, got %d", result.Priority)
}
})
t.Run("notes concatenate when different", func(t *testing.T) {
left := merge.Issue{
ID: "bd-1",
Notes: "Note A",
}
right := merge.Issue{
ID: "bd-1",
Notes: "Note B",
}
result := mergeIssueConflict(left, right)
if !strings.Contains(result.Notes, "Note A") || !strings.Contains(result.Notes, "Note B") {
t.Errorf("Expected concatenated notes, got %q", result.Notes)
}
})
t.Run("dependencies union", func(t *testing.T) {
left := merge.Issue{
ID: "bd-1",
Dependencies: []merge.Dependency{
{IssueID: "bd-1", DependsOnID: "bd-2", Type: "blocks"},
},
}
right := merge.Issue{
ID: "bd-1",
Dependencies: []merge.Dependency{
{IssueID: "bd-1", DependsOnID: "bd-3", Type: "blocks"},
},
}
result := mergeIssueConflict(left, right)
if len(result.Dependencies) != 2 {
t.Errorf("Expected 2 dependencies, got %d", len(result.Dependencies))
}
})
}
func TestTimeHelpers(t *testing.T) {
t.Run("isTimeAfterStr", func(t *testing.T) {
tests := []struct {
t1, t2 string
want bool
}{
{"2024-01-02T00:00:00Z", "2024-01-01T00:00:00Z", true},
{"2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", false},
{"2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z", false},
{"2024-01-01T00:00:00Z", "", true},
{"", "2024-01-01T00:00:00Z", false},
{"", "", false},
}
for _, tt := range tests {
got := isTimeAfterStr(tt.t1, tt.t2)
if got != tt.want {
t.Errorf("isTimeAfterStr(%q, %q) = %v, want %v", tt.t1, tt.t2, got, tt.want)
}
}
})
t.Run("maxTimeStr", func(t *testing.T) {
tests := []struct {
t1, t2, want string
}{
{"2024-01-02T00:00:00Z", "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z"},
{"2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "2024-01-02T00:00:00Z"},
{"2024-01-01T00:00:00Z", "", "2024-01-01T00:00:00Z"},
{"", "2024-01-01T00:00:00Z", "2024-01-01T00:00:00Z"},
{"", "", ""},
}
for _, tt := range tests {
got := maxTimeStr(tt.t1, tt.t2)
if got != tt.want {
t.Errorf("maxTimeStr(%q, %q) = %q, want %q", tt.t1, tt.t2, got, tt.want)
}
}
})
t.Run("pickByUpdatedAt", func(t *testing.T) {
tests := []struct {
left, right, leftTime, rightTime, want string
}{
{"A", "B", "2024-01-02T00:00:00Z", "2024-01-01T00:00:00Z", "A"},
{"A", "B", "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "B"},
{"Same", "Same", "2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z", "Same"},
}
for _, tt := range tests {
got := pickByUpdatedAt(tt.left, tt.right, tt.leftTime, tt.rightTime)
if got != tt.want {
t.Errorf("pickByUpdatedAt(%q, %q, ...) = %q, want %q", tt.left, tt.right, got, tt.want)
}
}
})
}
func TestResolveConflictsEndToEnd(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-resolve-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
// Create a file with conflicts
conflictFile := filepath.Join(tmpDir, "test.jsonl")
content := `{"id":"bd-1","title":"Clean issue"}
<<<<<<< HEAD
{"id":"bd-2","title":"Local version","updated_at":"2024-01-02T00:00:00Z"}
=======
{"id":"bd-2","title":"Remote version","updated_at":"2024-01-01T00:00:00Z"}
>>>>>>> remote-branch
{"id":"bd-3","title":"Another clean issue"}`
if err := os.WriteFile(conflictFile, []byte(content), 0644); err != nil {
t.Fatalf("Failed to write test file: %v", err)
}
// Parse conflicts
conflicts, cleanLines, err := parseConflicts(content)
if err != nil {
t.Fatalf("Failed to parse conflicts: %v", err)
}
if len(conflicts) != 1 {
t.Errorf("Expected 1 conflict, got %d", len(conflicts))
}
if len(cleanLines) != 2 {
t.Errorf("Expected 2 clean lines, got %d", len(cleanLines))
}
// Resolve the conflict
resolved, info := resolveConflict(conflicts[0], 1)
if info.Resolution != "merged" {
t.Errorf("Expected resolution 'merged', got %q", info.Resolution)
}
if info.IssueID != "bd-2" {
t.Errorf("Expected issue ID 'bd-2', got %q", info.IssueID)
}
// The resolved content should contain the local title (later updated_at)
if len(resolved) != 1 {
t.Fatalf("Expected 1 resolved line, got %d", len(resolved))
}
if !strings.Contains(resolved[0], "Local version") {
t.Errorf("Expected resolved content to contain 'Local version', got %q", resolved[0])
}
}