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>
This commit is contained in:
fang
2026-01-07 21:41:29 -08:00
committed by Steve Yegge
parent e96dd023ce
commit 9cffdceb4e
3 changed files with 976 additions and 0 deletions

View File

@@ -310,6 +310,7 @@ var rootCmd = &cobra.Command{
"prime",
"quickstart",
"repair",
"resolve-conflicts",
"setup",
"version",
"zsh",

552
cmd/bd/resolve_conflicts.go Normal file
View File

@@ -0,0 +1,552 @@
package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/merge"
"github.com/steveyegge/beads/internal/ui"
)
var resolveConflictsCmd = &cobra.Command{
Use: "resolve-conflicts [file]",
GroupID: GroupMaintenance,
Short: "Resolve git merge conflicts in JSONL files",
Long: `Resolve git merge conflict markers in beads JSONL files.
When git merges fail to auto-resolve, JSONL files can end up with conflict
markers (<<<<<<, =======, >>>>>>). This command parses those markers and
resolves the conflicts using beads merge semantics.
Modes:
mechanical (default) Uses deterministic merge rules (updated_at wins, etc.)
interactive Prompts for each conflict (not yet implemented)
The file defaults to .beads/beads.jsonl if not specified.
Examples:
bd resolve-conflicts # Resolve conflicts in .beads/beads.jsonl
bd resolve-conflicts --dry-run # Show what would be resolved
bd resolve-conflicts custom.jsonl # Resolve conflicts in custom file
bd resolve-conflicts --json # Output results as JSON`,
Args: cobra.MaximumNArgs(1),
// PreRun disables PersistentPreRun for this command (no database needed)
PreRun: func(cmd *cobra.Command, args []string) {},
Run: runResolveConflicts,
}
var (
resolveConflictsMode string
resolveConflictsDryRun bool
resolveConflictsJSON bool
resolveConflictsPath string
)
func init() {
resolveConflictsCmd.Flags().StringVar(&resolveConflictsMode, "mode", "mechanical", "Resolution mode: mechanical, interactive")
resolveConflictsCmd.Flags().BoolVar(&resolveConflictsDryRun, "dry-run", false, "Show what would be resolved without making changes")
resolveConflictsCmd.Flags().BoolVar(&resolveConflictsJSON, "json", false, "Output results as JSON")
resolveConflictsCmd.Flags().StringVar(&resolveConflictsPath, "path", ".", "Path to repository with .beads directory")
rootCmd.AddCommand(resolveConflictsCmd)
}
// conflictRegion represents a single conflict in the file
type conflictRegion struct {
StartLine int // Line number where <<<<<<< starts
EndLine int // Line number where >>>>>>> ends
LeftSide []string // Lines between <<<<<<< and =======
RightSide []string // Lines between ======= and >>>>>>>
LeftLabel string // Label after <<<<<<< (e.g., "HEAD")
RightLabel string // Label after >>>>>>> (e.g., "branch-name")
}
// resolveConflictsResult is the JSON output structure
type resolveConflictsResult struct {
FilePath string `json:"file_path"`
DryRun bool `json:"dry_run"`
Mode string `json:"mode"`
ConflictsFound int `json:"conflicts_found"`
ConflictsResolved int `json:"conflicts_resolved"`
Status string `json:"status"` // "success", "no_conflicts", "dry_run", "error"
BackupPath string `json:"backup_path,omitempty"`
Error string `json:"error,omitempty"`
Conflicts []conflictResolutionInfo `json:"conflicts,omitempty"`
}
type conflictResolutionInfo struct {
LineRange string `json:"line_range"`
LeftLabel string `json:"left_label"`
RightLabel string `json:"right_label"`
Resolution string `json:"resolution"` // "merged", "left", "right", "both"
IssueID string `json:"issue_id,omitempty"`
}
func runResolveConflicts(cmd *cobra.Command, args []string) {
// Determine file path
var filePath string
if len(args) > 0 {
filePath = args[0]
} else {
filePath = filepath.Join(resolveConflictsPath, ".beads", "beads.jsonl")
}
// Validate mode
if resolveConflictsMode != "mechanical" && resolveConflictsMode != "interactive" {
outputResolveError(filePath, fmt.Sprintf("invalid mode: %s (use 'mechanical' or 'interactive')", resolveConflictsMode))
os.Exit(1)
}
if resolveConflictsMode == "interactive" {
outputResolveError(filePath, "interactive mode not yet implemented")
os.Exit(1)
}
// Check file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
outputResolveError(filePath, fmt.Sprintf("file not found: %s", filePath))
os.Exit(1)
}
// Read file content
content, err := os.ReadFile(filePath) // #nosec G304 -- user-provided path for conflict resolution
if err != nil {
outputResolveError(filePath, fmt.Sprintf("reading file: %v", err))
os.Exit(1)
}
// Parse conflicts
conflicts, cleanLines, err := parseConflicts(string(content))
if err != nil {
outputResolveError(filePath, fmt.Sprintf("parsing conflicts: %v", err))
os.Exit(1)
}
result := resolveConflictsResult{
FilePath: filePath,
DryRun: resolveConflictsDryRun,
Mode: resolveConflictsMode,
ConflictsFound: len(conflicts),
}
// No conflicts case
if len(conflicts) == 0 {
result.Status = "no_conflicts"
if resolveConflictsJSON {
outputResolveJSON(result, 0)
}
fmt.Printf("%s No conflict markers found in %s\n", ui.RenderPass("✓"), filePath)
return
}
if !resolveConflictsJSON {
fmt.Printf("Found %d conflict region(s) in %s\n", len(conflicts), filePath)
if resolveConflictsDryRun {
fmt.Println("[DRY-RUN] No changes will be made")
}
fmt.Println()
}
// Resolve each conflict
var resolvedLines []string
resolvedLines = append(resolvedLines, cleanLines...)
for i, conflict := range conflicts {
resolution, info := resolveConflict(conflict, i+1)
result.Conflicts = append(result.Conflicts, info)
if !resolveConflictsJSON && !resolveConflictsDryRun {
fmt.Printf(" Conflict %d (lines %d-%d): %s\n", i+1, conflict.StartLine, conflict.EndLine, info.Resolution)
}
resolvedLines = append(resolvedLines, resolution...)
result.ConflictsResolved++
}
// Dry-run output
if resolveConflictsDryRun {
result.Status = "dry_run"
if resolveConflictsJSON {
outputResolveJSON(result, 0)
} else {
fmt.Printf("[DRY-RUN] Would resolve %d conflict(s)\n", len(conflicts))
for i, info := range result.Conflicts {
fmt.Printf(" %d. Lines %s: %s", i+1, info.LineRange, info.Resolution)
if info.IssueID != "" {
fmt.Printf(" (issue: %s)", info.IssueID)
}
fmt.Println()
}
}
return
}
// Create backup
backupPath := filePath + ".pre-resolve"
if err := copyFile(filePath, backupPath); err != nil {
outputResolveError(filePath, fmt.Sprintf("creating backup: %v", err))
os.Exit(1)
}
result.BackupPath = backupPath
if !resolveConflictsJSON {
fmt.Printf(" Backup created: %s\n", filepath.Base(backupPath))
}
// Write resolved content
output := strings.Join(resolvedLines, "\n")
if len(resolvedLines) > 0 {
output += "\n"
}
if err := os.WriteFile(filePath, []byte(output), 0644); err != nil { // #nosec G306 -- standard file permissions
outputResolveError(filePath, fmt.Sprintf("writing file: %v", err))
os.Exit(1)
}
result.Status = "success"
if resolveConflictsJSON {
outputResolveJSON(result, 0)
} else {
fmt.Println()
fmt.Printf("%s Resolved %d conflict(s) in %s\n", ui.RenderPass("✓"), len(conflicts), filepath.Base(filePath))
fmt.Printf("Backup preserved at: %s\n", filepath.Base(backupPath))
}
}
// parseConflicts extracts conflict regions and non-conflicted lines from content
func parseConflicts(content string) ([]conflictRegion, []string, error) {
var conflicts []conflictRegion
var cleanLines []string
scanner := bufio.NewScanner(strings.NewReader(content))
lineNum := 0
var current *conflictRegion
inLeft := false
inRight := false
for scanner.Scan() {
lineNum++
line := scanner.Text()
// Detect conflict start
if strings.HasPrefix(line, "<<<<<<<") {
if current != nil {
return nil, nil, fmt.Errorf("nested conflict at line %d", lineNum)
}
current = &conflictRegion{
StartLine: lineNum,
LeftLabel: strings.TrimSpace(strings.TrimPrefix(line, "<<<<<<<")),
}
inLeft = true
continue
}
// Detect conflict separator
if strings.HasPrefix(line, "=======") && current != nil {
inLeft = false
inRight = true
continue
}
// Detect conflict end
if strings.HasPrefix(line, ">>>>>>>") && current != nil {
current.EndLine = lineNum
current.RightLabel = strings.TrimSpace(strings.TrimPrefix(line, ">>>>>>>"))
conflicts = append(conflicts, *current)
current = nil
inLeft = false
inRight = false
continue
}
// Collect lines
if current != nil {
if inLeft {
current.LeftSide = append(current.LeftSide, line)
} else if inRight {
current.RightSide = append(current.RightSide, line)
}
} else {
cleanLines = append(cleanLines, line)
}
}
if current != nil {
return nil, nil, fmt.Errorf("unclosed conflict starting at line %d", current.StartLine)
}
return conflicts, cleanLines, scanner.Err()
}
// resolveConflict resolves a single conflict region using merge semantics
func resolveConflict(conflict conflictRegion, num int) ([]string, conflictResolutionInfo) {
info := conflictResolutionInfo{
LineRange: fmt.Sprintf("%d-%d", conflict.StartLine, conflict.EndLine),
LeftLabel: conflict.LeftLabel,
RightLabel: conflict.RightLabel,
}
// Try to parse left and right as JSON issues
var leftIssues, rightIssues []merge.Issue
for _, line := range conflict.LeftSide {
if strings.TrimSpace(line) == "" {
continue
}
var issue merge.Issue
if err := json.Unmarshal([]byte(line), &issue); err == nil {
issue.RawLine = line
leftIssues = append(leftIssues, issue)
}
}
for _, line := range conflict.RightSide {
if strings.TrimSpace(line) == "" {
continue
}
var issue merge.Issue
if err := json.Unmarshal([]byte(line), &issue); err == nil {
issue.RawLine = line
rightIssues = append(rightIssues, issue)
}
}
// If we couldn't parse as JSON, keep both sides
if len(leftIssues) == 0 && len(rightIssues) == 0 {
info.Resolution = "kept_both_unparseable"
var result []string
result = append(result, conflict.LeftSide...)
result = append(result, conflict.RightSide...)
return result, info
}
// If only one side has valid JSON, use that
if len(leftIssues) > 0 && len(rightIssues) == 0 {
info.Resolution = "left_only_valid"
if len(leftIssues) == 1 {
info.IssueID = leftIssues[0].ID
}
return conflict.LeftSide, info
}
if len(rightIssues) > 0 && len(leftIssues) == 0 {
info.Resolution = "right_only_valid"
if len(rightIssues) == 1 {
info.IssueID = rightIssues[0].ID
}
return conflict.RightSide, info
}
// Both sides have valid JSON - merge them
// Use the 3-way merge logic with empty base (both sides added the same issue)
var result []string
mergedIDs := make(map[string]bool)
for _, left := range leftIssues {
// Find matching right issue by ID
var matchingRight *merge.Issue
for i := range rightIssues {
if rightIssues[i].ID == left.ID {
matchingRight = &rightIssues[i]
break
}
}
if matchingRight != nil {
// Merge the two versions
merged := mergeIssueConflict(left, *matchingRight)
mergedJSON, err := json.Marshal(merged)
if err != nil {
// Fall back to left on marshal error
result = append(result, left.RawLine)
} else {
result = append(result, string(mergedJSON))
}
mergedIDs[left.ID] = true
info.IssueID = left.ID
info.Resolution = "merged"
} else {
// No matching right issue - keep left
result = append(result, left.RawLine)
}
}
// Add any right issues that weren't merged
for _, right := range rightIssues {
if !mergedIDs[right.ID] {
result = append(result, right.RawLine)
}
}
if info.Resolution == "" {
info.Resolution = "merged_multiple"
}
return result, info
}
// mergeIssueConflict merges two conflicting issue versions
// Uses similar logic to internal/merge but simplified for conflict resolution
func mergeIssueConflict(left, right merge.Issue) merge.Issue {
result := merge.Issue{
ID: left.ID,
CreatedAt: left.CreatedAt,
CreatedBy: left.CreatedBy,
}
// Title: prefer later updated_at
result.Title = pickByUpdatedAt(left.Title, right.Title, left.UpdatedAt, right.UpdatedAt)
// Description: prefer later updated_at
result.Description = pickByUpdatedAt(left.Description, right.Description, left.UpdatedAt, right.UpdatedAt)
// Notes: concatenate if different
if left.Notes == right.Notes {
result.Notes = left.Notes
} else if left.Notes == "" {
result.Notes = right.Notes
} else if right.Notes == "" {
result.Notes = left.Notes
} else {
result.Notes = left.Notes + "\n\n---\n\n" + right.Notes
}
// Status: closed wins
if left.Status == "closed" || right.Status == "closed" {
result.Status = "closed"
} else if left.Status == "tombstone" || right.Status == "tombstone" {
result.Status = "tombstone"
} else {
result.Status = pickByUpdatedAt(left.Status, right.Status, left.UpdatedAt, right.UpdatedAt)
}
// Priority: lower number (higher priority) wins
if left.Priority != 0 && right.Priority != 0 {
if left.Priority < right.Priority {
result.Priority = left.Priority
} else {
result.Priority = right.Priority
}
} else if left.Priority != 0 {
result.Priority = left.Priority
} else {
result.Priority = right.Priority
}
// IssueType: prefer left
if left.IssueType != "" {
result.IssueType = left.IssueType
} else {
result.IssueType = right.IssueType
}
// UpdatedAt: max
result.UpdatedAt = maxTimeStr(left.UpdatedAt, right.UpdatedAt)
// ClosedAt: max (if status is closed)
if result.Status == "closed" {
result.ClosedAt = maxTimeStr(left.ClosedAt, right.ClosedAt)
// CloseReason and ClosedBySession from whichever has later ClosedAt
if isTimeAfterStr(left.ClosedAt, right.ClosedAt) {
result.CloseReason = left.CloseReason
result.ClosedBySession = left.ClosedBySession
} else {
result.CloseReason = right.CloseReason
result.ClosedBySession = right.ClosedBySession
}
}
// Dependencies: union
depMap := make(map[string]merge.Dependency)
for _, dep := range left.Dependencies {
key := fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
depMap[key] = dep
}
for _, dep := range right.Dependencies {
key := fmt.Sprintf("%s:%s:%s", dep.IssueID, dep.DependsOnID, dep.Type)
if _, exists := depMap[key]; !exists {
depMap[key] = dep
}
}
for _, dep := range depMap {
result.Dependencies = append(result.Dependencies, dep)
}
// Tombstone fields
if result.Status == "tombstone" {
if isTimeAfterStr(left.DeletedAt, right.DeletedAt) {
result.DeletedAt = left.DeletedAt
result.DeletedBy = left.DeletedBy
result.DeleteReason = left.DeleteReason
result.OriginalType = left.OriginalType
} else {
result.DeletedAt = right.DeletedAt
result.DeletedBy = right.DeletedBy
result.DeleteReason = right.DeleteReason
result.OriginalType = right.OriginalType
}
}
return result
}
func pickByUpdatedAt(left, right, leftTime, rightTime string) string {
if left == right {
return left
}
if isTimeAfterStr(leftTime, rightTime) {
return left
}
return right
}
func maxTimeStr(t1, t2 string) string {
if t1 == "" {
return t2
}
if t2 == "" {
return t1
}
if isTimeAfterStr(t1, t2) {
return t1
}
return t2
}
func isTimeAfterStr(t1, t2 string) bool {
if t1 == "" {
return false
}
if t2 == "" {
return true
}
// Simple string comparison works for RFC3339 timestamps
return t1 > t2
}
func outputResolveError(filePath, errMsg string) {
if resolveConflictsJSON {
result := resolveConflictsResult{
FilePath: filePath,
Status: "error",
Error: errMsg,
}
outputResolveJSON(result, 1)
} else {
fmt.Fprintf(os.Stderr, "Error: %s\n", errMsg)
}
}
func outputResolveJSON(result resolveConflictsResult, exitCode int) {
data, err := json.MarshalIndent(result, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, `{"error": "failed to marshal JSON: %v"}`, err)
os.Exit(1)
}
fmt.Println(string(data))
os.Exit(exitCode)
}

View File

@@ -0,0 +1,423 @@
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])
}
}