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 <noreply@anthropic.com>
This commit is contained in:
175
cmd/bd/sync.go
175
cmd/bd/sync.go
@@ -30,10 +30,23 @@ Commands:
|
|||||||
bd sync Export to JSONL (prep for push)
|
bd sync Export to JSONL (prep for push)
|
||||||
bd sync --import Import from JSONL (after pull)
|
bd sync --import Import from JSONL (after pull)
|
||||||
bd sync --status Show sync state
|
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 --force Force full export/import (skip incremental)
|
||||||
bd sync --full Full sync: pull → merge → export → commit → push (legacy)
|
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.`,
|
The --full flag provides the legacy full sync behavior for backwards compatibility.`,
|
||||||
Run: func(cmd *cobra.Command, _ []string) {
|
Run: func(cmd *cobra.Command, _ []string) {
|
||||||
CheckReadonly("sync")
|
CheckReadonly("sync")
|
||||||
@@ -58,6 +71,7 @@ The --full flag provides the legacy full sync behavior for backwards compatibili
|
|||||||
resolve, _ := cmd.Flags().GetBool("resolve")
|
resolve, _ := cmd.Flags().GetBool("resolve")
|
||||||
resolveOurs, _ := cmd.Flags().GetBool("ours")
|
resolveOurs, _ := cmd.Flags().GetBool("ours")
|
||||||
resolveTheirs, _ := cmd.Flags().GetBool("theirs")
|
resolveTheirs, _ := cmd.Flags().GetBool("theirs")
|
||||||
|
resolveManual, _ := cmd.Flags().GetBool("manual")
|
||||||
forceFlag, _ := cmd.Flags().GetBool("force")
|
forceFlag, _ := cmd.Flags().GetBool("force")
|
||||||
|
|
||||||
// --import is shorthand for --import-only
|
// --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
|
strategy = config.ConflictStrategyOurs
|
||||||
} else if resolveTheirs {
|
} else if resolveTheirs {
|
||||||
strategy = config.ConflictStrategyTheirs
|
strategy = config.ConflictStrategyTheirs
|
||||||
|
} else if resolveManual {
|
||||||
|
strategy = config.ConflictStrategyManual
|
||||||
}
|
}
|
||||||
if err := resolveSyncConflicts(ctx, jsonlPath, strategy, dryRun); err != nil {
|
if err := resolveSyncConflicts(ctx, jsonlPath, strategy, dryRun); err != nil {
|
||||||
FatalError("%v", err)
|
FatalError("%v", err)
|
||||||
@@ -831,6 +847,7 @@ func ClearSyncConflictState(beadsDir string) error {
|
|||||||
// - "newest": Keep whichever version has the newer updated_at timestamp (default)
|
// - "newest": Keep whichever version has the newer updated_at timestamp (default)
|
||||||
// - "ours": Keep local version
|
// - "ours": Keep local version
|
||||||
// - "theirs": Keep remote version
|
// - "theirs": Keep remote version
|
||||||
|
// - "manual": Interactive resolution with user prompts
|
||||||
func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string, dryRun bool) error {
|
func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string, dryRun bool) error {
|
||||||
beadsDir := filepath.Dir(jsonlPath)
|
beadsDir := filepath.Dir(jsonlPath)
|
||||||
|
|
||||||
@@ -875,6 +892,10 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build maps for quick lookup
|
// 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)
|
localMap := make(map[string]*beads.Issue)
|
||||||
for _, issue := range localIssues {
|
for _, issue := range localIssues {
|
||||||
localMap[issue.ID] = issue
|
localMap[issue.ID] = issue
|
||||||
@@ -884,6 +905,11 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string
|
|||||||
remoteMap[issue.ID] = issue
|
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
|
resolved := 0
|
||||||
for _, conflict := range conflictState.Conflicts {
|
for _, conflict := range conflictState.Conflicts {
|
||||||
local := localMap[conflict.IssueID]
|
local := localMap[conflict.IssueID]
|
||||||
@@ -895,10 +921,6 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string
|
|||||||
winner = "local"
|
winner = "local"
|
||||||
case config.ConflictStrategyTheirs:
|
case config.ConflictStrategyTheirs:
|
||||||
winner = "remote"
|
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:
|
case config.ConflictStrategyNewest:
|
||||||
fallthrough
|
fallthrough
|
||||||
default:
|
default:
|
||||||
@@ -961,6 +983,148 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string
|
|||||||
return nil
|
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() {
|
func init() {
|
||||||
syncCmd.Flags().StringP("message", "m", "", "Commit message (default: auto-generated)")
|
syncCmd.Flags().StringP("message", "m", "", "Commit message (default: auto-generated)")
|
||||||
syncCmd.Flags().Bool("dry-run", false, "Preview sync without making changes")
|
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("resolve", false, "Resolve pending sync conflicts")
|
||||||
syncCmd.Flags().Bool("ours", false, "Use 'ours' strategy for conflict resolution (with --resolve)")
|
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("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)")
|
syncCmd.Flags().Bool("force", false, "Force full export/import (skip incremental optimization)")
|
||||||
rootCmd.AddCommand(syncCmd)
|
rootCmd.AddCommand(syncCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
332
cmd/bd/sync_manual.go
Normal file
332
cmd/bd/sync_manual.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
274
cmd/bd/sync_manual_test.go
Normal file
274
cmd/bd/sync_manual_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user