fix(sync): address code review issues in manual conflict resolution

Fixes from code review:
- Fix duplicate check in merge logic (use else clause)
- Handle io.EOF gracefully (treat as quit)
- Add quit (q) option to abort resolution early
- Add accept-all (a) option to auto-merge remaining conflicts
- Fix skipped conflicts to keep local version (not auto-merge)
- Handle json.MarshalIndent errors properly
- Fix truncateText to use rune count for UTF-8 safety
- Update help text with new options
- Add UTF-8 and emoji test cases

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jane
2026-01-19 11:44:09 -08:00
committed by Steve Yegge
parent 5d68e6b61a
commit 80cd1f35c0
3 changed files with 202 additions and 39 deletions

View File

@@ -4,8 +4,10 @@ import (
"bufio"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"unicode/utf8"
"github.com/steveyegge/beads/internal/beads"
"github.com/steveyegge/beads/internal/ui"
@@ -21,8 +23,8 @@ type InteractiveConflict struct {
// InteractiveResolution represents the user's choice for a conflict
type InteractiveResolution struct {
Choice string // "local", "remote", "merged", "skip"
Issue *beads.Issue // The resolved issue (nil if skipped)
Choice string // "local", "remote", "merged", "skip", "quit", "accept-all"
Issue *beads.Issue // The resolved issue (nil if skipped/quit)
}
// resolveConflictsInteractively handles manual conflict resolution with user prompts.
@@ -54,15 +56,42 @@ func resolveConflictsInteractively(conflicts []InteractiveConflict) ([]*beads.Is
}
switch resolution.Choice {
case "quit":
// Quit - skip all remaining conflicts
remaining := len(conflicts) - i
skipped += remaining
fmt.Printf(" %s Quit - skipping %d remaining conflict(s)\n\n", ui.RenderMuted("⏹"), remaining)
return resolved, skipped, nil
case "accept-all":
// Auto-merge all remaining conflicts
fmt.Printf(" %s Auto-merging %d remaining conflict(s)...\n", ui.RenderAccent("⚡"), len(conflicts)-i)
for j := i; j < len(conflicts); j++ {
c := conflicts[j]
if c.Local != nil && c.Remote != nil {
merged := mergeFieldLevel(c.Base, c.Local, c.Remote)
resolved = append(resolved, merged)
} else if c.Local != nil {
resolved = append(resolved, c.Local)
} else if c.Remote != nil {
resolved = append(resolved, c.Remote)
}
}
fmt.Printf(" %s Done\n\n", ui.RenderPass("✓"))
return resolved, skipped, nil
case "skip":
skipped++
fmt.Printf(" %s Skipped\n\n", ui.RenderMuted("⏭"))
fmt.Printf(" %s Skipped (will keep local, conflict remains)\n\n", ui.RenderMuted("⏭"))
case "local":
resolved = append(resolved, conflict.Local)
fmt.Printf(" %s Kept local version\n\n", ui.RenderPass("✓"))
case "remote":
resolved = append(resolved, conflict.Remote)
fmt.Printf(" %s Kept remote version\n\n", ui.RenderPass("✓"))
case "merged":
resolved = append(resolved, resolution.Issue)
fmt.Printf(" %s Used field-level merge\n\n", ui.RenderPass("✓"))
@@ -187,7 +216,7 @@ func promptConflictResolution(reader *bufio.Reader, conflict InteractiveConflict
// Build options based on what's available
var options []string
var optionMap = make(map[string]string)
optionMap := make(map[string]string)
if local != nil {
options = append(options, "l")
@@ -205,9 +234,13 @@ func promptConflictResolution(reader *bufio.Reader, conflict InteractiveConflict
optionMap["merge"] = "merged"
optionMap["merged"] = "merged"
}
options = append(options, "s", "d", "?")
options = append(options, "s", "a", "q", "d", "?")
optionMap["s"] = "skip"
optionMap["skip"] = "skip"
optionMap["a"] = "accept-all"
optionMap["all"] = "accept-all"
optionMap["q"] = "quit"
optionMap["quit"] = "quit"
optionMap["d"] = "diff"
optionMap["diff"] = "diff"
optionMap["?"] = "help"
@@ -217,6 +250,10 @@ func promptConflictResolution(reader *bufio.Reader, conflict InteractiveConflict
fmt.Printf(" Choice [%s]: ", strings.Join(options, "/"))
input, err := reader.ReadString('\n')
if err != nil {
if err == io.EOF {
// Treat EOF as quit
return InteractiveResolution{Choice: "quit", Issue: nil}, nil
}
return InteractiveResolution{}, err
}
@@ -261,6 +298,12 @@ func promptConflictResolution(reader *bufio.Reader, conflict InteractiveConflict
case "skip":
return InteractiveResolution{Choice: "skip", Issue: nil}, nil
case "accept-all":
return InteractiveResolution{Choice: "accept-all", Issue: nil}, nil
case "quit":
return InteractiveResolution{Choice: "quit", Issue: nil}, nil
}
}
}
@@ -278,7 +321,9 @@ func printResolutionHelp(hasLocal, hasRemote bool) {
if hasLocal && hasRemote {
fmt.Println(" m, merge - Auto-merge (LWW for scalars, union for collections)")
}
fmt.Println(" s, skip - Skip this conflict (leave unresolved)")
fmt.Println(" s, skip - Skip this conflict (keep local, conflict remains)")
fmt.Println(" a, all - Accept auto-merge for all remaining conflicts")
fmt.Println(" q, quit - Quit and skip all remaining conflicts")
fmt.Println(" d, diff - Show detailed JSON diff")
fmt.Println(" ?, help - Show this help")
fmt.Println()
@@ -292,8 +337,12 @@ func showDetailedDiff(conflict InteractiveConflict) {
if conflict.Local != nil {
fmt.Printf(" %s\n", ui.RenderAccent("LOCAL:"))
localJSON, _ := json.MarshalIndent(conflict.Local, " ", " ")
fmt.Println(string(localJSON))
localJSON, err := json.MarshalIndent(conflict.Local, " ", " ")
if err != nil {
fmt.Printf(" (error marshaling: %v)\n", err)
} else {
fmt.Println(string(localJSON))
}
fmt.Println()
} else {
fmt.Printf(" %s (deleted)\n", ui.RenderMuted("LOCAL:"))
@@ -301,8 +350,12 @@ func showDetailedDiff(conflict InteractiveConflict) {
if conflict.Remote != nil {
fmt.Printf(" %s\n", ui.RenderAccent("REMOTE:"))
remoteJSON, _ := json.MarshalIndent(conflict.Remote, " ", " ")
fmt.Println(string(remoteJSON))
remoteJSON, err := json.MarshalIndent(conflict.Remote, " ", " ")
if err != nil {
fmt.Printf(" (error marshaling: %v)\n", err)
} else {
fmt.Println(string(remoteJSON))
}
fmt.Println()
} else {
fmt.Printf(" %s (deleted)\n", ui.RenderMuted("REMOTE:"))
@@ -318,6 +371,8 @@ func valueOrNone(s string) string {
return s
}
// truncateText truncates a string to maxLen runes (not bytes) for proper UTF-8 handling.
// Replaces newlines with spaces for single-line display.
func truncateText(s string, maxLen int) string {
if s == "" {
return "(empty)"
@@ -325,8 +380,17 @@ func truncateText(s string, maxLen int) string {
// Replace newlines with spaces for display
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", "")
if len(s) > maxLen {
return s[:maxLen-3] + "..."
// Count runes, not bytes, for proper UTF-8 handling
runeCount := utf8.RuneCountInString(s)
if runeCount <= maxLen {
return s
}
return s
// Truncate by runes
runes := []rune(s)
if maxLen <= 3 {
return "..."
}
return string(runes[:maxLen-3]) + "..."
}