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
@@ -574,16 +574,67 @@ func GetSyncConfig() SyncConfig {
|
||||
|
||||
// ConflictConfig holds the conflict resolution configuration.
|
||||
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.
|
||||
func GetConflictConfig() ConflictConfig {
|
||||
return ConflictConfig{
|
||||
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.
|
||||
type FederationConfig struct {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// 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
|
||||
var validConflictStrategies = map[ConflictStrategy]bool{
|
||||
ConflictStrategyNewest: true,
|
||||
@@ -84,6 +98,14 @@ var validConflictStrategies = map[ConflictStrategy]bool{
|
||||
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.
|
||||
func ValidConflictStrategies() []string {
|
||||
return []string{
|
||||
@@ -99,6 +121,21 @@ func IsValidConflictStrategy(strategy string) bool {
|
||||
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
|
||||
type Sovereignty string
|
||||
|
||||
@@ -223,3 +260,8 @@ func (s ConflictStrategy) String() string {
|
||||
func (s Sovereignty) String() string {
|
||||
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, "")
|
||||
}
|
||||
}
|
||||
|
||||
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