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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user