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:
beads/crew/lydia
2026-01-21 19:39:20 -08:00
committed by Steve Yegge
parent e0dc3a37c3
commit 9a9704b451
9 changed files with 698 additions and 91 deletions

View File

@@ -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