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:
@@ -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]) + "..."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user