feat(sync): wire up sync.mode config to change sync behavior

Implements hq-ew1mbr.27: The sync.mode config now actually changes how
bd sync operates:

- git-portable (default): JSONL exported on push, imported on pull
- realtime: JSONL exported on every change (placeholder for daemon hook)
- dolt-native: Uses Dolt Push/Pull, skips JSONL workflow entirely
- belt-and-suspenders: Both Dolt remotes AND JSONL for redundancy

Changes:
- Add sync_mode.go with mode constants, Get/Set functions, and helpers
- Update bd sync --status to show actual mode from config
- Add --set-mode flag to bd sync for configuring the mode
- Modify doExportSync to respect mode (Dolt push for dolt-native)
- Modify doPullFirstSync to use Dolt pull for dolt-native mode
- Add RemoteStorage interface for Push/Pull operations
- Add comprehensive tests for sync mode functionality

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beads/crew/emma
2026-01-17 13:59:53 -08:00
committed by Steve Yegge
parent c99bd00ca7
commit 356ab92b78
6 changed files with 494 additions and 520 deletions

View File

@@ -1,329 +0,0 @@
package config
import (
"bytes"
"os"
"strings"
"testing"
)
func TestGetSyncMode(t *testing.T) {
tests := []struct {
name string
configValue string
expectedMode SyncMode
expectsWarning bool
}{
{
name: "empty returns default",
configValue: "",
expectedMode: SyncModeGitPortable,
expectsWarning: false,
},
{
name: "git-portable is valid",
configValue: "git-portable",
expectedMode: SyncModeGitPortable,
expectsWarning: false,
},
{
name: "realtime is valid",
configValue: "realtime",
expectedMode: SyncModeRealtime,
expectsWarning: false,
},
{
name: "dolt-native is valid",
configValue: "dolt-native",
expectedMode: SyncModeDoltNative,
expectsWarning: false,
},
{
name: "belt-and-suspenders is valid",
configValue: "belt-and-suspenders",
expectedMode: SyncModeBeltAndSuspenders,
expectsWarning: false,
},
{
name: "mixed case is normalized",
configValue: "Git-Portable",
expectedMode: SyncModeGitPortable,
expectsWarning: false,
},
{
name: "whitespace is trimmed",
configValue: " realtime ",
expectedMode: SyncModeRealtime,
expectsWarning: false,
},
{
name: "invalid value returns default with warning",
configValue: "invalid-mode",
expectedMode: SyncModeGitPortable,
expectsWarning: true,
},
{
name: "typo returns default with warning",
configValue: "git-portabel",
expectedMode: SyncModeGitPortable,
expectsWarning: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset viper for test
ResetForTesting()
if err := Initialize(); err != nil {
t.Fatalf("Initialize failed: %v", err)
}
// Set the config value
if tt.configValue != "" {
Set("sync.mode", tt.configValue)
}
// Capture stderr
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
result := GetSyncMode()
// Restore stderr and get output
w.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
buf.ReadFrom(r)
stderrOutput := buf.String()
if result != tt.expectedMode {
t.Errorf("GetSyncMode() = %q, want %q", result, tt.expectedMode)
}
hasWarning := strings.Contains(stderrOutput, "Warning:")
if tt.expectsWarning && !hasWarning {
t.Errorf("Expected warning in stderr, got none. stderr=%q", stderrOutput)
}
if !tt.expectsWarning && hasWarning {
t.Errorf("Unexpected warning in stderr: %q", stderrOutput)
}
})
}
}
func TestGetConflictStrategy(t *testing.T) {
tests := []struct {
name string
configValue string
expectedStrategy ConflictStrategy
expectsWarning bool
}{
{
name: "empty returns default",
configValue: "",
expectedStrategy: ConflictStrategyNewest,
expectsWarning: false,
},
{
name: "newest is valid",
configValue: "newest",
expectedStrategy: ConflictStrategyNewest,
expectsWarning: false,
},
{
name: "ours is valid",
configValue: "ours",
expectedStrategy: ConflictStrategyOurs,
expectsWarning: false,
},
{
name: "theirs is valid",
configValue: "theirs",
expectedStrategy: ConflictStrategyTheirs,
expectsWarning: false,
},
{
name: "manual is valid",
configValue: "manual",
expectedStrategy: ConflictStrategyManual,
expectsWarning: false,
},
{
name: "mixed case is normalized",
configValue: "NEWEST",
expectedStrategy: ConflictStrategyNewest,
expectsWarning: false,
},
{
name: "whitespace is trimmed",
configValue: " ours ",
expectedStrategy: ConflictStrategyOurs,
expectsWarning: false,
},
{
name: "invalid value returns default with warning",
configValue: "invalid-strategy",
expectedStrategy: ConflictStrategyNewest,
expectsWarning: true,
},
{
name: "last-write-wins typo returns default with warning",
configValue: "last-write-wins",
expectedStrategy: ConflictStrategyNewest,
expectsWarning: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset viper for test
ResetForTesting()
if err := Initialize(); err != nil {
t.Fatalf("Initialize failed: %v", err)
}
// Set the config value
if tt.configValue != "" {
Set("conflict.strategy", tt.configValue)
}
// Capture stderr
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
result := GetConflictStrategy()
// Restore stderr and get output
w.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
buf.ReadFrom(r)
stderrOutput := buf.String()
if result != tt.expectedStrategy {
t.Errorf("GetConflictStrategy() = %q, want %q", result, tt.expectedStrategy)
}
hasWarning := strings.Contains(stderrOutput, "Warning:")
if tt.expectsWarning && !hasWarning {
t.Errorf("Expected warning in stderr, got none. stderr=%q", stderrOutput)
}
if !tt.expectsWarning && hasWarning {
t.Errorf("Unexpected warning in stderr: %q", stderrOutput)
}
})
}
}
func TestGetSovereignty(t *testing.T) {
tests := []struct {
name string
configValue string
expectedTier Sovereignty
expectsWarning bool
}{
{
name: "empty returns default",
configValue: "",
expectedTier: SovereigntyT1,
expectsWarning: false,
},
{
name: "T1 is valid",
configValue: "T1",
expectedTier: SovereigntyT1,
expectsWarning: false,
},
{
name: "T2 is valid",
configValue: "T2",
expectedTier: SovereigntyT2,
expectsWarning: false,
},
{
name: "T3 is valid",
configValue: "T3",
expectedTier: SovereigntyT3,
expectsWarning: false,
},
{
name: "T4 is valid",
configValue: "T4",
expectedTier: SovereigntyT4,
expectsWarning: false,
},
{
name: "lowercase is normalized",
configValue: "t1",
expectedTier: SovereigntyT1,
expectsWarning: false,
},
{
name: "whitespace is trimmed",
configValue: " T2 ",
expectedTier: SovereigntyT2,
expectsWarning: false,
},
{
name: "invalid value returns default with warning",
configValue: "T5",
expectedTier: SovereigntyT1,
expectsWarning: true,
},
{
name: "invalid tier 0 returns default with warning",
configValue: "T0",
expectedTier: SovereigntyT1,
expectsWarning: true,
},
{
name: "word tier returns default with warning",
configValue: "public",
expectedTier: SovereigntyT1,
expectsWarning: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Reset viper for test
ResetForTesting()
if err := Initialize(); err != nil {
t.Fatalf("Initialize failed: %v", err)
}
// Set the config value
if tt.configValue != "" {
Set("federation.sovereignty", tt.configValue)
}
// Capture stderr
oldStderr := os.Stderr
r, w, _ := os.Pipe()
os.Stderr = w
result := GetSovereignty()
// Restore stderr and get output
w.Close()
os.Stderr = oldStderr
var buf bytes.Buffer
buf.ReadFrom(r)
stderrOutput := buf.String()
if result != tt.expectedTier {
t.Errorf("GetSovereignty() = %q, want %q", result, tt.expectedTier)
}
hasWarning := strings.Contains(stderrOutput, "Warning:")
if tt.expectsWarning && !hasWarning {
t.Errorf("Expected warning in stderr, got none. stderr=%q", stderrOutput)
}
if !tt.expectsWarning && hasWarning {
t.Errorf("Unexpected warning in stderr: %q", stderrOutput)
}
})
}
}