From c99bd00ca71a728b50736c8b5c23d3b05016bc11 Mon Sep 17 00:00:00 2001 From: jane Date: Sat, 17 Jan 2026 14:02:22 -0800 Subject: [PATCH] feat(sync): implement interactive conflict resolution for manual strategy Adds interactive manual conflict resolution for `bd sync --resolve --manual`: - Shows field-by-field diff between local and remote versions - Prompts user to choose: local (l), remote (r), merge (m), skip (s) - Supports viewing full JSON diff with 'd' option - Skipped conflicts remain in conflict state for later resolution - Integrates with existing 3-way merge infrastructure New files: - cmd/bd/sync_manual.go: Interactive conflict resolution logic - cmd/bd/sync_manual_test.go: Unit tests for helper functions Closes hq-ew1mbr.28 Co-Authored-By: Claude Opus 4.5 --- cmd/bd/sync.go | 175 ++++++++++++++++++- cmd/bd/sync_manual.go | 332 +++++++++++++++++++++++++++++++++++++ cmd/bd/sync_manual_test.go | 274 ++++++++++++++++++++++++++++++ 3 files changed, 776 insertions(+), 5 deletions(-) create mode 100644 cmd/bd/sync_manual.go create mode 100644 cmd/bd/sync_manual_test.go diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index 46ab48cf..ea537a43 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -30,10 +30,23 @@ Commands: bd sync Export to JSONL (prep for push) bd sync --import Import from JSONL (after pull) bd sync --status Show sync state - bd sync --resolve Resolve conflicts + bd sync --resolve Resolve conflicts (uses configured strategy) bd sync --force Force full export/import (skip incremental) bd sync --full Full sync: pull → merge → export → commit → push (legacy) +Conflict Resolution: + bd sync --resolve Use configured conflict.strategy + bd sync --resolve --ours Keep local versions + bd sync --resolve --theirs Keep remote versions + bd sync --resolve --manual Interactive resolution with prompts + +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 + d/diff - Show full JSON diff + The --full flag provides the legacy full sync behavior for backwards compatibility.`, Run: func(cmd *cobra.Command, _ []string) { CheckReadonly("sync") @@ -58,6 +71,7 @@ The --full flag provides the legacy full sync behavior for backwards compatibili resolve, _ := cmd.Flags().GetBool("resolve") resolveOurs, _ := cmd.Flags().GetBool("ours") resolveTheirs, _ := cmd.Flags().GetBool("theirs") + resolveManual, _ := cmd.Flags().GetBool("manual") forceFlag, _ := cmd.Flags().GetBool("force") // --import is shorthand for --import-only @@ -120,6 +134,8 @@ The --full flag provides the legacy full sync behavior for backwards compatibili strategy = config.ConflictStrategyOurs } else if resolveTheirs { strategy = config.ConflictStrategyTheirs + } else if resolveManual { + strategy = config.ConflictStrategyManual } if err := resolveSyncConflicts(ctx, jsonlPath, strategy, dryRun); err != nil { FatalError("%v", err) @@ -831,6 +847,7 @@ func ClearSyncConflictState(beadsDir string) error { // - "newest": Keep whichever version has the newer updated_at timestamp (default) // - "ours": Keep local version // - "theirs": Keep remote version +// - "manual": Interactive resolution with user prompts func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string, dryRun bool) error { beadsDir := filepath.Dir(jsonlPath) @@ -875,6 +892,10 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string } // Build maps for quick lookup + baseMap := make(map[string]*beads.Issue) + for _, issue := range baseIssues { + baseMap[issue.ID] = issue + } localMap := make(map[string]*beads.Issue) for _, issue := range localIssues { localMap[issue.ID] = issue @@ -884,6 +905,11 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string remoteMap[issue.ID] = issue } + // Handle manual strategy with interactive resolution + if strategy == config.ConflictStrategyManual { + return resolveSyncConflictsManually(ctx, jsonlPath, beadsDir, conflictState, baseMap, localMap, remoteMap, baseIssues, localIssues, remoteIssues) + } + resolved := 0 for _, conflict := range conflictState.Conflicts { local := localMap[conflict.IssueID] @@ -895,10 +921,6 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string winner = "local" case config.ConflictStrategyTheirs: winner = "remote" - case config.ConflictStrategyManual: - // Manual mode should not reach here - conflicts are handled interactively - fmt.Printf("⚠ %s: requires manual resolution\n", conflict.IssueID) - continue case config.ConflictStrategyNewest: fallthrough default: @@ -961,6 +983,148 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string return nil } +// resolveSyncConflictsManually handles manual conflict resolution with interactive prompts. +func resolveSyncConflictsManually(ctx context.Context, jsonlPath, beadsDir string, conflictState *SyncConflictState, + baseMap, localMap, remoteMap map[string]*beads.Issue, + baseIssues, localIssues, remoteIssues []*beads.Issue) error { + + // Build interactive conflicts list + var interactiveConflicts []InteractiveConflict + for _, c := range conflictState.Conflicts { + interactiveConflicts = append(interactiveConflicts, InteractiveConflict{ + IssueID: c.IssueID, + Local: localMap[c.IssueID], + Remote: remoteMap[c.IssueID], + Base: baseMap[c.IssueID], + }) + } + + // Run interactive resolution + resolvedIssues, skipped, err := resolveConflictsInteractively(interactiveConflicts) + if err != nil { + return fmt.Errorf("interactive resolution: %w", err) + } + + if skipped > 0 { + fmt.Printf("\n⚠ %d conflict(s) skipped - will remain unresolved\n", skipped) + } + + if len(resolvedIssues) == 0 && skipped == len(conflictState.Conflicts) { + fmt.Println("No conflicts were resolved") + return nil + } + + // Build the merged issue list: + // 1. Start with issues that weren't in conflict + // 2. Add the resolved issues + conflictIDSet := make(map[string]bool) + for _, c := range conflictState.Conflicts { + conflictIDSet[c.IssueID] = true + } + + // Build resolved issue map for quick lookup + resolvedMap := make(map[string]*beads.Issue) + for _, issue := range resolvedIssues { + if issue != nil { + resolvedMap[issue.ID] = issue + } + } + + // Collect all unique IDs from base, local, remote + allIDSet := make(map[string]bool) + for id := range baseMap { + allIDSet[id] = true + } + for id := range localMap { + allIDSet[id] = true + } + for id := range remoteMap { + allIDSet[id] = true + } + + // Build final merged list + var mergedIssues []*beads.Issue + for id := range allIDSet { + if conflictIDSet[id] { + // This was a conflict - use the resolved version if available + if resolved, ok := resolvedMap[id]; ok { + 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 { + mergedIssues = append(mergedIssues, local) + } else if remote != nil { + mergedIssues = append(mergedIssues, remote) + } + } + } else { + // Not a conflict - use standard 3-way merge logic + local := localMap[id] + remote := remoteMap[id] + base := baseMap[id] + merged, _ := MergeIssue(base, local, remote) + if merged != nil { + mergedIssues = append(mergedIssues, merged) + } + } + } + + // Clear resolved conflicts (keep skipped ones) + if skipped == 0 { + if err := ClearSyncConflictState(beadsDir); err != nil { + return fmt.Errorf("clearing conflict state: %w", err) + } + } else { + // Update conflict state to only keep skipped conflicts + var remaining []SyncConflictRecord + for _, c := range conflictState.Conflicts { + if _, resolved := resolvedMap[c.IssueID]; !resolved { + remaining = append(remaining, c) + } + } + conflictState.Conflicts = remaining + if err := SaveSyncConflictState(beadsDir, conflictState); err != nil { + return fmt.Errorf("saving updated conflict state: %w", err) + } + } + + // Write merged state + if err := writeMergedStateToJSONL(jsonlPath, mergedIssues); err != nil { + return fmt.Errorf("writing merged state: %w", err) + } + + // Import to database + if err := importFromJSONLInline(ctx, jsonlPath, false, false); err != nil { + return fmt.Errorf("importing merged state: %w", err) + } + + // Export to ensure consistency + if err := exportToJSONL(ctx, jsonlPath); err != nil { + return fmt.Errorf("exporting: %w", err) + } + + // Update base state + finalIssues, err := loadIssuesFromJSONL(jsonlPath) + if err != nil { + return fmt.Errorf("reloading final state: %w", err) + } + if err := saveBaseState(beadsDir, finalIssues); err != nil { + return fmt.Errorf("saving base state: %w", err) + } + + resolvedCount := len(resolvedIssues) + fmt.Printf("\n✓ Manual resolution complete (%d resolved, %d skipped)\n", resolvedCount, skipped) + + return nil +} + func init() { syncCmd.Flags().StringP("message", "m", "", "Commit message (default: auto-generated)") syncCmd.Flags().Bool("dry-run", false, "Preview sync without making changes") @@ -982,6 +1146,7 @@ func init() { syncCmd.Flags().Bool("resolve", false, "Resolve pending sync conflicts") syncCmd.Flags().Bool("ours", false, "Use 'ours' strategy for conflict resolution (with --resolve)") syncCmd.Flags().Bool("theirs", false, "Use 'theirs' strategy for conflict resolution (with --resolve)") + syncCmd.Flags().Bool("manual", false, "Use interactive manual resolution for conflicts (with --resolve)") syncCmd.Flags().Bool("force", false, "Force full export/import (skip incremental optimization)") rootCmd.AddCommand(syncCmd) } diff --git a/cmd/bd/sync_manual.go b/cmd/bd/sync_manual.go new file mode 100644 index 00000000..79cfbbed --- /dev/null +++ b/cmd/bd/sync_manual.go @@ -0,0 +1,332 @@ +package main + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/ui" +) + +// InteractiveConflict represents a conflict to be resolved interactively +type InteractiveConflict struct { + IssueID string + Local *beads.Issue + Remote *beads.Issue + Base *beads.Issue // May be nil for first sync +} + +// 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) +} + +// resolveConflictsInteractively handles manual conflict resolution with user prompts. +// Returns resolved issues and the count of skipped conflicts. +func resolveConflictsInteractively(conflicts []InteractiveConflict) ([]*beads.Issue, int, error) { + // Check if we're in a terminal + if !ui.IsTerminal() { + return nil, 0, fmt.Errorf("manual conflict resolution requires an interactive terminal") + } + + reader := bufio.NewReader(os.Stdin) + var resolved []*beads.Issue + skipped := 0 + + fmt.Printf("\n%s Manual Conflict Resolution\n", ui.RenderAccent("🔧")) + fmt.Printf("Found %d conflict(s) requiring manual resolution.\n\n", len(conflicts)) + + for i, conflict := range conflicts { + fmt.Printf("%s Conflict %d/%d: %s\n", ui.RenderAccent("━━━"), i+1, len(conflicts), conflict.IssueID) + fmt.Println() + + // Display the diff + displayConflictDiff(conflict) + + // Prompt for choice + resolution, err := promptConflictResolution(reader, conflict) + if err != nil { + return nil, 0, fmt.Errorf("reading user input: %w", err) + } + + switch resolution.Choice { + case "skip": + skipped++ + fmt.Printf(" %s Skipped\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("✓")) + } + } + + return resolved, skipped, nil +} + +// displayConflictDiff shows the differences between local and remote versions +func displayConflictDiff(conflict InteractiveConflict) { + local := conflict.Local + remote := conflict.Remote + + if local == nil && remote == nil { + fmt.Println(" Both versions are nil (should not happen)") + return + } + + if local == nil { + fmt.Printf(" %s Local: (deleted)\n", ui.RenderMuted("LOCAL")) + fmt.Printf(" %s Remote: exists\n", ui.RenderAccent("REMOTE")) + displayIssueSummary(remote, " ") + return + } + + if remote == nil { + fmt.Printf(" %s Local: exists\n", ui.RenderAccent("LOCAL")) + displayIssueSummary(local, " ") + fmt.Printf(" %s Remote: (deleted)\n", ui.RenderMuted("REMOTE")) + return + } + + // Both exist - show field-by-field diff + fmt.Printf(" %s\n", ui.RenderMuted("─── Field Differences ───")) + fmt.Println() + + // Title + if local.Title != remote.Title { + fmt.Printf(" %s\n", ui.RenderAccent("title:")) + fmt.Printf(" %s %s\n", ui.RenderMuted("local:"), local.Title) + fmt.Printf(" %s %s\n", ui.RenderAccent("remote:"), remote.Title) + } + + // Status + if local.Status != remote.Status { + fmt.Printf(" %s\n", ui.RenderAccent("status:")) + fmt.Printf(" %s %s\n", ui.RenderMuted("local:"), local.Status) + fmt.Printf(" %s %s\n", ui.RenderAccent("remote:"), remote.Status) + } + + // Priority + if local.Priority != remote.Priority { + fmt.Printf(" %s\n", ui.RenderAccent("priority:")) + fmt.Printf(" %s P%d\n", ui.RenderMuted("local:"), local.Priority) + fmt.Printf(" %s P%d\n", ui.RenderAccent("remote:"), remote.Priority) + } + + // Assignee + if local.Assignee != remote.Assignee { + fmt.Printf(" %s\n", ui.RenderAccent("assignee:")) + fmt.Printf(" %s %s\n", ui.RenderMuted("local:"), valueOrNone(local.Assignee)) + fmt.Printf(" %s %s\n", ui.RenderAccent("remote:"), valueOrNone(remote.Assignee)) + } + + // Description (show truncated if different) + if local.Description != remote.Description { + fmt.Printf(" %s\n", ui.RenderAccent("description:")) + fmt.Printf(" %s %s\n", ui.RenderMuted("local:"), truncateText(local.Description, 60)) + fmt.Printf(" %s %s\n", ui.RenderAccent("remote:"), truncateText(remote.Description, 60)) + } + + // Notes (show truncated if different) + if local.Notes != remote.Notes { + fmt.Printf(" %s\n", ui.RenderAccent("notes:")) + fmt.Printf(" %s %s\n", ui.RenderMuted("local:"), truncateText(local.Notes, 60)) + fmt.Printf(" %s %s\n", ui.RenderAccent("remote:"), truncateText(remote.Notes, 60)) + } + + // Labels + localLabels := strings.Join(local.Labels, ", ") + remoteLabels := strings.Join(remote.Labels, ", ") + if localLabels != remoteLabels { + fmt.Printf(" %s\n", ui.RenderAccent("labels:")) + fmt.Printf(" %s [%s]\n", ui.RenderMuted("local:"), valueOrNone(localLabels)) + fmt.Printf(" %s [%s]\n", ui.RenderAccent("remote:"), valueOrNone(remoteLabels)) + } + + // Updated timestamps + fmt.Printf(" %s\n", ui.RenderAccent("updated_at:")) + fmt.Printf(" %s %s\n", ui.RenderMuted("local:"), local.UpdatedAt.Format("2006-01-02 15:04:05")) + fmt.Printf(" %s %s\n", ui.RenderAccent("remote:"), remote.UpdatedAt.Format("2006-01-02 15:04:05")) + + // Indicate which is newer + if local.UpdatedAt.After(remote.UpdatedAt) { + fmt.Printf(" %s\n", ui.RenderPass("(local is newer)")) + } else if remote.UpdatedAt.After(local.UpdatedAt) { + fmt.Printf(" %s\n", ui.RenderPass("(remote is newer)")) + } else { + fmt.Printf(" %s\n", ui.RenderMuted("(same timestamp)")) + } + + fmt.Println() +} + +// displayIssueSummary shows a brief summary of an issue +func displayIssueSummary(issue *beads.Issue, indent string) { + if issue == nil { + return + } + fmt.Printf("%stitle: %s\n", indent, issue.Title) + fmt.Printf("%sstatus: %s, priority: P%d\n", indent, issue.Status, issue.Priority) + if issue.Assignee != "" { + fmt.Printf("%sassignee: %s\n", indent, issue.Assignee) + } +} + +// promptConflictResolution asks the user how to resolve a conflict +func promptConflictResolution(reader *bufio.Reader, conflict InteractiveConflict) (InteractiveResolution, error) { + local := conflict.Local + remote := conflict.Remote + + // Build options based on what's available + var options []string + var optionMap = make(map[string]string) + + if local != nil { + options = append(options, "l") + optionMap["l"] = "local" + optionMap["local"] = "local" + } + if remote != nil { + options = append(options, "r") + optionMap["r"] = "remote" + optionMap["remote"] = "remote" + } + if local != nil && remote != nil { + options = append(options, "m") + optionMap["m"] = "merged" + optionMap["merge"] = "merged" + optionMap["merged"] = "merged" + } + options = append(options, "s", "d", "?") + optionMap["s"] = "skip" + optionMap["skip"] = "skip" + optionMap["d"] = "diff" + optionMap["diff"] = "diff" + optionMap["?"] = "help" + optionMap["help"] = "help" + + for { + fmt.Printf(" Choice [%s]: ", strings.Join(options, "/")) + input, err := reader.ReadString('\n') + if err != nil { + return InteractiveResolution{}, err + } + + choice := strings.TrimSpace(strings.ToLower(input)) + if choice == "" { + // Default to merged if both exist, otherwise keep the one that exists + if local != nil && remote != nil { + choice = "m" + } else if local != nil { + choice = "l" + } else { + choice = "r" + } + } + + action, ok := optionMap[choice] + if !ok { + fmt.Printf(" %s Unknown option '%s'. Type '?' for help.\n", ui.RenderFail("✗"), choice) + continue + } + + switch action { + case "help": + printResolutionHelp(local != nil, remote != nil) + continue + + case "diff": + // Show detailed diff (JSON dump) + showDetailedDiff(conflict) + continue + + case "local": + return InteractiveResolution{Choice: "local", Issue: local}, nil + + case "remote": + return InteractiveResolution{Choice: "remote", Issue: remote}, nil + + case "merged": + // Do field-level merge (same as automatic LWW merge) + merged := mergeFieldLevel(conflict.Base, local, remote) + return InteractiveResolution{Choice: "merged", Issue: merged}, nil + + case "skip": + return InteractiveResolution{Choice: "skip", Issue: nil}, nil + } + } +} + +// printResolutionHelp shows help for resolution options +func printResolutionHelp(hasLocal, hasRemote bool) { + fmt.Println() + fmt.Println(" Resolution options:") + if hasLocal { + fmt.Println(" l, local - Keep the local version") + } + if hasRemote { + fmt.Println(" r, remote - Keep the remote version") + } + 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(" d, diff - Show detailed JSON diff") + fmt.Println(" ?, help - Show this help") + fmt.Println() +} + +// showDetailedDiff displays the full JSON of both versions +func showDetailedDiff(conflict InteractiveConflict) { + fmt.Println() + fmt.Printf(" %s\n", ui.RenderMuted("─── Detailed Diff (JSON) ───")) + fmt.Println() + + if conflict.Local != nil { + fmt.Printf(" %s\n", ui.RenderAccent("LOCAL:")) + localJSON, _ := json.MarshalIndent(conflict.Local, " ", " ") + fmt.Println(string(localJSON)) + fmt.Println() + } else { + fmt.Printf(" %s (deleted)\n", ui.RenderMuted("LOCAL:")) + } + + if conflict.Remote != nil { + fmt.Printf(" %s\n", ui.RenderAccent("REMOTE:")) + remoteJSON, _ := json.MarshalIndent(conflict.Remote, " ", " ") + fmt.Println(string(remoteJSON)) + fmt.Println() + } else { + fmt.Printf(" %s (deleted)\n", ui.RenderMuted("REMOTE:")) + } +} + +// Helper functions + +func valueOrNone(s string) string { + if s == "" { + return "(none)" + } + return s +} + +func truncateText(s string, maxLen int) string { + if s == "" { + return "(empty)" + } + // Replace newlines with spaces for display + s = strings.ReplaceAll(s, "\n", " ") + s = strings.ReplaceAll(s, "\r", "") + if len(s) > maxLen { + return s[:maxLen-3] + "..." + } + return s +} diff --git a/cmd/bd/sync_manual_test.go b/cmd/bd/sync_manual_test.go new file mode 100644 index 00000000..98f58d95 --- /dev/null +++ b/cmd/bd/sync_manual_test.go @@ -0,0 +1,274 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/steveyegge/beads/internal/beads" +) + +func TestTruncateText(t *testing.T) { + tests := []struct { + name string + input string + maxLen int + want string + }{ + { + name: "empty string", + input: "", + maxLen: 10, + want: "(empty)", + }, + { + name: "short string", + input: "hello", + maxLen: 10, + want: "hello", + }, + { + name: "exact length", + input: "0123456789", + maxLen: 10, + want: "0123456789", + }, + { + name: "truncated", + input: "this is a very long string", + maxLen: 15, + want: "this is a ve...", + }, + { + name: "newlines replaced", + input: "line1\nline2\nline3", + maxLen: 30, + want: "line1 line2 line3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := truncateText(tt.input, tt.maxLen) + if got != tt.want { + t.Errorf("truncateText(%q, %d) = %q, want %q", tt.input, tt.maxLen, got, tt.want) + } + }) + } +} + +func TestValueOrNone(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", "(none)"}, + {"value", "value"}, + {" ", " "}, + } + + for _, tt := range tests { + got := valueOrNone(tt.input) + if got != tt.want { + t.Errorf("valueOrNone(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} + +func TestInteractiveConflictDisplay(t *testing.T) { + now := time.Now() + earlier := now.Add(-1 * time.Hour) + + // Test that displayConflictDiff doesn't panic for various inputs + tests := []struct { + name string + conflict InteractiveConflict + }{ + { + name: "both exist with differences", + conflict: InteractiveConflict{ + IssueID: "test-1", + Local: &beads.Issue{ + ID: "test-1", + Title: "Local title", + Status: beads.StatusOpen, + Priority: 1, + UpdatedAt: now, + }, + Remote: &beads.Issue{ + ID: "test-1", + Title: "Remote title", + Status: beads.StatusInProgress, + Priority: 2, + UpdatedAt: earlier, + }, + }, + }, + { + name: "local deleted", + conflict: InteractiveConflict{ + IssueID: "test-2", + Local: nil, + Remote: &beads.Issue{ + ID: "test-2", + Title: "Remote only", + Status: beads.StatusOpen, + UpdatedAt: now, + }, + }, + }, + { + name: "remote deleted", + conflict: InteractiveConflict{ + IssueID: "test-3", + Local: &beads.Issue{ + ID: "test-3", + Title: "Local only", + Status: beads.StatusOpen, + UpdatedAt: now, + }, + Remote: nil, + }, + }, + { + name: "same timestamps", + conflict: InteractiveConflict{ + IssueID: "test-4", + Local: &beads.Issue{ + ID: "test-4", + Title: "Same time local", + UpdatedAt: now, + }, + Remote: &beads.Issue{ + ID: "test-4", + Title: "Same time remote", + UpdatedAt: now, + }, + }, + }, + { + name: "with labels", + conflict: InteractiveConflict{ + IssueID: "test-5", + Local: &beads.Issue{ + ID: "test-5", + Title: "Local", + Labels: []string{"bug", "urgent"}, + UpdatedAt: now, + }, + Remote: &beads.Issue{ + ID: "test-5", + Title: "Remote", + Labels: []string{"feature", "low-priority"}, + UpdatedAt: earlier, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Just make sure it doesn't panic + displayConflictDiff(tt.conflict) + }) + } +} + +func TestShowDetailedDiff(t *testing.T) { + now := time.Now() + + 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, + }, + } + + // Just make sure it doesn't panic + showDetailedDiff(conflict) +} + +func TestPrintResolutionHelp(t *testing.T) { + // Test all combinations of hasLocal/hasRemote + tests := []struct { + hasLocal bool + hasRemote bool + }{ + {true, true}, + {true, false}, + {false, true}, + {false, false}, + } + + for _, tt := range tests { + // Just make sure it doesn't panic + printResolutionHelp(tt.hasLocal, tt.hasRemote) + } +} + +func TestDisplayIssueSummary(t *testing.T) { + issue := &beads.Issue{ + ID: "test-1", + Title: "Test issue", + Status: beads.StatusOpen, + Priority: 2, + Assignee: "alice", + } + + // Just make sure it doesn't panic + displayIssueSummary(issue, " ") + displayIssueSummary(nil, " ") +} + +func TestInteractiveResolutionMerge(t *testing.T) { + // Test that mergeFieldLevel is called correctly in resolution + now := time.Now() + earlier := now.Add(-1 * time.Hour) + + local := &beads.Issue{ + ID: "test-1", + Title: "Local title", + Status: beads.StatusOpen, + Priority: 1, + Labels: []string{"bug"}, + UpdatedAt: now, + } + + remote := &beads.Issue{ + ID: "test-1", + Title: "Remote title", + Status: beads.StatusInProgress, + Priority: 2, + Labels: []string{"feature"}, + UpdatedAt: earlier, + } + + // mergeFieldLevel should pick local values (newer) for scalars + // and union for labels + merged := mergeFieldLevel(nil, local, remote) + + if merged.Title != "Local title" { + t.Errorf("Expected title 'Local title', got %q", merged.Title) + } + if merged.Status != beads.StatusOpen { + t.Errorf("Expected status 'open', got %q", merged.Status) + } + if merged.Priority != 1 { + t.Errorf("Expected priority 1, got %d", merged.Priority) + } + // Labels should be merged (union) + if len(merged.Labels) != 2 { + t.Errorf("Expected 2 labels, got %d", len(merged.Labels)) + } + labelsStr := strings.Join(merged.Labels, ",") + if !strings.Contains(labelsStr, "bug") || !strings.Contains(labelsStr, "feature") { + t.Errorf("Expected labels to contain 'bug' and 'feature', got %v", merged.Labels) + } +}