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

@@ -45,7 +45,9 @@ The --manual flag shows a diff for each conflict and prompts you to choose:
l/local - Keep local version
r/remote - Keep remote version
m/merge - Auto-merge (LWW for scalars, union for collections)
s/skip - Skip and leave unresolved
s/skip - Skip (keep local, conflict remains for later)
a/all - Accept auto-merge for all remaining conflicts
q/quit - Quit and skip all remaining conflicts
d/diff - Show full JSON diff
The --full flag provides the legacy full sync behavior for backwards compatibility.`,
@@ -1149,22 +1151,14 @@ func resolveSyncConflictsManually(ctx context.Context, jsonlPath, beadsDir strin
var mergedIssues []*beads.Issue
for id := range allIDSet {
if conflictIDSet[id] {
// This was a conflict - use the resolved version if available
// This was a conflict
if resolved, ok := resolvedMap[id]; ok {
// User resolved this conflict - use their choice
mergedIssues = append(mergedIssues, resolved)
}
// If not in resolvedMap, it was skipped - use the automatic merge result
if _, ok := resolvedMap[id]; !ok {
// Fall back to field-level merge for skipped conflicts
local := localMap[id]
remote := remoteMap[id]
base := baseMap[id]
if local != nil && remote != nil {
mergedIssues = append(mergedIssues, mergeFieldLevel(base, local, remote))
} else if local != nil {
} else {
// Skipped - keep local version in output, conflict remains for later
if local := localMap[id]; local != nil {
mergedIssues = append(mergedIssues, local)
} else if remote != nil {
mergedIssues = append(mergedIssues, remote)
}
}
} else {

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]) + "..."
}

View File

@@ -45,6 +45,36 @@ func TestTruncateText(t *testing.T) {
maxLen: 30,
want: "line1 line2 line3",
},
{
name: "very short max",
input: "hello world",
maxLen: 3,
want: "...",
},
{
name: "UTF-8 characters preserved",
input: "Hello 世界This is a test",
maxLen: 12,
want: "Hello 世界!...",
},
{
name: "UTF-8 exact length",
input: "日本語テスト",
maxLen: 6,
want: "日本語テスト",
},
{
name: "UTF-8 truncate",
input: "日本語テストです",
maxLen: 6,
want: "日本語...",
},
{
name: "emoji handling",
input: "Hello 🌍🌎🌏 World",
maxLen: 12,
want: "Hello 🌍🌎🌏...",
},
}
for _, tt := range tests {
@@ -164,6 +194,14 @@ func TestInteractiveConflictDisplay(t *testing.T) {
},
},
},
{
name: "both nil (edge case)",
conflict: InteractiveConflict{
IssueID: "test-6",
Local: nil,
Remote: nil,
},
},
}
for _, tt := range tests {
@@ -177,22 +215,58 @@ func TestInteractiveConflictDisplay(t *testing.T) {
func TestShowDetailedDiff(t *testing.T) {
now := time.Now()
conflict := InteractiveConflict{
IssueID: "test-1",
Local: &beads.Issue{
ID: "test-1",
Title: "Local",
UpdatedAt: now,
tests := []struct {
name string
conflict InteractiveConflict
}{
{
name: "both exist",
conflict: InteractiveConflict{
IssueID: "test-1",
Local: &beads.Issue{
ID: "test-1",
Title: "Local",
UpdatedAt: now,
},
Remote: &beads.Issue{
ID: "test-1",
Title: "Remote",
UpdatedAt: now,
},
},
},
Remote: &beads.Issue{
ID: "test-1",
Title: "Remote",
UpdatedAt: now,
{
name: "local nil",
conflict: InteractiveConflict{
IssueID: "test-2",
Local: nil,
Remote: &beads.Issue{
ID: "test-2",
Title: "Remote",
UpdatedAt: now,
},
},
},
{
name: "remote nil",
conflict: InteractiveConflict{
IssueID: "test-3",
Local: &beads.Issue{
ID: "test-3",
Title: "Local",
UpdatedAt: now,
},
Remote: nil,
},
},
}
// Just make sure it doesn't panic
showDetailedDiff(conflict)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Just make sure it doesn't panic
showDetailedDiff(tt.conflict)
})
}
}
func TestPrintResolutionHelp(t *testing.T) {
@@ -272,3 +346,34 @@ func TestInteractiveResolutionMerge(t *testing.T) {
t.Errorf("Expected labels to contain 'bug' and 'feature', got %v", merged.Labels)
}
}
func TestInteractiveResolutionChoices(t *testing.T) {
// Test InteractiveResolution struct values
tests := []struct {
name string
choice string
issue *beads.Issue
}{
{"local", "local", &beads.Issue{ID: "test"}},
{"remote", "remote", &beads.Issue{ID: "test"}},
{"merged", "merged", &beads.Issue{ID: "test"}},
{"skip", "skip", nil},
{"quit", "quit", nil},
{"accept-all", "accept-all", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
res := InteractiveResolution{Choice: tt.choice, Issue: tt.issue}
if res.Choice != tt.choice {
t.Errorf("Expected choice %q, got %q", tt.choice, res.Choice)
}
if tt.issue == nil && res.Issue != nil {
t.Errorf("Expected nil issue, got %v", res.Issue)
}
if tt.issue != nil && res.Issue == nil {
t.Errorf("Expected non-nil issue, got nil")
}
})
}
}