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",
|
"prime",
|
||||||
"quickstart",
|
"quickstart",
|
||||||
"repair",
|
"repair",
|
||||||
|
"resolve-conflicts",
|
||||||
"setup",
|
"setup",
|
||||||
"version",
|
"version",
|
||||||
"zsh",
|
"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