feat(sync): add sync mode configuration (hq-ew1mbr.3)

Add configurable sync modes for Dolt storage integration:

Sync modes:
- git-portable (default): Export JSONL on push, import on pull
- realtime: Export JSONL on every database change
- dolt-native: Use Dolt remotes directly (no JSONL)
- belt-and-suspenders: Both Dolt remote AND JSONL backup

Configuration options in .beads/config.yaml:
- sync.mode: Select sync mode
- sync.export_on: push (default) or change
- sync.import_on: pull (default) or change
- conflict.strategy: newest (default), ours, theirs, manual
- federation.remote: Dolt remote URL for dolt-native mode
- federation.sovereignty: T1-T4 data sovereignty tier

The sync command now displays configuration in `bd sync --status`
and uses configured conflict strategy for resolution.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
darcy
2026-01-17 10:52:08 -08:00
committed by Steve Yegge
parent ba0e754dc8
commit 16f8c3d3ae
4 changed files with 728 additions and 9 deletions

View File

@@ -998,3 +998,417 @@ validation:
t.Errorf("GetString(validation.on-sync) = %q, want \"warn\"", got)
}
}
// Tests for sync mode configuration (hq-ew1mbr.3)
func TestSyncModeConstants(t *testing.T) {
// Verify sync mode constants have expected string values
if SyncModeGitPortable != "git-portable" {
t.Errorf("SyncModeGitPortable = %q, want \"git-portable\"", SyncModeGitPortable)
}
if SyncModeRealtime != "realtime" {
t.Errorf("SyncModeRealtime = %q, want \"realtime\"", SyncModeRealtime)
}
if SyncModeDoltNative != "dolt-native" {
t.Errorf("SyncModeDoltNative = %q, want \"dolt-native\"", SyncModeDoltNative)
}
if SyncModeBeltAndSuspenders != "belt-and-suspenders" {
t.Errorf("SyncModeBeltAndSuspenders = %q, want \"belt-and-suspenders\"", SyncModeBeltAndSuspenders)
}
}
func TestSyncTriggerConstants(t *testing.T) {
if SyncTriggerPush != "push" {
t.Errorf("SyncTriggerPush = %q, want \"push\"", SyncTriggerPush)
}
if SyncTriggerChange != "change" {
t.Errorf("SyncTriggerChange = %q, want \"change\"", SyncTriggerChange)
}
if SyncTriggerPull != "pull" {
t.Errorf("SyncTriggerPull = %q, want \"pull\"", SyncTriggerPull)
}
}
func TestConflictStrategyConstants(t *testing.T) {
if ConflictStrategyNewest != "newest" {
t.Errorf("ConflictStrategyNewest = %q, want \"newest\"", ConflictStrategyNewest)
}
if ConflictStrategyOurs != "ours" {
t.Errorf("ConflictStrategyOurs = %q, want \"ours\"", ConflictStrategyOurs)
}
if ConflictStrategyTheirs != "theirs" {
t.Errorf("ConflictStrategyTheirs = %q, want \"theirs\"", ConflictStrategyTheirs)
}
if ConflictStrategyManual != "manual" {
t.Errorf("ConflictStrategyManual = %q, want \"manual\"", ConflictStrategyManual)
}
}
func TestSovereigntyConstants(t *testing.T) {
if SovereigntyT1 != "T1" {
t.Errorf("SovereigntyT1 = %q, want \"T1\"", SovereigntyT1)
}
if SovereigntyT2 != "T2" {
t.Errorf("SovereigntyT2 = %q, want \"T2\"", SovereigntyT2)
}
if SovereigntyT3 != "T3" {
t.Errorf("SovereigntyT3 = %q, want \"T3\"", SovereigntyT3)
}
if SovereigntyT4 != "T4" {
t.Errorf("SovereigntyT4 = %q, want \"T4\"", SovereigntyT4)
}
}
func TestSyncConfigDefaults(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Initialize config
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Test sync mode default
if got := GetSyncMode(); got != SyncModeGitPortable {
t.Errorf("GetSyncMode() = %q, want %q", got, SyncModeGitPortable)
}
// Test sync config defaults
cfg := GetSyncConfig()
if cfg.Mode != SyncModeGitPortable {
t.Errorf("GetSyncConfig().Mode = %q, want %q", cfg.Mode, SyncModeGitPortable)
}
if cfg.ExportOn != SyncTriggerPush {
t.Errorf("GetSyncConfig().ExportOn = %q, want %q", cfg.ExportOn, SyncTriggerPush)
}
if cfg.ImportOn != SyncTriggerPull {
t.Errorf("GetSyncConfig().ImportOn = %q, want %q", cfg.ImportOn, SyncTriggerPull)
}
}
func TestConflictConfigDefaults(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Initialize config
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Test conflict strategy default
if got := GetConflictStrategy(); got != ConflictStrategyNewest {
t.Errorf("GetConflictStrategy() = %q, want %q", got, ConflictStrategyNewest)
}
// Test conflict config
cfg := GetConflictConfig()
if cfg.Strategy != ConflictStrategyNewest {
t.Errorf("GetConflictConfig().Strategy = %q, want %q", cfg.Strategy, ConflictStrategyNewest)
}
}
func TestFederationConfigDefaults(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Initialize config
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Test federation config defaults (empty)
cfg := GetFederationConfig()
if cfg.Remote != "" {
t.Errorf("GetFederationConfig().Remote = %q, want empty", cfg.Remote)
}
if cfg.Sovereignty != "" {
t.Errorf("GetFederationConfig().Sovereignty = %q, want empty", cfg.Sovereignty)
}
}
func TestIsSyncModeValid(t *testing.T) {
tests := []struct {
mode string
valid bool
}{
{SyncModeGitPortable, true},
{SyncModeRealtime, true},
{SyncModeDoltNative, true},
{SyncModeBeltAndSuspenders, true},
{"invalid-mode", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.mode, func(t *testing.T) {
if got := IsSyncModeValid(tt.mode); got != tt.valid {
t.Errorf("IsSyncModeValid(%q) = %v, want %v", tt.mode, got, tt.valid)
}
})
}
}
func TestIsConflictStrategyValid(t *testing.T) {
tests := []struct {
strategy string
valid bool
}{
{ConflictStrategyNewest, true},
{ConflictStrategyOurs, true},
{ConflictStrategyTheirs, true},
{ConflictStrategyManual, true},
{"invalid-strategy", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.strategy, func(t *testing.T) {
if got := IsConflictStrategyValid(tt.strategy); got != tt.valid {
t.Errorf("IsConflictStrategyValid(%q) = %v, want %v", tt.strategy, got, tt.valid)
}
})
}
}
func TestIsSovereigntyValid(t *testing.T) {
tests := []struct {
sovereignty string
valid bool
}{
{SovereigntyT1, true},
{SovereigntyT2, true},
{SovereigntyT3, true},
{SovereigntyT4, true},
{"", true}, // Empty is valid (means no restriction)
{"T5", false},
{"invalid", false},
}
for _, tt := range tests {
t.Run(tt.sovereignty, func(t *testing.T) {
if got := IsSovereigntyValid(tt.sovereignty); got != tt.valid {
t.Errorf("IsSovereigntyValid(%q) = %v, want %v", tt.sovereignty, got, tt.valid)
}
})
}
}
func TestSyncConfigFromFile(t *testing.T) {
// Create a temporary directory for config file
tmpDir := t.TempDir()
// Create a config file with sync settings
configContent := `
sync:
mode: realtime
export_on: change
import_on: change
conflict:
strategy: ours
federation:
remote: dolthub://myorg/beads
sovereignty: T2
`
beadsDir := filepath.Join(tmpDir, ".beads")
if err := os.MkdirAll(beadsDir, 0750); err != nil {
t.Fatalf("failed to create .beads directory: %v", err)
}
configPath := filepath.Join(beadsDir, "config.yaml")
if err := os.WriteFile(configPath, []byte(configContent), 0600); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
// Change to tmp directory
t.Chdir(tmpDir)
// Initialize viper
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Test sync config
syncCfg := GetSyncConfig()
if syncCfg.Mode != SyncModeRealtime {
t.Errorf("GetSyncConfig().Mode = %q, want %q", syncCfg.Mode, SyncModeRealtime)
}
if syncCfg.ExportOn != SyncTriggerChange {
t.Errorf("GetSyncConfig().ExportOn = %q, want %q", syncCfg.ExportOn, SyncTriggerChange)
}
if syncCfg.ImportOn != SyncTriggerChange {
t.Errorf("GetSyncConfig().ImportOn = %q, want %q", syncCfg.ImportOn, SyncTriggerChange)
}
// Test conflict config
conflictCfg := GetConflictConfig()
if conflictCfg.Strategy != ConflictStrategyOurs {
t.Errorf("GetConflictConfig().Strategy = %q, want %q", conflictCfg.Strategy, ConflictStrategyOurs)
}
// Test federation config
fedCfg := GetFederationConfig()
if fedCfg.Remote != "dolthub://myorg/beads" {
t.Errorf("GetFederationConfig().Remote = %q, want \"dolthub://myorg/beads\"", fedCfg.Remote)
}
if fedCfg.Sovereignty != SovereigntyT2 {
t.Errorf("GetFederationConfig().Sovereignty = %q, want %q", fedCfg.Sovereignty, SovereigntyT2)
}
}
func TestShouldExportOnChange(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Initialize config
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Default should be false (export on push, not change)
if ShouldExportOnChange() {
t.Error("ShouldExportOnChange() = true, want false (default)")
}
// Set to change
Set("sync.export_on", SyncTriggerChange)
if !ShouldExportOnChange() {
t.Error("ShouldExportOnChange() = false after setting to change, want true")
}
}
func TestShouldImportOnChange(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Initialize config
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Default should be false (import on pull, not change)
if ShouldImportOnChange() {
t.Error("ShouldImportOnChange() = true, want false (default)")
}
// Set to change
Set("sync.import_on", SyncTriggerChange)
if !ShouldImportOnChange() {
t.Error("ShouldImportOnChange() = false after setting to change, want true")
}
}
func TestNeedsDoltRemote(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
tests := []struct {
mode string
needsRemote bool
}{
{SyncModeGitPortable, false},
{SyncModeRealtime, false},
{SyncModeDoltNative, true},
{SyncModeBeltAndSuspenders, true},
}
for _, tt := range tests {
t.Run(tt.mode, func(t *testing.T) {
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
Set("sync.mode", tt.mode)
if got := NeedsDoltRemote(); got != tt.needsRemote {
t.Errorf("NeedsDoltRemote() with mode=%s = %v, want %v", tt.mode, got, tt.needsRemote)
}
})
}
}
func TestNeedsJSONL(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
tests := []struct {
mode string
needsJSONL bool
}{
{SyncModeGitPortable, true},
{SyncModeRealtime, true},
{SyncModeDoltNative, false},
{SyncModeBeltAndSuspenders, true},
}
for _, tt := range tests {
t.Run(tt.mode, func(t *testing.T) {
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
Set("sync.mode", tt.mode)
if got := NeedsJSONL(); got != tt.needsJSONL {
t.Errorf("NeedsJSONL() with mode=%s = %v, want %v", tt.mode, got, tt.needsJSONL)
}
})
}
}
func TestGetSyncModeInvalid(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Initialize config
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Set invalid mode - should fall back to git-portable
Set("sync.mode", "invalid-mode")
if got := GetSyncMode(); got != SyncModeGitPortable {
t.Errorf("GetSyncMode() with invalid mode = %q, want %q (fallback)", got, SyncModeGitPortable)
}
}
func TestGetConflictStrategyInvalid(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Initialize config
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Set invalid strategy - should fall back to newest
Set("conflict.strategy", "invalid-strategy")
if got := GetConflictStrategy(); got != ConflictStrategyNewest {
t.Errorf("GetConflictStrategy() with invalid strategy = %q, want %q (fallback)", got, ConflictStrategyNewest)
}
}
func TestGetSovereigntyInvalid(t *testing.T) {
// Isolate from environment variables
restore := envSnapshot(t)
defer restore()
// Initialize config
if err := Initialize(); err != nil {
t.Fatalf("Initialize() returned error: %v", err)
}
// Set invalid sovereignty - should return empty
Set("federation.sovereignty", "T99")
if got := GetSovereignty(); got != "" {
t.Errorf("GetSovereignty() with invalid tier = %q, want empty (fallback)", got)
}
}