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, _ 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) }