feat(sync): add per-field merge strategies for conflict resolution
Implements configurable per-field merge strategies (hq-ew1mbr.11):
- Add FieldStrategy type with strategies: newest, max, union, manual
- Add conflict.fields config section for per-field overrides
- compaction_level defaults to "max" (highest value wins)
- estimated_minutes defaults to "manual" (flags for user resolution)
- labels defaults to "union" (set merge)
Manual conflicts are displayed during sync with resolution options:
bd sync --ours / --theirs, or bd resolve <id> <field> <value>
Config example:
conflict:
strategy: newest
fields:
compaction_level: max
estimated_minutes: manual
labels: union
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
e0dc3a37c3
commit
9a9704b451
+11
-1
@@ -572,6 +572,11 @@ func doPullFirstSync(ctx context.Context, jsonlPath string, renameOnImport, noGi
|
|||||||
fmt.Printf(" Local wins: %d, Remote wins: %d, Same: %d, Conflicts (LWW): %d\n",
|
fmt.Printf(" Local wins: %d, Remote wins: %d, Same: %d, Conflicts (LWW): %d\n",
|
||||||
localCount, remoteCount, sameCount, mergeResult.Conflicts)
|
localCount, remoteCount, sameCount, mergeResult.Conflicts)
|
||||||
|
|
||||||
|
// Display manual conflicts that need user resolution
|
||||||
|
if len(mergeResult.ManualConflicts) > 0 {
|
||||||
|
displayManualConflicts(mergeResult.ManualConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
// Step 6: Import merged state to DB
|
// Step 6: Import merged state to DB
|
||||||
// First, write merged result to JSONL so import can read it
|
// First, write merged result to JSONL so import can read it
|
||||||
fmt.Println("→ Writing merged state to JSONL...")
|
fmt.Println("→ Writing merged state to JSONL...")
|
||||||
@@ -1071,6 +1076,11 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy config
|
|||||||
// Re-run merge with the resolved conflicts
|
// Re-run merge with the resolved conflicts
|
||||||
mergeResult := MergeIssues(baseIssues, localIssues, remoteIssues)
|
mergeResult := MergeIssues(baseIssues, localIssues, remoteIssues)
|
||||||
|
|
||||||
|
// Display any remaining manual conflicts
|
||||||
|
if len(mergeResult.ManualConflicts) > 0 {
|
||||||
|
displayManualConflicts(mergeResult.ManualConflicts)
|
||||||
|
}
|
||||||
|
|
||||||
// Write merged state
|
// Write merged state
|
||||||
if err := writeMergedStateToJSONL(jsonlPath, mergeResult.Merged); err != nil {
|
if err := writeMergedStateToJSONL(jsonlPath, mergeResult.Merged); err != nil {
|
||||||
return fmt.Errorf("writing merged state: %w", err)
|
return fmt.Errorf("writing merged state: %w", err)
|
||||||
@@ -1177,7 +1187,7 @@ func resolveSyncConflictsManually(ctx context.Context, jsonlPath, beadsDir strin
|
|||||||
local := localMap[id]
|
local := localMap[id]
|
||||||
remote := remoteMap[id]
|
remote := remoteMap[id]
|
||||||
base := baseMap[id]
|
base := baseMap[id]
|
||||||
merged, _ := MergeIssue(base, local, remote)
|
merged, _, _ := MergeIssue(base, local, remote)
|
||||||
if merged != nil {
|
if merged != nil {
|
||||||
mergedIssues = append(mergedIssues, merged)
|
mergedIssues = append(mergedIssues, merged)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ func resolveConflictsInteractively(conflicts []InteractiveConflict) ([]*beads.Is
|
|||||||
for j := i; j < len(conflicts); j++ {
|
for j := i; j < len(conflicts); j++ {
|
||||||
c := conflicts[j]
|
c := conflicts[j]
|
||||||
if c.Local != nil && c.Remote != nil {
|
if c.Local != nil && c.Remote != nil {
|
||||||
merged := mergeFieldLevel(c.Base, c.Local, c.Remote)
|
merged, _ := mergeFieldLevel(c.Base, c.Local, c.Remote)
|
||||||
resolved = append(resolved, merged)
|
resolved = append(resolved, merged)
|
||||||
} else if c.Local != nil {
|
} else if c.Local != nil {
|
||||||
resolved = append(resolved, c.Local)
|
resolved = append(resolved, c.Local)
|
||||||
@@ -293,7 +293,7 @@ func promptConflictResolution(reader *bufio.Reader, conflict InteractiveConflict
|
|||||||
|
|
||||||
case "merged":
|
case "merged":
|
||||||
// Do field-level merge (same as automatic LWW merge)
|
// Do field-level merge (same as automatic LWW merge)
|
||||||
merged := mergeFieldLevel(conflict.Base, local, remote)
|
merged, _ := mergeFieldLevel(conflict.Base, local, remote)
|
||||||
return InteractiveResolution{Choice: "merged", Issue: merged}, nil
|
return InteractiveResolution{Choice: "merged", Issue: merged}, nil
|
||||||
|
|
||||||
case "skip":
|
case "skip":
|
||||||
|
|||||||
@@ -285,7 +285,7 @@ func TestInteractiveResolutionMerge(t *testing.T) {
|
|||||||
|
|
||||||
// mergeFieldLevel should pick local values (newer) for scalars
|
// mergeFieldLevel should pick local values (newer) for scalars
|
||||||
// and union for labels
|
// and union for labels
|
||||||
merged := mergeFieldLevel(nil, local, remote)
|
merged, _ := mergeFieldLevel(nil, local, remote)
|
||||||
|
|
||||||
if merged.Title != "Local title" {
|
if merged.Title != "Local title" {
|
||||||
t.Errorf("Expected title 'Local title', got %q", merged.Title)
|
t.Errorf("Expected title 'Local title', got %q", merged.Title)
|
||||||
|
|||||||
+170
-54
@@ -10,13 +10,27 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/steveyegge/beads/internal/beads"
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
|
"github.com/steveyegge/beads/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MergeResult contains the outcome of a 3-way merge
|
// MergeResult contains the outcome of a 3-way merge
|
||||||
type MergeResult struct {
|
type MergeResult struct {
|
||||||
Merged []*beads.Issue // Final merged state
|
Merged []*beads.Issue // Final merged state
|
||||||
Conflicts int // Number of true conflicts resolved
|
Conflicts int // Number of true conflicts resolved
|
||||||
Strategy map[string]string // Per-issue: "local", "remote", "merged", "same"
|
Strategy map[string]string // Per-issue: "local", "remote", "merged", "same"
|
||||||
|
ManualConflicts []ManualConflict // Fields requiring manual resolution
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManualConflict represents a field conflict that requires manual user resolution.
|
||||||
|
// When a field's strategy is "manual", the conflict is flagged here instead of
|
||||||
|
// being auto-resolved.
|
||||||
|
type ManualConflict struct {
|
||||||
|
IssueID string // Issue ID with conflict
|
||||||
|
Field string // Field name (e.g., "estimated_minutes")
|
||||||
|
LocalValue interface{} // Local (ours) value
|
||||||
|
RemoteValue interface{} // Remote (theirs) value
|
||||||
|
LocalTime time.Time // When local value was set
|
||||||
|
RemoteTime time.Time // When remote value was set
|
||||||
}
|
}
|
||||||
|
|
||||||
// MergeStrategy constants for describing how each issue was merged
|
// MergeStrategy constants for describing how each issue was merged
|
||||||
@@ -27,43 +41,19 @@ const (
|
|||||||
StrategySame = "same" // Both made identical change (or no change)
|
StrategySame = "same" // Both made identical change (or no change)
|
||||||
)
|
)
|
||||||
|
|
||||||
// FieldMergeRule defines how a specific field is merged in conflicts
|
|
||||||
type FieldMergeRule string
|
|
||||||
|
|
||||||
const (
|
|
||||||
RuleLWW FieldMergeRule = "lww" // Last-Write-Wins by updated_at
|
|
||||||
RuleUnion FieldMergeRule = "union" // Set union (OR-Set)
|
|
||||||
RuleAppend FieldMergeRule = "append" // Append-only merge
|
|
||||||
)
|
|
||||||
|
|
||||||
// FieldRules maps field names to merge rules
|
|
||||||
// Scalar fields use LWW, collection fields use union/append
|
|
||||||
var FieldRules = map[string]FieldMergeRule{
|
|
||||||
// Scalar fields - LWW by updated_at
|
|
||||||
"status": RuleLWW,
|
|
||||||
"priority": RuleLWW,
|
|
||||||
"assignee": RuleLWW,
|
|
||||||
"title": RuleLWW,
|
|
||||||
"description": RuleLWW,
|
|
||||||
"design": RuleLWW,
|
|
||||||
"issue_type": RuleLWW,
|
|
||||||
"notes": RuleLWW,
|
|
||||||
|
|
||||||
// Set fields - union (no data loss)
|
|
||||||
"labels": RuleUnion,
|
|
||||||
"dependencies": RuleUnion,
|
|
||||||
|
|
||||||
// Append-only fields
|
|
||||||
"comments": RuleAppend,
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeFieldLevel performs field-by-field merge for true conflicts.
|
// mergeFieldLevel performs field-by-field merge for true conflicts.
|
||||||
// Returns a new issue with:
|
// Returns a new issue with:
|
||||||
// - Scalar fields: from the newer issue (LWW by updated_at, remote wins on tie)
|
// - Scalar fields: from the newer issue (LWW by updated_at, remote wins on tie)
|
||||||
// - Labels: union of both
|
// - Labels: union of both
|
||||||
// - Dependencies: union of both (by DependsOnID+Type)
|
// - Dependencies: union of both (by DependsOnID+Type)
|
||||||
// - Comments: append from both (deduplicated by ID or content)
|
// - Comments: append from both (deduplicated by ID or content)
|
||||||
func mergeFieldLevel(_base, local, remote *beads.Issue) *beads.Issue {
|
// - compaction_level: max strategy (highest value wins)
|
||||||
|
// - estimated_minutes: manual strategy if configured (flags for user resolution)
|
||||||
|
//
|
||||||
|
// Also returns any manual conflicts that require user resolution.
|
||||||
|
func mergeFieldLevel(_base, local, remote *beads.Issue) (*beads.Issue, []ManualConflict) {
|
||||||
|
var manualConflicts []ManualConflict
|
||||||
|
|
||||||
// Determine which is newer for LWW scalars
|
// Determine which is newer for LWW scalars
|
||||||
localNewer := local.UpdatedAt.After(remote.UpdatedAt)
|
localNewer := local.UpdatedAt.After(remote.UpdatedAt)
|
||||||
|
|
||||||
@@ -85,8 +75,66 @@ func mergeFieldLevel(_base, local, remote *beads.Issue) *beads.Issue {
|
|||||||
merged = *remote
|
merged = *remote
|
||||||
}
|
}
|
||||||
|
|
||||||
// Union merge: Labels
|
// Get per-field strategies from config
|
||||||
merged.Labels = mergeLabels(local.Labels, remote.Labels)
|
fieldStrategies := config.GetFieldStrategies()
|
||||||
|
|
||||||
|
// Handle compaction_level with configurable strategy (default: max)
|
||||||
|
compactionStrategy := fieldStrategies["compaction_level"]
|
||||||
|
if compactionStrategy == "" {
|
||||||
|
compactionStrategy = config.FieldStrategyMax // Default for compaction_level
|
||||||
|
}
|
||||||
|
switch compactionStrategy {
|
||||||
|
case config.FieldStrategyMax:
|
||||||
|
merged.CompactionLevel = maxInt(local.CompactionLevel, remote.CompactionLevel)
|
||||||
|
case config.FieldStrategyNewest:
|
||||||
|
// Already handled by default LWW merge above
|
||||||
|
case config.FieldStrategyManual:
|
||||||
|
if local.CompactionLevel != remote.CompactionLevel {
|
||||||
|
manualConflicts = append(manualConflicts, ManualConflict{
|
||||||
|
IssueID: local.ID,
|
||||||
|
Field: "compaction_level",
|
||||||
|
LocalValue: local.CompactionLevel,
|
||||||
|
RemoteValue: remote.CompactionLevel,
|
||||||
|
LocalTime: local.UpdatedAt,
|
||||||
|
RemoteTime: remote.UpdatedAt,
|
||||||
|
})
|
||||||
|
// Keep local value as tentative (can be overridden by resolve command)
|
||||||
|
merged.CompactionLevel = local.CompactionLevel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle estimated_minutes with configurable strategy (default: manual per spec)
|
||||||
|
estimatedStrategy := fieldStrategies["estimated_minutes"]
|
||||||
|
if estimatedStrategy == "" {
|
||||||
|
estimatedStrategy = config.FieldStrategyManual // Default for estimated_minutes (human judgment needed)
|
||||||
|
}
|
||||||
|
switch estimatedStrategy {
|
||||||
|
case config.FieldStrategyMax:
|
||||||
|
merged.EstimatedMinutes = maxIntPtr(local.EstimatedMinutes, remote.EstimatedMinutes)
|
||||||
|
case config.FieldStrategyNewest:
|
||||||
|
// Already handled by default LWW merge above
|
||||||
|
case config.FieldStrategyManual:
|
||||||
|
if !intPtrEqual(local.EstimatedMinutes, remote.EstimatedMinutes) {
|
||||||
|
manualConflicts = append(manualConflicts, ManualConflict{
|
||||||
|
IssueID: local.ID,
|
||||||
|
Field: "estimated_minutes",
|
||||||
|
LocalValue: derefIntPtr(local.EstimatedMinutes),
|
||||||
|
RemoteValue: derefIntPtr(remote.EstimatedMinutes),
|
||||||
|
LocalTime: local.UpdatedAt,
|
||||||
|
RemoteTime: remote.UpdatedAt,
|
||||||
|
})
|
||||||
|
// Keep local value as tentative
|
||||||
|
merged.EstimatedMinutes = local.EstimatedMinutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Union merge: Labels (always union unless configured otherwise)
|
||||||
|
labelsStrategy := fieldStrategies["labels"]
|
||||||
|
if labelsStrategy == "" || labelsStrategy == config.FieldStrategyUnion {
|
||||||
|
merged.Labels = mergeLabels(local.Labels, remote.Labels)
|
||||||
|
} else if labelsStrategy == config.FieldStrategyNewest {
|
||||||
|
// Use LWW for labels (already set from initial merge)
|
||||||
|
}
|
||||||
|
|
||||||
// Union merge: Dependencies (by DependsOnID+Type key)
|
// Union merge: Dependencies (by DependsOnID+Type key)
|
||||||
merged.Dependencies = mergeDependencies(local.Dependencies, remote.Dependencies)
|
merged.Dependencies = mergeDependencies(local.Dependencies, remote.Dependencies)
|
||||||
@@ -94,7 +142,65 @@ func mergeFieldLevel(_base, local, remote *beads.Issue) *beads.Issue {
|
|||||||
// Append merge: Comments (deduplicated)
|
// Append merge: Comments (deduplicated)
|
||||||
merged.Comments = mergeComments(local.Comments, remote.Comments)
|
merged.Comments = mergeComments(local.Comments, remote.Comments)
|
||||||
|
|
||||||
return &merged
|
return &merged, manualConflicts
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxInt returns the larger of two integers
|
||||||
|
func maxInt(a, b int) int {
|
||||||
|
if a > b {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxIntPtr returns a pointer to the larger of two *int values
|
||||||
|
// Treats nil as 0 for comparison purposes
|
||||||
|
func maxIntPtr(a, b *int) *int {
|
||||||
|
aVal := 0
|
||||||
|
bVal := 0
|
||||||
|
if a != nil {
|
||||||
|
aVal = *a
|
||||||
|
}
|
||||||
|
if b != nil {
|
||||||
|
bVal = *b
|
||||||
|
}
|
||||||
|
if aVal >= bVal {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// derefIntPtr safely dereferences an int pointer, returning nil representation for display
|
||||||
|
func derefIntPtr(p *int) interface{} {
|
||||||
|
if p == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayManualConflicts prints manual conflicts that need user resolution.
|
||||||
|
// These are fields where the configured strategy is "manual" and values differ.
|
||||||
|
func displayManualConflicts(conflicts []ManualConflict) {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n⚠ %d field conflict(s) require manual resolution:\n", len(conflicts))
|
||||||
|
|
||||||
|
for _, c := range conflicts {
|
||||||
|
fmt.Fprintf(os.Stderr, "\n %s.%s:\n", c.IssueID, c.Field)
|
||||||
|
fmt.Fprintf(os.Stderr, " Local: %v (set %s)\n", formatConflictValue(c.LocalValue), c.LocalTime.Format("2006-01-02"))
|
||||||
|
fmt.Fprintf(os.Stderr, " Remote: %v (set %s)\n", formatConflictValue(c.RemoteValue), c.RemoteTime.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(os.Stderr, "\n To resolve, use one of:\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " bd sync --ours # Keep all local values\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " bd sync --theirs # Keep all remote values\n")
|
||||||
|
fmt.Fprintf(os.Stderr, " bd resolve <issue-id> <field> <value> # Set specific value\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatConflictValue formats a conflict value for display
|
||||||
|
func formatConflictValue(v interface{}) string {
|
||||||
|
if v == nil {
|
||||||
|
return "(not set)"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeLabels performs set union on labels
|
// mergeLabels performs set union on labels
|
||||||
@@ -244,8 +350,9 @@ func MergeIssues(base, local, remote []*beads.Issue) *MergeResult {
|
|||||||
allIDs := collectUniqueIDs(baseMap, localMap, remoteMap)
|
allIDs := collectUniqueIDs(baseMap, localMap, remoteMap)
|
||||||
|
|
||||||
result := &MergeResult{
|
result := &MergeResult{
|
||||||
Merged: make([]*beads.Issue, 0, len(allIDs)),
|
Merged: make([]*beads.Issue, 0, len(allIDs)),
|
||||||
Strategy: make(map[string]string),
|
Strategy: make(map[string]string),
|
||||||
|
ManualConflicts: make([]ManualConflict, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, id := range allIDs {
|
for _, id := range allIDs {
|
||||||
@@ -253,11 +360,16 @@ func MergeIssues(base, local, remote []*beads.Issue) *MergeResult {
|
|||||||
localIssue := localMap[id]
|
localIssue := localMap[id]
|
||||||
remoteIssue := remoteMap[id]
|
remoteIssue := remoteMap[id]
|
||||||
|
|
||||||
merged, strategy := MergeIssue(baseIssue, localIssue, remoteIssue)
|
merged, strategy, manualConflicts := MergeIssue(baseIssue, localIssue, remoteIssue)
|
||||||
|
|
||||||
// Always record strategy (even for deletions, for logging/debugging)
|
// Always record strategy (even for deletions, for logging/debugging)
|
||||||
result.Strategy[id] = strategy
|
result.Strategy[id] = strategy
|
||||||
|
|
||||||
|
// Collect manual conflicts for fields that need user resolution
|
||||||
|
if len(manualConflicts) > 0 {
|
||||||
|
result.ManualConflicts = append(result.ManualConflicts, manualConflicts...)
|
||||||
|
}
|
||||||
|
|
||||||
if merged != nil {
|
if merged != nil {
|
||||||
result.Merged = append(result.Merged, merged)
|
result.Merged = append(result.Merged, merged)
|
||||||
if strategy == StrategyMerged {
|
if strategy == StrategyMerged {
|
||||||
@@ -289,63 +401,64 @@ func MergeIssues(base, local, remote []*beads.Issue) *MergeResult {
|
|||||||
// - Deletion handling:
|
// - Deletion handling:
|
||||||
// - local=nil (deleted locally): if remote unchanged from base, delete; else keep remote
|
// - local=nil (deleted locally): if remote unchanged from base, delete; else keep remote
|
||||||
// - remote=nil (deleted remotely): if local unchanged from base, delete; else keep local
|
// - remote=nil (deleted remotely): if local unchanged from base, delete; else keep local
|
||||||
func MergeIssue(base, local, remote *beads.Issue) (*beads.Issue, string) {
|
func MergeIssue(base, local, remote *beads.Issue) (*beads.Issue, string, []ManualConflict) {
|
||||||
// Case: no base state (first sync)
|
// Case: no base state (first sync)
|
||||||
if base == nil {
|
if base == nil {
|
||||||
if local == nil && remote == nil {
|
if local == nil && remote == nil {
|
||||||
// Should not happen (would not be in allIDs)
|
// Should not happen (would not be in allIDs)
|
||||||
return nil, StrategySame
|
return nil, StrategySame, nil
|
||||||
}
|
}
|
||||||
if local == nil {
|
if local == nil {
|
||||||
return remote, StrategyRemote
|
return remote, StrategyRemote, nil
|
||||||
}
|
}
|
||||||
if remote == nil {
|
if remote == nil {
|
||||||
return local, StrategyLocal
|
return local, StrategyLocal, nil
|
||||||
}
|
}
|
||||||
// Both exist with no base: treat as conflict, use field-level merge
|
// Both exist with no base: treat as conflict, use field-level merge
|
||||||
// This allows labels/comments to be union-merged even in first sync
|
// This allows labels/comments to be union-merged even in first sync
|
||||||
return mergeFieldLevel(nil, local, remote), StrategyMerged
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
||||||
|
return merged, StrategyMerged, manualConflicts
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case: local deleted
|
// Case: local deleted
|
||||||
if local == nil {
|
if local == nil {
|
||||||
// If remote unchanged from base, honor the local deletion
|
// If remote unchanged from base, honor the local deletion
|
||||||
if issueEqual(base, remote) {
|
if issueEqual(base, remote) {
|
||||||
return nil, StrategyLocal
|
return nil, StrategyLocal, nil
|
||||||
}
|
}
|
||||||
// Remote changed after local deleted: keep remote (remote wins conflict)
|
// Remote changed after local deleted: keep remote (remote wins conflict)
|
||||||
return remote, StrategyMerged
|
return remote, StrategyMerged, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Case: remote deleted
|
// Case: remote deleted
|
||||||
if remote == nil {
|
if remote == nil {
|
||||||
// If local unchanged from base, honor the remote deletion
|
// If local unchanged from base, honor the remote deletion
|
||||||
if issueEqual(base, local) {
|
if issueEqual(base, local) {
|
||||||
return nil, StrategyRemote
|
return nil, StrategyRemote, nil
|
||||||
}
|
}
|
||||||
// Local changed after remote deleted: keep local (local wins conflict)
|
// Local changed after remote deleted: keep local (local wins conflict)
|
||||||
return local, StrategyMerged
|
return local, StrategyMerged, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Standard 3-way cases (all three exist)
|
// Standard 3-way cases (all three exist)
|
||||||
if issueEqual(base, local) && issueEqual(base, remote) {
|
if issueEqual(base, local) && issueEqual(base, remote) {
|
||||||
// No changes anywhere
|
// No changes anywhere
|
||||||
return local, StrategySame
|
return local, StrategySame, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if issueEqual(base, local) {
|
if issueEqual(base, local) {
|
||||||
// Only remote changed
|
// Only remote changed
|
||||||
return remote, StrategyRemote
|
return remote, StrategyRemote, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if issueEqual(base, remote) {
|
if issueEqual(base, remote) {
|
||||||
// Only local changed
|
// Only local changed
|
||||||
return local, StrategyLocal
|
return local, StrategyLocal, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if issueEqual(local, remote) {
|
if issueEqual(local, remote) {
|
||||||
// Both made identical change
|
// Both made identical change
|
||||||
return local, StrategySame
|
return local, StrategySame, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// True conflict: use field-level merge
|
// True conflict: use field-level merge
|
||||||
@@ -353,7 +466,10 @@ func MergeIssue(base, local, remote *beads.Issue) (*beads.Issue, string) {
|
|||||||
// - Labels use union (no data loss)
|
// - Labels use union (no data loss)
|
||||||
// - Dependencies use union (no data loss)
|
// - Dependencies use union (no data loss)
|
||||||
// - Comments use append (deduplicated)
|
// - Comments use append (deduplicated)
|
||||||
return mergeFieldLevel(base, local, remote), StrategyMerged
|
// - compaction_level uses max (or configured strategy)
|
||||||
|
// - estimated_minutes uses configured strategy (may flag for manual resolution)
|
||||||
|
merged, manualConflicts := mergeFieldLevel(base, local, remote)
|
||||||
|
return merged, StrategyMerged, manualConflicts
|
||||||
}
|
}
|
||||||
|
|
||||||
// issueEqual compares two issues for equality (content-level, not pointer)
|
// issueEqual compares two issues for equality (content-level, not pointer)
|
||||||
|
|||||||
+228
-32
@@ -242,7 +242,7 @@ func makeTestIssue(id, title string, status types.Status, priority int, updatedA
|
|||||||
func TestMergeIssue_NoBase_LocalOnly(t *testing.T) {
|
func TestMergeIssue_NoBase_LocalOnly(t *testing.T) {
|
||||||
local := makeTestIssue("bd-1234", "Local Issue", types.StatusOpen, 1, time.Now())
|
local := makeTestIssue("bd-1234", "Local Issue", types.StatusOpen, 1, time.Now())
|
||||||
|
|
||||||
merged, strategy := MergeIssue(nil, local, nil)
|
merged, strategy, _ := MergeIssue(nil, local, nil)
|
||||||
|
|
||||||
if strategy != StrategyLocal {
|
if strategy != StrategyLocal {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyLocal, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyLocal, strategy)
|
||||||
@@ -259,7 +259,7 @@ func TestMergeIssue_NoBase_LocalOnly(t *testing.T) {
|
|||||||
func TestMergeIssue_NoBase_RemoteOnly(t *testing.T) {
|
func TestMergeIssue_NoBase_RemoteOnly(t *testing.T) {
|
||||||
remote := makeTestIssue("bd-5678", "Remote Issue", types.StatusOpen, 2, time.Now())
|
remote := makeTestIssue("bd-5678", "Remote Issue", types.StatusOpen, 2, time.Now())
|
||||||
|
|
||||||
merged, strategy := MergeIssue(nil, nil, remote)
|
merged, strategy, _ := MergeIssue(nil, nil, remote)
|
||||||
|
|
||||||
if strategy != StrategyRemote {
|
if strategy != StrategyRemote {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyRemote, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyRemote, strategy)
|
||||||
@@ -278,7 +278,7 @@ func TestMergeIssue_NoBase_BothExist_LocalNewer(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now.Add(time.Hour))
|
local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now.Add(time.Hour))
|
||||||
remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now)
|
remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now)
|
||||||
|
|
||||||
merged, strategy := MergeIssue(nil, local, remote)
|
merged, strategy, _ := MergeIssue(nil, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -297,7 +297,7 @@ func TestMergeIssue_NoBase_BothExist_RemoteNewer(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now)
|
local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now)
|
||||||
remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now.Add(time.Hour))
|
remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now.Add(time.Hour))
|
||||||
|
|
||||||
merged, strategy := MergeIssue(nil, local, remote)
|
merged, strategy, _ := MergeIssue(nil, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -316,7 +316,7 @@ func TestMergeIssue_NoBase_BothExist_SameTime(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now)
|
local := makeTestIssue("bd-1234", "Local Title", types.StatusOpen, 1, now)
|
||||||
remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now)
|
remote := makeTestIssue("bd-1234", "Remote Title", types.StatusOpen, 2, now)
|
||||||
|
|
||||||
merged, strategy := MergeIssue(nil, local, remote)
|
merged, strategy, _ := MergeIssue(nil, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -337,7 +337,7 @@ func TestMergeIssue_NoChanges(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Same Title", types.StatusOpen, 1, now)
|
local := makeTestIssue("bd-1234", "Same Title", types.StatusOpen, 1, now)
|
||||||
remote := makeTestIssue("bd-1234", "Same Title", types.StatusOpen, 1, now)
|
remote := makeTestIssue("bd-1234", "Same Title", types.StatusOpen, 1, now)
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategySame {
|
if strategy != StrategySame {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategySame, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategySame, strategy)
|
||||||
@@ -354,7 +354,7 @@ func TestMergeIssue_OnlyLocalChanged(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Updated Title", types.StatusOpen, 1, now.Add(time.Hour))
|
local := makeTestIssue("bd-1234", "Updated Title", types.StatusOpen, 1, now.Add(time.Hour))
|
||||||
remote := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now)
|
remote := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now)
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyLocal {
|
if strategy != StrategyLocal {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyLocal, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyLocal, strategy)
|
||||||
@@ -374,7 +374,7 @@ func TestMergeIssue_OnlyRemoteChanged(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now)
|
local := makeTestIssue("bd-1234", "Original Title", types.StatusOpen, 1, now)
|
||||||
remote := makeTestIssue("bd-1234", "Updated Title", types.StatusOpen, 1, now.Add(time.Hour))
|
remote := makeTestIssue("bd-1234", "Updated Title", types.StatusOpen, 1, now.Add(time.Hour))
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyRemote {
|
if strategy != StrategyRemote {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyRemote, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyRemote, strategy)
|
||||||
@@ -394,7 +394,7 @@ func TestMergeIssue_BothMadeSameChange(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Same Update", types.StatusClosed, 2, now.Add(time.Hour))
|
local := makeTestIssue("bd-1234", "Same Update", types.StatusClosed, 2, now.Add(time.Hour))
|
||||||
remote := makeTestIssue("bd-1234", "Same Update", types.StatusClosed, 2, now.Add(time.Hour))
|
remote := makeTestIssue("bd-1234", "Same Update", types.StatusClosed, 2, now.Add(time.Hour))
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategySame {
|
if strategy != StrategySame {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategySame, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategySame, strategy)
|
||||||
@@ -414,7 +414,7 @@ func TestMergeIssue_TrueConflict_LocalNewer(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(2*time.Hour))
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(2*time.Hour))
|
||||||
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 2, now.Add(time.Hour))
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 2, now.Add(time.Hour))
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -438,7 +438,7 @@ func TestMergeIssue_TrueConflict_RemoteNewer(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(time.Hour))
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 1, now.Add(time.Hour))
|
||||||
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 2, now.Add(2*time.Hour))
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 2, now.Add(2*time.Hour))
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -461,7 +461,7 @@ func TestMergeIssue_LocalDeleted_RemoteUnchanged(t *testing.T) {
|
|||||||
base := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
base := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
||||||
remote := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
remote := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, nil, remote)
|
merged, strategy, _ := MergeIssue(base, nil, remote)
|
||||||
|
|
||||||
if strategy != StrategyLocal {
|
if strategy != StrategyLocal {
|
||||||
t.Errorf("Expected strategy=%s (honor local deletion), got %s", StrategyLocal, strategy)
|
t.Errorf("Expected strategy=%s (honor local deletion), got %s", StrategyLocal, strategy)
|
||||||
@@ -477,7 +477,7 @@ func TestMergeIssue_LocalDeleted_RemoteChanged(t *testing.T) {
|
|||||||
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
||||||
remote := makeTestIssue("bd-1234", "Remote Updated", types.StatusClosed, 2, now.Add(time.Hour))
|
remote := makeTestIssue("bd-1234", "Remote Updated", types.StatusClosed, 2, now.Add(time.Hour))
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, nil, remote)
|
merged, strategy, _ := MergeIssue(base, nil, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s (conflict: deleted vs updated), got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s (conflict: deleted vs updated), got %s", StrategyMerged, strategy)
|
||||||
@@ -496,7 +496,7 @@ func TestMergeIssue_RemoteDeleted_LocalUnchanged(t *testing.T) {
|
|||||||
base := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
base := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
||||||
local := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
local := makeTestIssue("bd-1234", "To Delete", types.StatusOpen, 1, now)
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, nil)
|
merged, strategy, _ := MergeIssue(base, local, nil)
|
||||||
|
|
||||||
if strategy != StrategyRemote {
|
if strategy != StrategyRemote {
|
||||||
t.Errorf("Expected strategy=%s (honor remote deletion), got %s", StrategyRemote, strategy)
|
t.Errorf("Expected strategy=%s (honor remote deletion), got %s", StrategyRemote, strategy)
|
||||||
@@ -512,7 +512,7 @@ func TestMergeIssue_RemoteDeleted_LocalChanged(t *testing.T) {
|
|||||||
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
base := makeTestIssue("bd-1234", "Original", types.StatusOpen, 1, now)
|
||||||
local := makeTestIssue("bd-1234", "Local Updated", types.StatusClosed, 2, now.Add(time.Hour))
|
local := makeTestIssue("bd-1234", "Local Updated", types.StatusClosed, 2, now.Add(time.Hour))
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, nil)
|
merged, strategy, _ := MergeIssue(base, local, nil)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s (conflict: updated vs deleted), got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s (conflict: updated vs deleted), got %s", StrategyMerged, strategy)
|
||||||
@@ -826,7 +826,7 @@ func TestFieldMerge_LWW_LocalNewer(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now.Add(2*time.Hour))
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now.Add(2*time.Hour))
|
||||||
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now.Add(time.Hour))
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now.Add(time.Hour))
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -853,7 +853,7 @@ func TestFieldMerge_LWW_RemoteNewer(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now.Add(time.Hour))
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now.Add(time.Hour))
|
||||||
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now.Add(2*time.Hour))
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now.Add(2*time.Hour))
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -880,7 +880,7 @@ func TestFieldMerge_LWW_SameTimestamp(t *testing.T) {
|
|||||||
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now)
|
local := makeTestIssue("bd-1234", "Local Update", types.StatusInProgress, 2, now)
|
||||||
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now)
|
remote := makeTestIssue("bd-1234", "Remote Update", types.StatusClosed, 3, now)
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -904,7 +904,7 @@ func TestLabelUnion_BothAdd(t *testing.T) {
|
|||||||
local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "local-added"})
|
local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "local-added"})
|
||||||
remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original", "remote-added"})
|
remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original", "remote-added"})
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -939,7 +939,7 @@ func TestLabelUnion_LocalOnly(t *testing.T) {
|
|||||||
local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "local-added"})
|
local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "local-added"})
|
||||||
remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original"})
|
remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original"})
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -962,7 +962,7 @@ func TestLabelUnion_RemoteOnly(t *testing.T) {
|
|||||||
local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original"})
|
local := makeTestIssueWithLabels("bd-1234", "Test Local", types.StatusOpen, 1, now.Add(2*time.Hour), []string{"original"})
|
||||||
remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "remote-added"})
|
remote := makeTestIssueWithLabels("bd-1234", "Test Remote", types.StatusOpen, 1, now.Add(time.Hour), []string{"original", "remote-added"})
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -1001,7 +1001,7 @@ func TestDependencyUnion(t *testing.T) {
|
|||||||
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
||||||
remote.Dependencies = []*types.Dependency{remoteDep}
|
remote.Dependencies = []*types.Dependency{remoteDep}
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -1070,7 +1070,7 @@ func TestCommentAppend(t *testing.T) {
|
|||||||
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
||||||
remote.Comments = []*types.Comment{commonComment, remoteComment}
|
remote.Comments = []*types.Comment{commonComment, remoteComment}
|
||||||
|
|
||||||
merged, strategy := MergeIssue(base, local, remote)
|
merged, strategy, _ := MergeIssue(base, local, remote)
|
||||||
|
|
||||||
if strategy != StrategyMerged {
|
if strategy != StrategyMerged {
|
||||||
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
t.Errorf("Expected strategy=%s, got %s", StrategyMerged, strategy)
|
||||||
@@ -1103,7 +1103,7 @@ func TestFieldMerge_EdgeCases(t *testing.T) {
|
|||||||
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
||||||
remote.Labels = []string{"remote-label"}
|
remote.Labels = []string{"remote-label"}
|
||||||
|
|
||||||
merged, _ := MergeIssue(base, local, remote)
|
merged, _, _ := MergeIssue(base, local, remote)
|
||||||
if merged == nil {
|
if merged == nil {
|
||||||
t.Fatal("Expected merged issue, got nil")
|
t.Fatal("Expected merged issue, got nil")
|
||||||
}
|
}
|
||||||
@@ -1122,7 +1122,7 @@ func TestFieldMerge_EdgeCases(t *testing.T) {
|
|||||||
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
||||||
remote.Labels = []string{"remote-label"}
|
remote.Labels = []string{"remote-label"}
|
||||||
|
|
||||||
merged, _ := MergeIssue(base, local, remote)
|
merged, _, _ := MergeIssue(base, local, remote)
|
||||||
if merged == nil {
|
if merged == nil {
|
||||||
t.Fatal("Expected merged issue, got nil")
|
t.Fatal("Expected merged issue, got nil")
|
||||||
}
|
}
|
||||||
@@ -1148,7 +1148,7 @@ func TestFieldMerge_EdgeCases(t *testing.T) {
|
|||||||
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
||||||
remote.Dependencies = nil
|
remote.Dependencies = nil
|
||||||
|
|
||||||
merged, _ := MergeIssue(base, local, remote)
|
merged, _, _ := MergeIssue(base, local, remote)
|
||||||
if merged == nil {
|
if merged == nil {
|
||||||
t.Fatal("Expected merged issue, got nil")
|
t.Fatal("Expected merged issue, got nil")
|
||||||
}
|
}
|
||||||
@@ -1175,7 +1175,7 @@ func TestFieldMerge_EdgeCases(t *testing.T) {
|
|||||||
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
||||||
remote.Comments = []*types.Comment{comment}
|
remote.Comments = []*types.Comment{comment}
|
||||||
|
|
||||||
merged, _ := MergeIssue(base, local, remote)
|
merged, _, _ := MergeIssue(base, local, remote)
|
||||||
if merged == nil {
|
if merged == nil {
|
||||||
t.Fatal("Expected merged issue, got nil")
|
t.Fatal("Expected merged issue, got nil")
|
||||||
}
|
}
|
||||||
@@ -1211,7 +1211,7 @@ func TestFieldMerge_EdgeCases(t *testing.T) {
|
|||||||
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
remote := makeTestIssue("bd-1234", "Test Remote", types.StatusClosed, 1, now.Add(2*time.Hour))
|
||||||
remote.Dependencies = []*types.Dependency{remoteDep}
|
remote.Dependencies = []*types.Dependency{remoteDep}
|
||||||
|
|
||||||
merged, _ := MergeIssue(base, local, remote)
|
merged, _, _ := MergeIssue(base, local, remote)
|
||||||
if merged == nil {
|
if merged == nil {
|
||||||
t.Fatal("Expected merged issue, got nil")
|
t.Fatal("Expected merged issue, got nil")
|
||||||
}
|
}
|
||||||
@@ -1244,7 +1244,7 @@ func TestMergeClockSkewWarning(t *testing.T) {
|
|||||||
r, w, _ := os.Pipe()
|
r, w, _ := os.Pipe()
|
||||||
os.Stderr = w
|
os.Stderr = w
|
||||||
|
|
||||||
_, _ = MergeIssue(base, local, remote)
|
_, _, _ = MergeIssue(base, local, remote)
|
||||||
|
|
||||||
w.Close()
|
w.Close()
|
||||||
os.Stderr = oldStderr
|
os.Stderr = oldStderr
|
||||||
@@ -1268,7 +1268,7 @@ func TestMergeClockSkewWarning(t *testing.T) {
|
|||||||
r, w, _ := os.Pipe()
|
r, w, _ := os.Pipe()
|
||||||
os.Stderr = w
|
os.Stderr = w
|
||||||
|
|
||||||
_, _ = MergeIssue(base, local, remote)
|
_, _, _ = MergeIssue(base, local, remote)
|
||||||
|
|
||||||
w.Close()
|
w.Close()
|
||||||
os.Stderr = oldStderr
|
os.Stderr = oldStderr
|
||||||
@@ -1295,7 +1295,7 @@ func TestMergeClockSkewWarning(t *testing.T) {
|
|||||||
r, w, _ := os.Pipe()
|
r, w, _ := os.Pipe()
|
||||||
os.Stderr = w
|
os.Stderr = w
|
||||||
|
|
||||||
_, _ = MergeIssue(base, local, remote)
|
_, _, _ = MergeIssue(base, local, remote)
|
||||||
|
|
||||||
w.Close()
|
w.Close()
|
||||||
os.Stderr = oldStderr
|
os.Stderr = oldStderr
|
||||||
@@ -1322,7 +1322,7 @@ func TestMergeClockSkewWarning(t *testing.T) {
|
|||||||
r, w, _ := os.Pipe()
|
r, w, _ := os.Pipe()
|
||||||
os.Stderr = w
|
os.Stderr = w
|
||||||
|
|
||||||
_, _ = MergeIssue(base, local, remote)
|
_, _, _ = MergeIssue(base, local, remote)
|
||||||
|
|
||||||
w.Close()
|
w.Close()
|
||||||
os.Stderr = oldStderr
|
os.Stderr = oldStderr
|
||||||
@@ -1409,3 +1409,199 @@ func TestMergeLabels(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestMergeFieldLevel_CompactionLevel tests compaction_level uses max strategy by default
|
||||||
|
func TestMergeFieldLevel_CompactionLevel(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
t.Run("max_strategy_takes_higher_value", func(t *testing.T) {
|
||||||
|
// Local has higher compaction_level
|
||||||
|
local := makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour))
|
||||||
|
local.CompactionLevel = 5
|
||||||
|
remote := makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now)
|
||||||
|
remote.CompactionLevel = 3
|
||||||
|
|
||||||
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
||||||
|
|
||||||
|
if merged.CompactionLevel != 5 {
|
||||||
|
t.Errorf("Expected compaction_level=5 (max), got %d", merged.CompactionLevel)
|
||||||
|
}
|
||||||
|
if len(manualConflicts) != 0 {
|
||||||
|
t.Errorf("Expected no manual conflicts, got %d", len(manualConflicts))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("max_strategy_takes_higher_from_remote", func(t *testing.T) {
|
||||||
|
// Remote has higher compaction_level
|
||||||
|
local := makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour))
|
||||||
|
local.CompactionLevel = 2
|
||||||
|
remote := makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now)
|
||||||
|
remote.CompactionLevel = 7
|
||||||
|
|
||||||
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
||||||
|
|
||||||
|
if merged.CompactionLevel != 7 {
|
||||||
|
t.Errorf("Expected compaction_level=7 (max), got %d", merged.CompactionLevel)
|
||||||
|
}
|
||||||
|
if len(manualConflicts) != 0 {
|
||||||
|
t.Errorf("Expected no manual conflicts, got %d", len(manualConflicts))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMergeFieldLevel_EstimatedMinutes tests estimated_minutes uses manual strategy by default
|
||||||
|
func TestMergeFieldLevel_EstimatedMinutes(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
t.Run("manual_strategy_flags_conflict_when_different", func(t *testing.T) {
|
||||||
|
// Default is manual strategy - should flag conflict when values differ
|
||||||
|
localMins := 120
|
||||||
|
remoteMins := 60
|
||||||
|
local := makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour))
|
||||||
|
local.EstimatedMinutes = &localMins
|
||||||
|
remote := makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now)
|
||||||
|
remote.EstimatedMinutes = &remoteMins
|
||||||
|
|
||||||
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
||||||
|
|
||||||
|
// Should keep local value as tentative
|
||||||
|
if merged.EstimatedMinutes == nil || *merged.EstimatedMinutes != 120 {
|
||||||
|
t.Errorf("Expected estimated_minutes=120 (local as tentative), got %v", merged.EstimatedMinutes)
|
||||||
|
}
|
||||||
|
// Should flag for manual resolution
|
||||||
|
if len(manualConflicts) != 1 {
|
||||||
|
t.Errorf("Expected 1 manual conflict, got %d", len(manualConflicts))
|
||||||
|
} else {
|
||||||
|
mc := manualConflicts[0]
|
||||||
|
if mc.Field != "estimated_minutes" {
|
||||||
|
t.Errorf("Expected field=estimated_minutes, got %s", mc.Field)
|
||||||
|
}
|
||||||
|
if mc.LocalValue != 120 {
|
||||||
|
t.Errorf("Expected LocalValue=120, got %v", mc.LocalValue)
|
||||||
|
}
|
||||||
|
if mc.RemoteValue != 60 {
|
||||||
|
t.Errorf("Expected RemoteValue=60, got %v", mc.RemoteValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("manual_strategy_no_conflict_when_same", func(t *testing.T) {
|
||||||
|
// No conflict when values are the same
|
||||||
|
mins := 120
|
||||||
|
local := makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour))
|
||||||
|
local.EstimatedMinutes = &mins
|
||||||
|
remote := makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now)
|
||||||
|
remoteMins := 120
|
||||||
|
remote.EstimatedMinutes = &remoteMins
|
||||||
|
|
||||||
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
||||||
|
|
||||||
|
if merged.EstimatedMinutes == nil || *merged.EstimatedMinutes != 120 {
|
||||||
|
t.Errorf("Expected estimated_minutes=120, got %v", merged.EstimatedMinutes)
|
||||||
|
}
|
||||||
|
if len(manualConflicts) != 0 {
|
||||||
|
t.Errorf("Expected no manual conflicts when values match, got %d", len(manualConflicts))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("manual_strategy_nil_vs_value_flags_conflict", func(t *testing.T) {
|
||||||
|
// Conflict when one is nil and other has value
|
||||||
|
remoteMins := 60
|
||||||
|
local := makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour))
|
||||||
|
local.EstimatedMinutes = nil
|
||||||
|
remote := makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now)
|
||||||
|
remote.EstimatedMinutes = &remoteMins
|
||||||
|
|
||||||
|
merged, manualConflicts := mergeFieldLevel(nil, local, remote)
|
||||||
|
|
||||||
|
// Should keep local value (nil) as tentative
|
||||||
|
if merged.EstimatedMinutes != nil {
|
||||||
|
t.Errorf("Expected estimated_minutes=nil (local as tentative), got %v", *merged.EstimatedMinutes)
|
||||||
|
}
|
||||||
|
// Should flag for manual resolution
|
||||||
|
if len(manualConflicts) != 1 {
|
||||||
|
t.Errorf("Expected 1 manual conflict, got %d", len(manualConflicts))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaxInt tests the maxInt helper function
|
||||||
|
func TestMaxInt(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
a, b, expected int
|
||||||
|
}{
|
||||||
|
{0, 0, 0},
|
||||||
|
{1, 0, 1},
|
||||||
|
{0, 1, 1},
|
||||||
|
{5, 3, 5},
|
||||||
|
{3, 5, 5},
|
||||||
|
{-1, -2, -1},
|
||||||
|
{-2, -1, -1},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
result := maxInt(tc.a, tc.b)
|
||||||
|
if result != tc.expected {
|
||||||
|
t.Errorf("maxInt(%d, %d) = %d, expected %d", tc.a, tc.b, result, tc.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMaxIntPtr tests the maxIntPtr helper function
|
||||||
|
func TestMaxIntPtr(t *testing.T) {
|
||||||
|
five := 5
|
||||||
|
three := 3
|
||||||
|
|
||||||
|
t.Run("both_nil_returns_nil", func(t *testing.T) {
|
||||||
|
result := maxIntPtr(nil, nil)
|
||||||
|
if result != nil {
|
||||||
|
t.Errorf("Expected nil, got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("left_nil_returns_right", func(t *testing.T) {
|
||||||
|
result := maxIntPtr(nil, &three)
|
||||||
|
if result == nil || *result != 3 {
|
||||||
|
t.Errorf("Expected 3, got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("right_nil_returns_left", func(t *testing.T) {
|
||||||
|
result := maxIntPtr(&five, nil)
|
||||||
|
if result == nil || *result != 5 {
|
||||||
|
t.Errorf("Expected 5, got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns_larger_value", func(t *testing.T) {
|
||||||
|
result := maxIntPtr(&three, &five)
|
||||||
|
if result == nil || *result != 5 {
|
||||||
|
t.Errorf("Expected 5, got %v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = maxIntPtr(&five, &three)
|
||||||
|
if result == nil || *result != 5 {
|
||||||
|
t.Errorf("Expected 5, got %v", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMergeResult_ManualConflicts tests that ManualConflicts is properly initialized
|
||||||
|
func TestMergeResult_ManualConflicts(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
base := []*types.Issue{
|
||||||
|
makeTestIssue("bd-1234", "Base", types.StatusOpen, 1, now),
|
||||||
|
}
|
||||||
|
local := []*types.Issue{
|
||||||
|
makeTestIssue("bd-1234", "Local", types.StatusOpen, 1, now.Add(time.Hour)),
|
||||||
|
}
|
||||||
|
remote := []*types.Issue{
|
||||||
|
makeTestIssue("bd-1234", "Remote", types.StatusOpen, 1, now.Add(2*time.Hour)),
|
||||||
|
}
|
||||||
|
|
||||||
|
result := MergeIssues(base, local, remote)
|
||||||
|
|
||||||
|
if result.ManualConflicts == nil {
|
||||||
|
t.Error("ManualConflicts should be initialized, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -574,16 +574,67 @@ func GetSyncConfig() SyncConfig {
|
|||||||
|
|
||||||
// ConflictConfig holds the conflict resolution configuration.
|
// ConflictConfig holds the conflict resolution configuration.
|
||||||
type ConflictConfig struct {
|
type ConflictConfig struct {
|
||||||
Strategy ConflictStrategy // newest, ours, theirs, manual
|
Strategy ConflictStrategy // newest, ours, theirs, manual (default for all fields)
|
||||||
|
Fields map[string]FieldStrategy // Per-field strategy overrides
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConflictConfig returns the current conflict resolution configuration.
|
// GetConflictConfig returns the current conflict resolution configuration.
|
||||||
func GetConflictConfig() ConflictConfig {
|
func GetConflictConfig() ConflictConfig {
|
||||||
return ConflictConfig{
|
return ConflictConfig{
|
||||||
Strategy: GetConflictStrategy(),
|
Strategy: GetConflictStrategy(),
|
||||||
|
Fields: GetFieldStrategies(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetFieldStrategies retrieves per-field conflict resolution strategies from config.
|
||||||
|
// Returns a map of field name to strategy (e.g., {"labels": "union", "compaction_level": "max"}).
|
||||||
|
// Invalid strategies are logged and skipped.
|
||||||
|
//
|
||||||
|
// Config key: conflict.fields
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// conflict:
|
||||||
|
// strategy: newest
|
||||||
|
// fields:
|
||||||
|
// compaction_level: max
|
||||||
|
// labels: union
|
||||||
|
// waiters: union
|
||||||
|
// estimated_minutes: manual
|
||||||
|
func GetFieldStrategies() map[string]FieldStrategy {
|
||||||
|
result := make(map[string]FieldStrategy)
|
||||||
|
if v == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the raw map from config
|
||||||
|
fieldsMap := v.GetStringMapString("conflict.fields")
|
||||||
|
if fieldsMap == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
for field, strategyStr := range fieldsMap {
|
||||||
|
strategy := FieldStrategy(strings.ToLower(strings.TrimSpace(strategyStr)))
|
||||||
|
if !validFieldStrategies[strategy] {
|
||||||
|
logConfigWarning("Warning: invalid conflict.fields.%s strategy %q (valid: %s), skipping\n",
|
||||||
|
field, strategyStr, strings.Join(ValidFieldStrategies(), ", "))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[field] = strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFieldStrategy returns the merge strategy for a specific field.
|
||||||
|
// Returns the per-field strategy if configured, otherwise returns "newest" (default).
|
||||||
|
func GetFieldStrategy(field string) FieldStrategy {
|
||||||
|
fields := GetFieldStrategies()
|
||||||
|
if strategy, ok := fields[field]; ok {
|
||||||
|
return strategy
|
||||||
|
}
|
||||||
|
return FieldStrategyNewest // Default
|
||||||
|
}
|
||||||
|
|
||||||
// FederationConfig holds the federation (Dolt remote) configuration.
|
// FederationConfig holds the federation (Dolt remote) configuration.
|
||||||
type FederationConfig struct {
|
type FederationConfig struct {
|
||||||
Remote string // dolthub://org/beads, gs://bucket/beads, s3://bucket/beads
|
Remote string // dolthub://org/beads, gs://bucket/beads, s3://bucket/beads
|
||||||
|
|||||||
@@ -1543,3 +1543,141 @@ func TestGetCustomTypesFromYAML_NilViper(t *testing.T) {
|
|||||||
t.Errorf("GetCustomTypesFromYAML() with nil viper = %v, want nil", got)
|
t.Errorf("GetCustomTypesFromYAML() with nil viper = %v, want nil", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetFieldStrategies(t *testing.T) {
|
||||||
|
// Isolate from environment variables
|
||||||
|
restore := envSnapshot(t)
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
// Initialize config
|
||||||
|
ResetForTesting()
|
||||||
|
if err := Initialize(); err != nil {
|
||||||
|
t.Fatalf("Initialize() returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("empty_by_default", func(t *testing.T) {
|
||||||
|
result := GetFieldStrategies()
|
||||||
|
if len(result) != 0 {
|
||||||
|
t.Errorf("GetFieldStrategies() with no config = %v, want empty map", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("valid_strategies", func(t *testing.T) {
|
||||||
|
ResetForTesting()
|
||||||
|
if err := Initialize(); err != nil {
|
||||||
|
t.Fatalf("Initialize() returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set per-field strategies
|
||||||
|
Set("conflict.fields", map[string]string{
|
||||||
|
"compaction_level": "max",
|
||||||
|
"labels": "union",
|
||||||
|
"estimated_minutes": "manual",
|
||||||
|
"status": "newest",
|
||||||
|
})
|
||||||
|
|
||||||
|
result := GetFieldStrategies()
|
||||||
|
|
||||||
|
if result["compaction_level"] != FieldStrategyMax {
|
||||||
|
t.Errorf("Expected compaction_level=max, got %s", result["compaction_level"])
|
||||||
|
}
|
||||||
|
if result["labels"] != FieldStrategyUnion {
|
||||||
|
t.Errorf("Expected labels=union, got %s", result["labels"])
|
||||||
|
}
|
||||||
|
if result["estimated_minutes"] != FieldStrategyManual {
|
||||||
|
t.Errorf("Expected estimated_minutes=manual, got %s", result["estimated_minutes"])
|
||||||
|
}
|
||||||
|
if result["status"] != FieldStrategyNewest {
|
||||||
|
t.Errorf("Expected status=newest, got %s", result["status"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid_strategy_skipped", func(t *testing.T) {
|
||||||
|
ResetForTesting()
|
||||||
|
if err := Initialize(); err != nil {
|
||||||
|
t.Fatalf("Initialize() returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a mix of valid and invalid strategies
|
||||||
|
Set("conflict.fields", map[string]string{
|
||||||
|
"compaction_level": "max",
|
||||||
|
"invalid_field": "invalid-strategy",
|
||||||
|
})
|
||||||
|
|
||||||
|
result := GetFieldStrategies()
|
||||||
|
|
||||||
|
// Valid one should be present
|
||||||
|
if result["compaction_level"] != FieldStrategyMax {
|
||||||
|
t.Errorf("Expected compaction_level=max, got %s", result["compaction_level"])
|
||||||
|
}
|
||||||
|
// Invalid one should be skipped
|
||||||
|
if _, exists := result["invalid_field"]; exists {
|
||||||
|
t.Errorf("Expected invalid_field to be skipped, but it was included: %s", result["invalid_field"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetFieldStrategy(t *testing.T) {
|
||||||
|
// Isolate from environment variables
|
||||||
|
restore := envSnapshot(t)
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
// Initialize config
|
||||||
|
ResetForTesting()
|
||||||
|
if err := Initialize(); err != nil {
|
||||||
|
t.Fatalf("Initialize() returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("returns_default_for_unconfigured_field", func(t *testing.T) {
|
||||||
|
result := GetFieldStrategy("unconfigured_field")
|
||||||
|
if result != FieldStrategyNewest {
|
||||||
|
t.Errorf("GetFieldStrategy(unconfigured_field) = %s, want newest (default)", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns_configured_strategy", func(t *testing.T) {
|
||||||
|
ResetForTesting()
|
||||||
|
if err := Initialize(); err != nil {
|
||||||
|
t.Fatalf("Initialize() returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Set("conflict.fields", map[string]string{
|
||||||
|
"compaction_level": "max",
|
||||||
|
})
|
||||||
|
|
||||||
|
result := GetFieldStrategy("compaction_level")
|
||||||
|
if result != FieldStrategyMax {
|
||||||
|
t.Errorf("GetFieldStrategy(compaction_level) = %s, want max", result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetConflictConfigWithFields(t *testing.T) {
|
||||||
|
// Isolate from environment variables
|
||||||
|
restore := envSnapshot(t)
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
// Initialize config
|
||||||
|
ResetForTesting()
|
||||||
|
if err := Initialize(); err != nil {
|
||||||
|
t.Fatalf("Initialize() returned error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
Set("conflict.strategy", "ours")
|
||||||
|
Set("conflict.fields", map[string]string{
|
||||||
|
"compaction_level": "max",
|
||||||
|
"labels": "union",
|
||||||
|
})
|
||||||
|
|
||||||
|
result := GetConflictConfig()
|
||||||
|
|
||||||
|
if result.Strategy != ConflictStrategyOurs {
|
||||||
|
t.Errorf("GetConflictConfig().Strategy = %s, want ours", result.Strategy)
|
||||||
|
}
|
||||||
|
if result.Fields["compaction_level"] != FieldStrategyMax {
|
||||||
|
t.Errorf("GetConflictConfig().Fields[compaction_level] = %s, want max", result.Fields["compaction_level"])
|
||||||
|
}
|
||||||
|
if result.Fields["labels"] != FieldStrategyUnion {
|
||||||
|
t.Errorf("GetConflictConfig().Fields[labels] = %s, want union", result.Fields["labels"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,6 +76,20 @@ const (
|
|||||||
ConflictStrategyManual ConflictStrategy = "manual"
|
ConflictStrategyManual ConflictStrategy = "manual"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// FieldStrategy represents the merge strategy for a specific field
|
||||||
|
type FieldStrategy string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// FieldStrategyNewest uses last-write-wins (default for scalar fields)
|
||||||
|
FieldStrategyNewest FieldStrategy = "newest"
|
||||||
|
// FieldStrategyMax takes the maximum value (for counters like compaction_level)
|
||||||
|
FieldStrategyMax FieldStrategy = "max"
|
||||||
|
// FieldStrategyUnion performs set union (for arrays like labels, waiters)
|
||||||
|
FieldStrategyUnion FieldStrategy = "union"
|
||||||
|
// FieldStrategyManual flags conflict for user resolution (for fields like estimated_minutes)
|
||||||
|
FieldStrategyManual FieldStrategy = "manual"
|
||||||
|
)
|
||||||
|
|
||||||
// validConflictStrategies is the set of allowed conflict strategy values
|
// validConflictStrategies is the set of allowed conflict strategy values
|
||||||
var validConflictStrategies = map[ConflictStrategy]bool{
|
var validConflictStrategies = map[ConflictStrategy]bool{
|
||||||
ConflictStrategyNewest: true,
|
ConflictStrategyNewest: true,
|
||||||
@@ -84,6 +98,14 @@ var validConflictStrategies = map[ConflictStrategy]bool{
|
|||||||
ConflictStrategyManual: true,
|
ConflictStrategyManual: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validFieldStrategies is the set of allowed per-field strategy values
|
||||||
|
var validFieldStrategies = map[FieldStrategy]bool{
|
||||||
|
FieldStrategyNewest: true,
|
||||||
|
FieldStrategyMax: true,
|
||||||
|
FieldStrategyUnion: true,
|
||||||
|
FieldStrategyManual: true,
|
||||||
|
}
|
||||||
|
|
||||||
// ValidConflictStrategies returns the list of valid conflict strategy values.
|
// ValidConflictStrategies returns the list of valid conflict strategy values.
|
||||||
func ValidConflictStrategies() []string {
|
func ValidConflictStrategies() []string {
|
||||||
return []string{
|
return []string{
|
||||||
@@ -99,6 +121,21 @@ func IsValidConflictStrategy(strategy string) bool {
|
|||||||
return validConflictStrategies[ConflictStrategy(strings.ToLower(strings.TrimSpace(strategy)))]
|
return validConflictStrategies[ConflictStrategy(strings.ToLower(strings.TrimSpace(strategy)))]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidFieldStrategies returns the list of valid per-field strategy values.
|
||||||
|
func ValidFieldStrategies() []string {
|
||||||
|
return []string{
|
||||||
|
string(FieldStrategyNewest),
|
||||||
|
string(FieldStrategyMax),
|
||||||
|
string(FieldStrategyUnion),
|
||||||
|
string(FieldStrategyManual),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsValidFieldStrategy returns true if the given string is a valid per-field strategy.
|
||||||
|
func IsValidFieldStrategy(strategy string) bool {
|
||||||
|
return validFieldStrategies[FieldStrategy(strings.ToLower(strings.TrimSpace(strategy)))]
|
||||||
|
}
|
||||||
|
|
||||||
// Sovereignty represents the federation sovereignty tier
|
// Sovereignty represents the federation sovereignty tier
|
||||||
type Sovereignty string
|
type Sovereignty string
|
||||||
|
|
||||||
@@ -223,3 +260,8 @@ func (s ConflictStrategy) String() string {
|
|||||||
func (s Sovereignty) String() string {
|
func (s Sovereignty) String() string {
|
||||||
return string(s)
|
return string(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// String returns the string representation of the FieldStrategy.
|
||||||
|
func (f FieldStrategy) String() string {
|
||||||
|
return string(f)
|
||||||
|
}
|
||||||
|
|||||||
@@ -478,3 +478,57 @@ func TestSovereigntyString(t *testing.T) {
|
|||||||
t.Errorf("SovereigntyNone.String() = %q, want %q", got, "")
|
t.Errorf("SovereigntyNone.String() = %q, want %q", got, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFieldStrategyString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
strategy FieldStrategy
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{FieldStrategyNewest, "newest"},
|
||||||
|
{FieldStrategyMax, "max"},
|
||||||
|
{FieldStrategyUnion, "union"},
|
||||||
|
{FieldStrategyManual, "manual"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := tt.strategy.String(); got != tt.expected {
|
||||||
|
t.Errorf("%v.String() = %q, want %q", tt.strategy, got, tt.expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidFieldStrategies(t *testing.T) {
|
||||||
|
strategies := ValidFieldStrategies()
|
||||||
|
if len(strategies) != 4 {
|
||||||
|
t.Errorf("ValidFieldStrategies() returned %d strategies, want 4", len(strategies))
|
||||||
|
}
|
||||||
|
expected := []string{"newest", "max", "union", "manual"}
|
||||||
|
for i, s := range strategies {
|
||||||
|
if s != expected[i] {
|
||||||
|
t.Errorf("ValidFieldStrategies()[%d] = %q, want %q", i, s, expected[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsValidFieldStrategy(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
strategy string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"newest", true},
|
||||||
|
{"max", true},
|
||||||
|
{"union", true},
|
||||||
|
{"manual", true},
|
||||||
|
{"NEWEST", true}, // case insensitive
|
||||||
|
{" max ", true}, // whitespace trimmed
|
||||||
|
{"invalid", false},
|
||||||
|
{"lww", false},
|
||||||
|
{"", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := IsValidFieldStrategy(tt.strategy); got != tt.valid {
|
||||||
|
t.Errorf("IsValidFieldStrategy(%q) = %v, want %v", tt.strategy, got, tt.valid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user