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:
@@ -310,6 +310,7 @@ var rootCmd = &cobra.Command{
|
||||
"prime",
|
||||
"quickstart",
|
||||
"repair",
|
||||
"resolve-conflicts",
|
||||
"setup",
|
||||
"version",
|
||||
"zsh",
|
||||
|
||||
552
cmd/bd/resolve_conflicts.go
Normal file
552
cmd/bd/resolve_conflicts.go
Normal 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)
|
||||
}
|
||||
423
cmd/bd/resolve_conflicts_test.go
Normal file
423
cmd/bd/resolve_conflicts_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user