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:
committed by
Steve Yegge
parent
c99bd00ca7
commit
356ab92b78
195
cmd/bd/sync.go
195
cmd/bd/sync.go
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/steveyegge/beads/internal/beads"
|
"github.com/steveyegge/beads/internal/beads"
|
||||||
"github.com/steveyegge/beads/internal/config"
|
"github.com/steveyegge/beads/internal/config"
|
||||||
"github.com/steveyegge/beads/internal/debug"
|
"github.com/steveyegge/beads/internal/debug"
|
||||||
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
"github.com/steveyegge/beads/internal/syncbranch"
|
"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)
|
// Resolve noGitHistory based on fromMain (fixes #417)
|
||||||
noGitHistory = resolveNoGitHistoryForFromMain(fromMain, noGitHistory)
|
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
|
// Find JSONL path
|
||||||
jsonlPath := findJSONLPath()
|
jsonlPath := findJSONLPath()
|
||||||
if jsonlPath == "" {
|
if jsonlPath == "" {
|
||||||
@@ -398,30 +409,66 @@ func doPullFirstSync(ctx context.Context, jsonlPath string, renameOnImport, noGi
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Pull from remote
|
// Step 3: Pull from remote
|
||||||
// When sync.branch is configured, pull from the sync branch via worktree
|
// Mode-specific pull behavior:
|
||||||
// Otherwise, use normal git pull on the current branch
|
// - dolt-native/belt-and-suspenders with Dolt remote: Pull from Dolt
|
||||||
if hasSyncBranchConfig {
|
// - sync.branch configured: Pull from sync branch via worktree
|
||||||
fmt.Printf("→ Pulling from sync branch '%s'...\n", syncBranch)
|
// - Default (git-portable): Normal git pull
|
||||||
pullResult, err := syncbranch.PullFromSyncBranch(ctx, syncBranchRepoRoot, syncBranch, jsonlPath, false)
|
syncMode := GetSyncMode(ctx, store)
|
||||||
if err != nil {
|
shouldUseDolt := ShouldUseDoltRemote(ctx, store)
|
||||||
return fmt.Errorf("pulling from sync branch: %w", err)
|
|
||||||
|
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 belt-and-suspenders, continue with git pull even if Dolt pull failed
|
||||||
for _, warning := range pullResult.SafetyWarnings {
|
}
|
||||||
fmt.Fprintln(os.Stderr, warning)
|
|
||||||
}
|
// Git-based pull (for git-portable, belt-and-suspenders, or when Dolt not available)
|
||||||
if pullResult.Merged {
|
if ShouldExportJSONL(ctx, store) {
|
||||||
fmt.Println(" Merged divergent sync branch histories")
|
if hasSyncBranchConfig {
|
||||||
} else if pullResult.FastForwarded {
|
fmt.Printf("→ Pulling from sync branch '%s'...\n", syncBranch)
|
||||||
fmt.Println(" Fast-forwarded to remote")
|
pullResult, err := syncbranch.PullFromSyncBranch(ctx, syncBranchRepoRoot, syncBranch, jsonlPath, false)
|
||||||
}
|
if err != nil {
|
||||||
} else {
|
return fmt.Errorf("pulling from sync branch: %w", err)
|
||||||
fmt.Println("→ Pulling from remote...")
|
}
|
||||||
if err := gitPull(ctx, ""); err != nil {
|
// Display any safety warnings from the pull
|
||||||
return fmt.Errorf("pulling: %w", err)
|
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)
|
// Step 4: Load remote state from JSONL (after pull)
|
||||||
remoteIssues, err := loadIssuesFromJSONL(jsonlPath)
|
remoteIssues, err := loadIssuesFromJSONL(jsonlPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -631,53 +678,98 @@ func writeMergedStateToJSONL(path string, issues []*beads.Issue) error {
|
|||||||
return os.Rename(tempPath, path)
|
return os.Rename(tempPath, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
// doExportSync exports the current database state to JSONL.
|
// doExportSync exports the current database state based on sync mode.
|
||||||
// This is the new default behavior for bd sync (per spec).
|
// - git-portable, realtime: Export to JSONL
|
||||||
// Does NOT stage or commit - that's the user's job.
|
// - 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 {
|
func doExportSync(ctx context.Context, jsonlPath string, force, dryRun bool) error {
|
||||||
if err := ensureStoreActive(); err != nil {
|
if err := ensureStoreActive(); err != nil {
|
||||||
return fmt.Errorf("failed to initialize store: %w", err)
|
return fmt.Errorf("failed to initialize store: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncMode := GetSyncMode(ctx, store)
|
||||||
|
shouldExportJSONL := ShouldExportJSONL(ctx, store)
|
||||||
|
shouldUseDolt := ShouldUseDoltRemote(ctx, store)
|
||||||
|
|
||||||
if dryRun {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Exporting beads to JSONL...")
|
// Handle Dolt remote operations for dolt-native and belt-and-suspenders modes
|
||||||
|
if shouldUseDolt {
|
||||||
// Get count of dirty (changed) issues for incremental tracking
|
rs, ok := storage.AsRemote(store)
|
||||||
var changedCount int
|
if !ok {
|
||||||
if !force {
|
if syncMode == SyncModeDoltNative {
|
||||||
dirtyIDs, err := store.GetDirtyIssues(ctx)
|
return fmt.Errorf("dolt-native sync mode requires Dolt backend (current backend doesn't support remote operations)")
|
||||||
if err != nil {
|
}
|
||||||
debug.Logf("warning: failed to get dirty issues: %v", err)
|
// belt-and-suspenders: warn but continue with JSONL
|
||||||
|
fmt.Println("⚠ Dolt remote not available, falling back to JSONL-only")
|
||||||
} else {
|
} 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
|
// Export to JSONL for git-portable, realtime, and belt-and-suspenders modes
|
||||||
result, err := exportToJSONLDeferred(ctx, jsonlPath)
|
if shouldExportJSONL {
|
||||||
if err != nil {
|
fmt.Println("Exporting beads to JSONL...")
|
||||||
return fmt.Errorf("exporting: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finalize export (update metadata)
|
// Get count of dirty (changed) issues for incremental tracking
|
||||||
finalizeExport(ctx, result)
|
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
|
// Export to JSONL
|
||||||
totalCount := 0
|
result, err := exportToJSONLDeferred(ctx, jsonlPath)
|
||||||
if result != nil {
|
if err != nil {
|
||||||
totalCount = len(result.ExportedIDs)
|
return fmt.Errorf("exporting: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if changedCount > 0 && !force {
|
// Finalize export (update metadata)
|
||||||
fmt.Printf("✓ Exported %d issues (%d changed since last sync)\n", totalCount, changedCount)
|
finalizeExport(ctx, result)
|
||||||
} else {
|
|
||||||
fmt.Printf("✓ Exported %d issues\n", totalCount)
|
// 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
|
return nil
|
||||||
}
|
}
|
||||||
@@ -699,7 +791,7 @@ func showSyncStateStatus(ctx context.Context, jsonlPath string) error {
|
|||||||
|
|
||||||
// Sync mode (from config)
|
// Sync mode (from config)
|
||||||
syncCfg := config.GetSyncConfig()
|
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)
|
fmt.Printf(" Export on: %s, Import on: %s\n", syncCfg.ExportOn, syncCfg.ImportOn)
|
||||||
|
|
||||||
// Conflict strategy
|
// Conflict strategy
|
||||||
@@ -1148,6 +1240,7 @@ func init() {
|
|||||||
syncCmd.Flags().Bool("theirs", false, "Use 'theirs' strategy for conflict resolution (with --resolve)")
|
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("manual", false, "Use interactive manual resolution for conflicts (with --resolve)")
|
||||||
syncCmd.Flags().Bool("force", false, "Force full export/import (skip incremental optimization)")
|
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)
|
rootCmd.AddCommand(syncCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
129
cmd/bd/sync_mode.go
Normal file
129
cmd/bd/sync_mode.go
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
191
cmd/bd/sync_mode_test.go
Normal file
191
cmd/bd/sync_mode_test.go
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -117,3 +117,33 @@ func AsVersioned(s Storage) (VersionedStorage, bool) {
|
|||||||
vs, ok := s.(VersionedStorage)
|
vs, ok := s.(VersionedStorage)
|
||||||
return vs, ok
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user