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

Add configurable sync modes for Dolt storage integration:

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

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

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

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

View File

@@ -115,11 +115,11 @@ The --full flag provides the legacy full sync behavior for backwards compatibili
// If resolve mode, resolve conflicts
if resolve {
strategy := "newest" // default
strategy := config.GetConflictStrategy() // use configured default
if resolveOurs {
strategy = "ours"
strategy = config.ConflictStrategyOurs
} else if resolveTheirs {
strategy = "theirs"
strategy = config.ConflictStrategyTheirs
}
if err := resolveSyncConflicts(ctx, jsonlPath, strategy, dryRun); err != nil {
FatalError("%v", err)
@@ -681,8 +681,23 @@ func showSyncStateStatus(ctx context.Context, jsonlPath string) error {
beadsDir := filepath.Dir(jsonlPath)
// Sync mode
fmt.Println("Sync mode: git-portable")
// Sync mode (from config)
syncCfg := config.GetSyncConfig()
fmt.Printf("Sync mode: %s\n", syncCfg.Mode)
fmt.Printf(" Export on: %s, Import on: %s\n", syncCfg.ExportOn, syncCfg.ImportOn)
// Conflict strategy
conflictCfg := config.GetConflictConfig()
fmt.Printf("Conflict strategy: %s\n", conflictCfg.Strategy)
// Federation config (if set)
fedCfg := config.GetFederationConfig()
if fedCfg.Remote != "" {
fmt.Printf("Federation remote: %s\n", fedCfg.Remote)
if fedCfg.Sovereignty != "" {
fmt.Printf(" Sovereignty: %s\n", fedCfg.Sovereignty)
}
}
// Last export time
lastExport, err := store.GetMetadata(ctx, "last_import_time")
@@ -876,11 +891,15 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string
var winner string
switch strategy {
case "ours":
case config.ConflictStrategyOurs:
winner = "local"
case "theirs":
case config.ConflictStrategyTheirs:
winner = "remote"
case "newest":
case config.ConflictStrategyManual:
// Manual mode should not reach here - conflicts are handled interactively
fmt.Printf("⚠ %s: requires manual resolution\n", conflict.IssueID)
continue
case config.ConflictStrategyNewest:
fallthrough
default:
// Compare updated_at timestamps
@@ -898,7 +917,7 @@ func resolveSyncConflicts(ctx context.Context, jsonlPath string, strategy string
}
fmt.Printf("✓ %s: kept %s", conflict.IssueID, winner)
if strategy == "newest" {
if strategy == config.ConflictStrategyNewest {
fmt.Print(" (newer)")
}
fmt.Println()

View File

@@ -35,6 +35,12 @@ Tool-level settings you can configure:
| `no-auto-flush` | `--no-auto-flush` | `BD_NO_AUTO_FLUSH` | `false` | Disable auto JSONL export |
| `no-auto-import` | `--no-auto-import` | `BD_NO_AUTO_IMPORT` | `false` | Disable auto JSONL import |
| `no-push` | `--no-push` | `BD_NO_PUSH` | `false` | Skip pushing to remote in bd sync |
| `sync.mode` | - | `BD_SYNC_MODE` | `git-portable` | Sync mode (see below) |
| `sync.export_on` | - | `BD_SYNC_EXPORT_ON` | `push` | When to export: `push`, `change` |
| `sync.import_on` | - | `BD_SYNC_IMPORT_ON` | `pull` | When to import: `pull`, `change` |
| `conflict.strategy` | - | `BD_CONFLICT_STRATEGY` | `newest` | Conflict resolution: `newest`, `ours`, `theirs`, `manual` |
| `federation.remote` | - | `BD_FEDERATION_REMOTE` | (none) | Dolt remote URL for federation |
| `federation.sovereignty` | - | `BD_FEDERATION_SOVEREIGNTY` | (none) | Data sovereignty tier: `T1`, `T2`, `T3`, `T4` |
| `create.require-description` | - | `BD_CREATE_REQUIRE_DESCRIPTION` | `false` | Require description when creating issues |
| `validation.on-create` | - | `BD_VALIDATION_ON_CREATE` | `none` | Template validation on create: `none`, `warn`, `error` |
| `validation.on-sync` | - | `BD_VALIDATION_ON_SYNC` | `none` | Template validation before sync: `none`, `warn`, `error` |
@@ -69,6 +75,73 @@ To override, set `BD_ACTOR` in your shell profile:
export BD_ACTOR="my-github-handle"
```
### Sync Mode Configuration
The sync mode controls how beads synchronizes data with git and/or Dolt remotes.
#### Sync Modes
| Mode | Description |
|------|-------------|
| `git-portable` | (default) Export JSONL on push, import on pull. Standard git-based workflow. |
| `realtime` | Export JSONL on every database change. Legacy behavior, higher I/O. |
| `dolt-native` | Use Dolt remotes directly. No JSONL needed - Dolt handles sync. |
| `belt-and-suspenders` | Both Dolt remote AND JSONL backup. Maximum redundancy. |
#### Sync Triggers
Control when sync operations occur:
- `sync.export_on`: `push` (default) or `change`
- `sync.import_on`: `pull` (default) or `change`
#### Conflict Resolution Strategies
When merging conflicting changes:
| Strategy | Description |
|----------|-------------|
| `newest` | (default) Keep the version with the newer `updated_at` timestamp |
| `ours` | Always keep the local version |
| `theirs` | Always keep the remote version |
| `manual` | Require interactive resolution for each conflict |
#### Federation Configuration
For Dolt-native or belt-and-suspenders modes:
- `federation.remote`: Dolt remote URL (e.g., `dolthub://org/beads`, `gs://bucket/beads`, `s3://bucket/beads`)
- `federation.sovereignty`: Data sovereignty tier:
- `T1`: Full sovereignty - data never leaves controlled infrastructure
- `T2`: Regional sovereignty - data stays within region/jurisdiction
- `T3`: Provider sovereignty - data with trusted cloud provider
- `T4`: No restrictions - data can be anywhere
#### Example Sync Configuration
```yaml
# .beads/config.yaml
sync:
mode: git-portable # git-portable | realtime | dolt-native | belt-and-suspenders
export_on: push # push | change
import_on: pull # pull | change
conflict:
strategy: newest # newest | ours | theirs | manual
# Optional: Dolt federation for dolt-native or belt-and-suspenders modes
federation:
remote: dolthub://myorg/beads
sovereignty: T2
```
#### When to Use Each Mode
- **git-portable** (default): Best for most teams. JSONL is committed to git, works with any git hosting.
- **realtime**: Use when you need instant JSONL updates (e.g., file watchers, CI triggers on JSONL changes).
- **dolt-native**: Use when you have Dolt infrastructure and want database-level sync without JSONL.
- **belt-and-suspenders**: Use for critical data where you want both Dolt sync AND git-portable backup.
### Example Config File
`~/.config/bd/config.yaml`:

View File

@@ -12,6 +12,67 @@ import (
"github.com/steveyegge/beads/internal/debug"
)
// Sync mode constants define how beads syncs with git/remotes.
const (
// SyncModeGitPortable exports JSONL on push, imports on pull (default).
// This is the standard git-based workflow where JSONL is committed.
SyncModeGitPortable = "git-portable"
// SyncModeRealtime exports JSONL on every database change.
// Legacy behavior, more frequent writes but higher I/O.
SyncModeRealtime = "realtime"
// SyncModeDoltNative uses Dolt remotes directly (dolthub://, gs://, s3://).
// No JSONL export needed - Dolt handles sync.
SyncModeDoltNative = "dolt-native"
// SyncModeBeltAndSuspenders uses both Dolt remote AND JSONL backup.
// Maximum redundancy for critical data.
SyncModeBeltAndSuspenders = "belt-and-suspenders"
)
// Sync trigger constants define when sync operations occur.
const (
// SyncTriggerPush triggers sync on git push operations.
SyncTriggerPush = "push"
// SyncTriggerChange triggers sync on every database change.
SyncTriggerChange = "change"
// SyncTriggerPull triggers import on git pull operations.
SyncTriggerPull = "pull"
)
// Conflict strategy constants define how sync conflicts are resolved.
const (
// ConflictStrategyNewest keeps whichever version has the newer updated_at timestamp.
ConflictStrategyNewest = "newest"
// ConflictStrategyOurs keeps the local version on conflict.
ConflictStrategyOurs = "ours"
// ConflictStrategyTheirs keeps the remote version on conflict.
ConflictStrategyTheirs = "theirs"
// ConflictStrategyManual requires manual resolution of conflicts.
ConflictStrategyManual = "manual"
)
// Federation sovereignty tiers define data sovereignty levels.
const (
// SovereigntyT1 - Full sovereignty: data never leaves controlled infrastructure.
SovereigntyT1 = "T1"
// SovereigntyT2 - Regional sovereignty: data stays within region/jurisdiction.
SovereigntyT2 = "T2"
// SovereigntyT3 - Provider sovereignty: data with trusted cloud provider.
SovereigntyT3 = "T3"
// SovereigntyT4 - No restrictions: data can be anywhere (e.g., DoltHub public).
SovereigntyT4 = "T4"
)
var v *viper.Viper
// Initialize sets up the viper configuration singleton
@@ -108,6 +169,19 @@ func Initialize() error {
// Sync configuration defaults (bd-4u8)
v.SetDefault("sync.require_confirmation_on_mass_delete", false)
// Sync mode configuration (hq-ew1mbr.3)
// See docs/CONFIG.md for detailed documentation
v.SetDefault("sync.mode", SyncModeGitPortable) // git-portable | realtime | dolt-native | belt-and-suspenders
v.SetDefault("sync.export_on", SyncTriggerPush) // push | change
v.SetDefault("sync.import_on", SyncTriggerPull) // pull | change
// Conflict resolution configuration
v.SetDefault("conflict.strategy", ConflictStrategyNewest) // newest | ours | theirs | manual
// Federation configuration (optional Dolt remote)
v.SetDefault("federation.remote", "") // e.g., dolthub://org/beads, gs://bucket/beads, s3://bucket/beads
v.SetDefault("federation.sovereignty", "") // T1 | T2 | T3 | T4 (empty = no restriction)
// Push configuration defaults
v.SetDefault("no-push", false)
@@ -530,3 +604,142 @@ func GetIdentity(flagValue string) string {
return "unknown"
}
// SyncConfig holds the sync mode configuration.
type SyncConfig struct {
Mode string // git-portable, realtime, dolt-native, belt-and-suspenders
ExportOn string // push, change
ImportOn string // pull, change
}
// GetSyncConfig returns the current sync configuration.
func GetSyncConfig() SyncConfig {
return SyncConfig{
Mode: GetSyncMode(),
ExportOn: GetString("sync.export_on"),
ImportOn: GetString("sync.import_on"),
}
}
// GetSyncMode returns the configured sync mode.
// Returns git-portable if not configured or invalid.
func GetSyncMode() string {
mode := GetString("sync.mode")
if mode == "" {
return SyncModeGitPortable
}
// Validate mode
switch mode {
case SyncModeGitPortable, SyncModeRealtime, SyncModeDoltNative, SyncModeBeltAndSuspenders:
return mode
default:
return SyncModeGitPortable
}
}
// IsSyncModeValid checks if the given sync mode is valid.
func IsSyncModeValid(mode string) bool {
switch mode {
case SyncModeGitPortable, SyncModeRealtime, SyncModeDoltNative, SyncModeBeltAndSuspenders:
return true
default:
return false
}
}
// ConflictConfig holds the conflict resolution configuration.
type ConflictConfig struct {
Strategy string // newest, ours, theirs, manual
}
// GetConflictConfig returns the current conflict resolution configuration.
func GetConflictConfig() ConflictConfig {
return ConflictConfig{
Strategy: GetConflictStrategy(),
}
}
// GetConflictStrategy returns the configured conflict resolution strategy.
// Returns newest if not configured or invalid.
func GetConflictStrategy() string {
strategy := GetString("conflict.strategy")
if strategy == "" {
return ConflictStrategyNewest
}
// Validate strategy
switch strategy {
case ConflictStrategyNewest, ConflictStrategyOurs, ConflictStrategyTheirs, ConflictStrategyManual:
return strategy
default:
return ConflictStrategyNewest
}
}
// IsConflictStrategyValid checks if the given conflict strategy is valid.
func IsConflictStrategyValid(strategy string) bool {
switch strategy {
case ConflictStrategyNewest, ConflictStrategyOurs, ConflictStrategyTheirs, ConflictStrategyManual:
return true
default:
return false
}
}
// FederationConfig holds the federation (Dolt remote) configuration.
type FederationConfig struct {
Remote string // dolthub://org/beads, gs://bucket/beads, s3://bucket/beads
Sovereignty string // T1, T2, T3, T4
}
// GetFederationConfig returns the current federation configuration.
func GetFederationConfig() FederationConfig {
return FederationConfig{
Remote: GetString("federation.remote"),
Sovereignty: GetSovereignty(),
}
}
// GetSovereignty returns the configured data sovereignty tier.
// Returns empty string if not configured.
func GetSovereignty() string {
sovereignty := GetString("federation.sovereignty")
// Validate sovereignty tier
switch sovereignty {
case SovereigntyT1, SovereigntyT2, SovereigntyT3, SovereigntyT4:
return sovereignty
default:
return ""
}
}
// IsSovereigntyValid checks if the given sovereignty tier is valid.
func IsSovereigntyValid(sovereignty string) bool {
switch sovereignty {
case "", SovereigntyT1, SovereigntyT2, SovereigntyT3, SovereigntyT4:
return true
default:
return false
}
}
// ShouldExportOnChange returns true if sync.export_on is set to "change".
func ShouldExportOnChange() bool {
return GetString("sync.export_on") == SyncTriggerChange
}
// ShouldImportOnChange returns true if sync.import_on is set to "change".
func ShouldImportOnChange() bool {
return GetString("sync.import_on") == SyncTriggerChange
}
// NeedsDoltRemote returns true if the sync mode requires a Dolt remote.
func NeedsDoltRemote() bool {
mode := GetSyncMode()
return mode == SyncModeDoltNative || mode == SyncModeBeltAndSuspenders
}
// NeedsJSONL returns true if the sync mode requires JSONL export.
func NeedsJSONL() bool {
mode := GetSyncMode()
return mode == SyncModeGitPortable || mode == SyncModeRealtime || mode == SyncModeBeltAndSuspenders
}

View File

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