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>
424 lines
10 KiB
Go
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])
|
|
}
|
|
}
|