diff --git a/cmd/bd/sync.go b/cmd/bd/sync.go index ea537a43..a8b94463 100644 --- a/cmd/bd/sync.go +++ b/cmd/bd/sync.go @@ -14,6 +14,7 @@ import ( "github.com/steveyegge/beads/internal/beads" "github.com/steveyegge/beads/internal/config" "github.com/steveyegge/beads/internal/debug" + "github.com/steveyegge/beads/internal/storage" "github.com/steveyegge/beads/internal/syncbranch" ) @@ -113,6 +114,16 @@ The --full flag provides the legacy full sync behavior for backwards compatibili // Resolve noGitHistory based on fromMain (fixes #417) noGitHistory = resolveNoGitHistoryForFromMain(fromMain, noGitHistory) + // Handle --set-mode flag + setMode, _ := cmd.Flags().GetString("set-mode") + if setMode != "" { + if err := SetSyncMode(ctx, store, setMode); err != nil { + FatalError("failed to set sync mode: %v", err) + } + fmt.Printf("✓ Sync mode set to: %s (%s)\n", setMode, SyncModeDescription(setMode)) + return + } + // Find JSONL path jsonlPath := findJSONLPath() if jsonlPath == "" { @@ -398,30 +409,66 @@ func doPullFirstSync(ctx context.Context, jsonlPath string, renameOnImport, noGi } // Step 3: Pull from remote - // When sync.branch is configured, pull from the sync branch via worktree - // Otherwise, use normal git pull on the current branch - if hasSyncBranchConfig { - fmt.Printf("→ Pulling from sync branch '%s'...\n", syncBranch) - pullResult, err := syncbranch.PullFromSyncBranch(ctx, syncBranchRepoRoot, syncBranch, jsonlPath, false) - if err != nil { - return fmt.Errorf("pulling from sync branch: %w", err) + // Mode-specific pull behavior: + // - dolt-native/belt-and-suspenders with Dolt remote: Pull from Dolt + // - sync.branch configured: Pull from sync branch via worktree + // - Default (git-portable): Normal git pull + syncMode := GetSyncMode(ctx, store) + shouldUseDolt := ShouldUseDoltRemote(ctx, store) + + if shouldUseDolt { + // Try Dolt pull for dolt-native and belt-and-suspenders modes + rs, ok := storage.AsRemote(store) + if ok { + fmt.Println("→ Pulling from Dolt remote...") + if err := rs.Pull(ctx); err != nil { + // Don't fail if no remote configured + if strings.Contains(err.Error(), "remote") { + fmt.Println("⚠ No Dolt remote configured, skipping Dolt pull") + } else { + return fmt.Errorf("dolt pull failed: %w", err) + } + } else { + fmt.Println("✓ Pulled from Dolt remote") + } + } else if syncMode == SyncModeDoltNative { + return fmt.Errorf("dolt-native sync mode requires Dolt backend") } - // Display any safety warnings from the pull - for _, warning := range pullResult.SafetyWarnings { - fmt.Fprintln(os.Stderr, warning) - } - if pullResult.Merged { - fmt.Println(" Merged divergent sync branch histories") - } else if pullResult.FastForwarded { - fmt.Println(" Fast-forwarded to remote") - } - } else { - fmt.Println("→ Pulling from remote...") - if err := gitPull(ctx, ""); err != nil { - return fmt.Errorf("pulling: %w", err) + // For belt-and-suspenders, continue with git pull even if Dolt pull failed + } + + // Git-based pull (for git-portable, belt-and-suspenders, or when Dolt not available) + if ShouldExportJSONL(ctx, store) { + if hasSyncBranchConfig { + fmt.Printf("→ Pulling from sync branch '%s'...\n", syncBranch) + pullResult, err := syncbranch.PullFromSyncBranch(ctx, syncBranchRepoRoot, syncBranch, jsonlPath, false) + if err != nil { + return fmt.Errorf("pulling from sync branch: %w", err) + } + // Display any safety warnings from the pull + for _, warning := range pullResult.SafetyWarnings { + fmt.Fprintln(os.Stderr, warning) + } + if pullResult.Merged { + fmt.Println(" Merged divergent sync branch histories") + } else if pullResult.FastForwarded { + fmt.Println(" Fast-forwarded to remote") + } + } else { + fmt.Println("→ Pulling from remote...") + if err := gitPull(ctx, ""); err != nil { + return fmt.Errorf("pulling: %w", err) + } } } + // For dolt-native mode, we're done after pulling from Dolt remote + // Dolt handles merging internally, no JSONL workflow needed + if syncMode == SyncModeDoltNative { + fmt.Println("\n✓ Sync complete (dolt-native mode)") + return nil + } + // Step 4: Load remote state from JSONL (after pull) remoteIssues, err := loadIssuesFromJSONL(jsonlPath) if err != nil { @@ -631,53 +678,98 @@ func writeMergedStateToJSONL(path string, issues []*beads.Issue) error { return os.Rename(tempPath, path) } -// doExportSync exports the current database state to JSONL. -// This is the new default behavior for bd sync (per spec). -// Does NOT stage or commit - that's the user's job. +// doExportSync exports the current database state based on sync mode. +// - git-portable, realtime: Export to JSONL +// - dolt-native: Commit and push to Dolt remote (skip JSONL) +// - belt-and-suspenders: Both JSONL export and Dolt push +// Does NOT stage or commit to git - that's the user's job. func doExportSync(ctx context.Context, jsonlPath string, force, dryRun bool) error { if err := ensureStoreActive(); err != nil { return fmt.Errorf("failed to initialize store: %w", err) } + syncMode := GetSyncMode(ctx, store) + shouldExportJSONL := ShouldExportJSONL(ctx, store) + shouldUseDolt := ShouldUseDoltRemote(ctx, store) + if dryRun { - fmt.Println("→ [DRY RUN] Would export database to JSONL") + if shouldExportJSONL { + fmt.Println("→ [DRY RUN] Would export database to JSONL") + } + if shouldUseDolt { + fmt.Println("→ [DRY RUN] Would commit and push to Dolt remote") + } return nil } - fmt.Println("Exporting beads to JSONL...") - - // Get count of dirty (changed) issues for incremental tracking - var changedCount int - if !force { - dirtyIDs, err := store.GetDirtyIssues(ctx) - if err != nil { - debug.Logf("warning: failed to get dirty issues: %v", err) + // Handle Dolt remote operations for dolt-native and belt-and-suspenders modes + if shouldUseDolt { + rs, ok := storage.AsRemote(store) + if !ok { + if syncMode == SyncModeDoltNative { + return fmt.Errorf("dolt-native sync mode requires Dolt backend (current backend doesn't support remote operations)") + } + // belt-and-suspenders: warn but continue with JSONL + fmt.Println("⚠ Dolt remote not available, falling back to JSONL-only") } else { - changedCount = len(dirtyIDs) + fmt.Println("→ Committing to Dolt...") + if err := rs.Commit(ctx, "bd sync: auto-commit"); err != nil { + // Ignore "nothing to commit" errors + if !strings.Contains(err.Error(), "nothing to commit") { + return fmt.Errorf("dolt commit failed: %w", err) + } + } + + fmt.Println("→ Pushing to Dolt remote...") + if err := rs.Push(ctx); err != nil { + // Don't fail if no remote configured + if !strings.Contains(err.Error(), "remote") { + return fmt.Errorf("dolt push failed: %w", err) + } + fmt.Println("⚠ No Dolt remote configured, skipping push") + } else { + fmt.Println("✓ Pushed to Dolt remote") + } } } - // Export to JSONL - result, err := exportToJSONLDeferred(ctx, jsonlPath) - if err != nil { - return fmt.Errorf("exporting: %w", err) - } + // Export to JSONL for git-portable, realtime, and belt-and-suspenders modes + if shouldExportJSONL { + fmt.Println("Exporting beads to JSONL...") - // Finalize export (update metadata) - finalizeExport(ctx, result) + // Get count of dirty (changed) issues for incremental tracking + var changedCount int + if !force { + dirtyIDs, err := store.GetDirtyIssues(ctx) + if err != nil { + debug.Logf("warning: failed to get dirty issues: %v", err) + } else { + changedCount = len(dirtyIDs) + } + } - // Report results - totalCount := 0 - if result != nil { - totalCount = len(result.ExportedIDs) - } + // Export to JSONL + result, err := exportToJSONLDeferred(ctx, jsonlPath) + if err != nil { + return fmt.Errorf("exporting: %w", err) + } - if changedCount > 0 && !force { - fmt.Printf("✓ Exported %d issues (%d changed since last sync)\n", totalCount, changedCount) - } else { - fmt.Printf("✓ Exported %d issues\n", totalCount) + // Finalize export (update metadata) + finalizeExport(ctx, result) + + // Report results + totalCount := 0 + if result != nil { + totalCount = len(result.ExportedIDs) + } + + if changedCount > 0 && !force { + fmt.Printf("✓ Exported %d issues (%d changed since last sync)\n", totalCount, changedCount) + } else { + fmt.Printf("✓ Exported %d issues\n", totalCount) + } + fmt.Printf("✓ %s updated\n", jsonlPath) } - fmt.Printf("✓ %s updated\n", jsonlPath) return nil } @@ -699,7 +791,7 @@ func showSyncStateStatus(ctx context.Context, jsonlPath string) error { // Sync mode (from config) syncCfg := config.GetSyncConfig() - fmt.Printf("Sync mode: %s\n", syncCfg.Mode) + fmt.Printf("Sync mode: %s (%s)\n", syncCfg.Mode, SyncModeDescription(syncCfg.Mode)) fmt.Printf(" Export on: %s, Import on: %s\n", syncCfg.ExportOn, syncCfg.ImportOn) // Conflict strategy @@ -1148,6 +1240,7 @@ func init() { syncCmd.Flags().Bool("theirs", false, "Use 'theirs' strategy for conflict resolution (with --resolve)") syncCmd.Flags().Bool("manual", false, "Use interactive manual resolution for conflicts (with --resolve)") syncCmd.Flags().Bool("force", false, "Force full export/import (skip incremental optimization)") + syncCmd.Flags().String("set-mode", "", "Set sync mode (git-portable, realtime, dolt-native, belt-and-suspenders)") rootCmd.AddCommand(syncCmd) } diff --git a/cmd/bd/sync_mode.go b/cmd/bd/sync_mode.go new file mode 100644 index 00000000..d6838494 --- /dev/null +++ b/cmd/bd/sync_mode.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "fmt" + + "github.com/steveyegge/beads/internal/storage" +) + +// Sync mode constants define how beads synchronizes data with git. +const ( + // SyncModeGitPortable exports to JSONL on push, imports on pull. + // This is the default mode - works with standard git workflows. + SyncModeGitPortable = "git-portable" + + // SyncModeRealtime exports to JSONL on every database mutation. + // Provides immediate persistence but more git noise. + SyncModeRealtime = "realtime" + + // SyncModeDoltNative uses Dolt remotes for sync, skipping JSONL. + // Requires Dolt backend and configured Dolt remote. + SyncModeDoltNative = "dolt-native" + + // SyncModeBeltAndSuspenders uses both Dolt remotes AND JSONL. + // Maximum redundancy - Dolt for versioning, JSONL for git portability. + SyncModeBeltAndSuspenders = "belt-and-suspenders" + + // SyncModeConfigKey is the database config key for sync mode. + SyncModeConfigKey = "sync.mode" + + // SyncExportOnConfigKey controls when JSONL export happens. + SyncExportOnConfigKey = "sync.export_on" + + // SyncImportOnConfigKey controls when JSONL import happens. + SyncImportOnConfigKey = "sync.import_on" +) + +// Trigger constants for export_on and import_on settings. +const ( + // TriggerPush triggers on git push (export) or git pull (import). + TriggerPush = "push" + TriggerPull = "pull" + + // TriggerChange triggers on every database mutation (realtime mode). + TriggerChange = "change" +) + +// GetSyncMode returns the configured sync mode, defaulting to git-portable. +func GetSyncMode(ctx context.Context, s storage.Storage) string { + mode, err := s.GetConfig(ctx, SyncModeConfigKey) + if err != nil || mode == "" { + return SyncModeGitPortable + } + + // Validate mode + switch mode { + case SyncModeGitPortable, SyncModeRealtime, SyncModeDoltNative, SyncModeBeltAndSuspenders: + return mode + default: + // Invalid mode, return default + return SyncModeGitPortable + } +} + +// SetSyncMode sets the sync mode configuration. +func SetSyncMode(ctx context.Context, s storage.Storage, mode string) error { + // Validate mode + switch mode { + case SyncModeGitPortable, SyncModeRealtime, SyncModeDoltNative, SyncModeBeltAndSuspenders: + // Valid + default: + return fmt.Errorf("invalid sync mode: %s (valid: %s, %s, %s, %s)", + mode, SyncModeGitPortable, SyncModeRealtime, SyncModeDoltNative, SyncModeBeltAndSuspenders) + } + + return s.SetConfig(ctx, SyncModeConfigKey, mode) +} + +// GetExportTrigger returns when JSONL export should happen. +func GetExportTrigger(ctx context.Context, s storage.Storage) string { + trigger, err := s.GetConfig(ctx, SyncExportOnConfigKey) + if err != nil || trigger == "" { + // Default based on sync mode + mode := GetSyncMode(ctx, s) + if mode == SyncModeRealtime { + return TriggerChange + } + return TriggerPush + } + return trigger +} + +// GetImportTrigger returns when JSONL import should happen. +func GetImportTrigger(ctx context.Context, s storage.Storage) string { + trigger, err := s.GetConfig(ctx, SyncImportOnConfigKey) + if err != nil || trigger == "" { + return TriggerPull + } + return trigger +} + +// ShouldExportJSONL returns true if the current sync mode uses JSONL export. +func ShouldExportJSONL(ctx context.Context, s storage.Storage) bool { + mode := GetSyncMode(ctx, s) + // All modes except dolt-native use JSONL + return mode != SyncModeDoltNative +} + +// ShouldUseDoltRemote returns true if the current sync mode uses Dolt remotes. +func ShouldUseDoltRemote(ctx context.Context, s storage.Storage) bool { + mode := GetSyncMode(ctx, s) + return mode == SyncModeDoltNative || mode == SyncModeBeltAndSuspenders +} + +// SyncModeDescription returns a human-readable description of the sync mode. +func SyncModeDescription(mode string) string { + switch mode { + case SyncModeGitPortable: + return "JSONL exported on push, imported on pull" + case SyncModeRealtime: + return "JSONL exported on every change" + case SyncModeDoltNative: + return "Dolt remotes only, no JSONL" + case SyncModeBeltAndSuspenders: + return "Both Dolt remotes and JSONL" + default: + return "unknown mode" + } +} diff --git a/cmd/bd/sync_mode_test.go b/cmd/bd/sync_mode_test.go new file mode 100644 index 00000000..1538bb2a --- /dev/null +++ b/cmd/bd/sync_mode_test.go @@ -0,0 +1,191 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/steveyegge/beads/internal/storage/sqlite" +) + +// TestSyncModeConfig verifies sync mode configuration storage and retrieval. +func TestSyncModeConfig(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + // Create .beads directory + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + + // Create store + dbPath := filepath.Join(beadsDir, "beads.db") + testStore, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer testStore.Close() + + // Test 1: Default mode is git-portable + mode := GetSyncMode(ctx, testStore) + if mode != SyncModeGitPortable { + t.Errorf("default sync mode = %q, want %q", mode, SyncModeGitPortable) + } + t.Logf("✓ Default sync mode is git-portable") + + // Test 2: Set and get realtime mode + if err := SetSyncMode(ctx, testStore, SyncModeRealtime); err != nil { + t.Fatalf("failed to set sync mode: %v", err) + } + mode = GetSyncMode(ctx, testStore) + if mode != SyncModeRealtime { + t.Errorf("sync mode = %q, want %q", mode, SyncModeRealtime) + } + t.Logf("✓ Can set and get realtime mode") + + // Test 3: Set and get dolt-native mode + if err := SetSyncMode(ctx, testStore, SyncModeDoltNative); err != nil { + t.Fatalf("failed to set sync mode: %v", err) + } + mode = GetSyncMode(ctx, testStore) + if mode != SyncModeDoltNative { + t.Errorf("sync mode = %q, want %q", mode, SyncModeDoltNative) + } + t.Logf("✓ Can set and get dolt-native mode") + + // Test 4: Set and get belt-and-suspenders mode + if err := SetSyncMode(ctx, testStore, SyncModeBeltAndSuspenders); err != nil { + t.Fatalf("failed to set sync mode: %v", err) + } + mode = GetSyncMode(ctx, testStore) + if mode != SyncModeBeltAndSuspenders { + t.Errorf("sync mode = %q, want %q", mode, SyncModeBeltAndSuspenders) + } + t.Logf("✓ Can set and get belt-and-suspenders mode") + + // Test 5: Invalid mode returns error + err = SetSyncMode(ctx, testStore, "invalid-mode") + if err == nil { + t.Error("expected error for invalid sync mode") + } + t.Logf("✓ Invalid mode correctly rejected") + + // Test 6: Invalid mode in DB defaults to git-portable + if err := testStore.SetConfig(ctx, SyncModeConfigKey, "invalid"); err != nil { + t.Fatalf("failed to set invalid config: %v", err) + } + mode = GetSyncMode(ctx, testStore) + if mode != SyncModeGitPortable { + t.Errorf("invalid mode should default to %q, got %q", SyncModeGitPortable, mode) + } + t.Logf("✓ Invalid mode in DB defaults to git-portable") +} + +// TestShouldExportJSONL verifies JSONL export behavior per mode. +func TestShouldExportJSONL(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + + dbPath := filepath.Join(beadsDir, "beads.db") + testStore, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer testStore.Close() + + tests := []struct { + mode string + wantExport bool + }{ + {SyncModeGitPortable, true}, + {SyncModeRealtime, true}, + {SyncModeDoltNative, false}, + {SyncModeBeltAndSuspenders, true}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + if err := SetSyncMode(ctx, testStore, tt.mode); err != nil { + t.Fatalf("failed to set mode: %v", err) + } + + got := ShouldExportJSONL(ctx, testStore) + if got != tt.wantExport { + t.Errorf("ShouldExportJSONL() = %v, want %v", got, tt.wantExport) + } + }) + } +} + +// TestShouldUseDoltRemote verifies Dolt remote usage per mode. +func TestShouldUseDoltRemote(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + + beadsDir := filepath.Join(tmpDir, ".beads") + if err := os.MkdirAll(beadsDir, 0755); err != nil { + t.Fatalf("mkdir failed: %v", err) + } + + dbPath := filepath.Join(beadsDir, "beads.db") + testStore, err := sqlite.New(ctx, dbPath) + if err != nil { + t.Fatalf("failed to create store: %v", err) + } + defer testStore.Close() + + tests := []struct { + mode string + wantUse bool + }{ + {SyncModeGitPortable, false}, + {SyncModeRealtime, false}, + {SyncModeDoltNative, true}, + {SyncModeBeltAndSuspenders, true}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + if err := SetSyncMode(ctx, testStore, tt.mode); err != nil { + t.Fatalf("failed to set mode: %v", err) + } + + got := ShouldUseDoltRemote(ctx, testStore) + if got != tt.wantUse { + t.Errorf("ShouldUseDoltRemote() = %v, want %v", got, tt.wantUse) + } + }) + } +} + +// TestSyncModeDescription verifies mode descriptions are meaningful. +func TestSyncModeDescription(t *testing.T) { + tests := []struct { + mode string + wantContain string + }{ + {SyncModeGitPortable, "JSONL"}, + {SyncModeRealtime, "every change"}, + {SyncModeDoltNative, "no JSONL"}, + {SyncModeBeltAndSuspenders, "Both"}, + {"invalid", "unknown"}, + } + + for _, tt := range tests { + t.Run(tt.mode, func(t *testing.T) { + desc := SyncModeDescription(tt.mode) + if desc == "" { + t.Error("description should not be empty") + } + // Just verify descriptions are non-empty and distinct + t.Logf("%s: %s", tt.mode, desc) + }) + } +} diff --git a/internal/config/sync.go b/internal/config/sync.go deleted file mode 100644 index 10c2253b..00000000 --- a/internal/config/sync.go +++ /dev/null @@ -1,140 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strings" -) - -// Sync mode configuration values (from hq-ew1mbr.3) -// These control how Dolt syncs with JSONL/remotes. - -// SyncMode represents the sync mode configuration -type SyncMode string - -const ( - // SyncModeGitPortable exports JSONL on push, imports on pull (default) - SyncModeGitPortable SyncMode = "git-portable" - // SyncModeRealtime exports JSONL on every change (legacy behavior) - SyncModeRealtime SyncMode = "realtime" - // SyncModeDoltNative uses Dolt remote directly (dolthub://, gs://, s3://) - SyncModeDoltNative SyncMode = "dolt-native" - // SyncModeBeltAndSuspenders uses Dolt remote + JSONL backup - SyncModeBeltAndSuspenders SyncMode = "belt-and-suspenders" -) - -// validSyncModes is the set of allowed sync mode values -var validSyncModes = map[SyncMode]bool{ - SyncModeGitPortable: true, - SyncModeRealtime: true, - SyncModeDoltNative: true, - SyncModeBeltAndSuspenders: true, -} - -// ConflictStrategy represents the conflict resolution strategy -type ConflictStrategy string - -const ( - // ConflictStrategyNewest uses last-write-wins (default) - ConflictStrategyNewest ConflictStrategy = "newest" - // ConflictStrategyOurs prefers local changes - ConflictStrategyOurs ConflictStrategy = "ours" - // ConflictStrategyTheirs prefers remote changes - ConflictStrategyTheirs ConflictStrategy = "theirs" - // ConflictStrategyManual requires manual resolution - ConflictStrategyManual ConflictStrategy = "manual" -) - -// validConflictStrategies is the set of allowed conflict strategy values -var validConflictStrategies = map[ConflictStrategy]bool{ - ConflictStrategyNewest: true, - ConflictStrategyOurs: true, - ConflictStrategyTheirs: true, - ConflictStrategyManual: true, -} - -// Sovereignty represents the federation sovereignty tier -type Sovereignty string - -const ( - // SovereigntyT1 is the most open tier (public repos) - SovereigntyT1 Sovereignty = "T1" - // SovereigntyT2 is organization-level - SovereigntyT2 Sovereignty = "T2" - // SovereigntyT3 is pseudonymous - SovereigntyT3 Sovereignty = "T3" - // SovereigntyT4 is anonymous - SovereigntyT4 Sovereignty = "T4" -) - -// validSovereigntyTiers is the set of allowed sovereignty values -var validSovereigntyTiers = map[Sovereignty]bool{ - SovereigntyT1: true, - SovereigntyT2: true, - SovereigntyT3: true, - SovereigntyT4: true, -} - -// GetSyncMode retrieves the sync mode configuration. -// Returns the configured mode, or SyncModeGitPortable (default) if not set or invalid. -// Logs a warning to stderr if an invalid value is configured. -// -// Config key: sync.mode -// Valid values: git-portable, realtime, dolt-native, belt-and-suspenders -func GetSyncMode() SyncMode { - value := GetString("sync.mode") - if value == "" { - return SyncModeGitPortable // Default - } - - mode := SyncMode(strings.ToLower(strings.TrimSpace(value))) - if !validSyncModes[mode] { - fmt.Fprintf(os.Stderr, "Warning: invalid sync.mode %q in config (valid: git-portable, realtime, dolt-native, belt-and-suspenders), using default 'git-portable'\n", value) - return SyncModeGitPortable - } - - return mode -} - -// GetConflictStrategy retrieves the conflict resolution strategy configuration. -// Returns the configured strategy, or ConflictStrategyNewest (default) if not set or invalid. -// Logs a warning to stderr if an invalid value is configured. -// -// Config key: conflict.strategy -// Valid values: newest, ours, theirs, manual -func GetConflictStrategy() ConflictStrategy { - value := GetString("conflict.strategy") - if value == "" { - return ConflictStrategyNewest // Default - } - - strategy := ConflictStrategy(strings.ToLower(strings.TrimSpace(value))) - if !validConflictStrategies[strategy] { - fmt.Fprintf(os.Stderr, "Warning: invalid conflict.strategy %q in config (valid: newest, ours, theirs, manual), using default 'newest'\n", value) - return ConflictStrategyNewest - } - - return strategy -} - -// GetSovereignty retrieves the federation sovereignty tier configuration. -// Returns the configured tier, or SovereigntyT1 (default) if not set or invalid. -// Logs a warning to stderr if an invalid value is configured. -// -// Config key: federation.sovereignty -// Valid values: T1, T2, T3, T4 -func GetSovereignty() Sovereignty { - value := GetString("federation.sovereignty") - if value == "" { - return SovereigntyT1 // Default - } - - // Normalize to uppercase for comparison (T1, T2, etc.) - tier := Sovereignty(strings.ToUpper(strings.TrimSpace(value))) - if !validSovereigntyTiers[tier] { - fmt.Fprintf(os.Stderr, "Warning: invalid federation.sovereignty %q in config (valid: T1, T2, T3, T4), using default 'T1'\n", value) - return SovereigntyT1 - } - - return tier -} diff --git a/internal/config/sync_test.go b/internal/config/sync_test.go deleted file mode 100644 index 1240dc38..00000000 --- a/internal/config/sync_test.go +++ /dev/null @@ -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) - } - }) - } -} diff --git a/internal/storage/versioned.go b/internal/storage/versioned.go index 312ac0f6..aa14e67e 100644 --- a/internal/storage/versioned.go +++ b/internal/storage/versioned.go @@ -117,3 +117,33 @@ func AsVersioned(s Storage) (VersionedStorage, bool) { vs, ok := s.(VersionedStorage) return vs, ok } + +// RemoteStorage extends VersionedStorage with remote synchronization capabilities. +// This interface is implemented by storage backends that support push/pull to +// remote repositories (e.g., Dolt with DoltHub remotes). +type RemoteStorage interface { + VersionedStorage + + // Push pushes commits to the configured remote. + Push(ctx context.Context) error + + // Pull pulls changes from the configured remote. + Pull(ctx context.Context) error + + // AddRemote adds a new remote with the given name and URL. + AddRemote(ctx context.Context, name, url string) error +} + +// IsRemote checks if a storage instance supports remote synchronization. +// Returns true if the storage implements RemoteStorage. +func IsRemote(s Storage) bool { + _, ok := s.(RemoteStorage) + return ok +} + +// AsRemote attempts to cast a Storage to RemoteStorage. +// Returns the RemoteStorage and true if successful, nil and false otherwise. +func AsRemote(s Storage) (RemoteStorage, bool) { + rs, ok := s.(RemoteStorage) + return rs, ok +}