When the Dolt database connection is temporarily unavailable (e.g., stale connection, server restart), GetCustomTypes() and GetCustomStatuses() now fall back to reading from config.yaml instead of failing. This matches the SQLite storage behavior and fixes the stop hook failure where `gt costs record` would fail with "invalid issue type: event" when the Dolt connection was broken. The fallback is checked in two cases: 1. When database query returns an error 2. When database has no custom types/statuses configured Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
751 lines
22 KiB
Go
751 lines
22 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/viper"
|
|
"github.com/steveyegge/beads/internal/debug"
|
|
)
|
|
|
|
// 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"
|
|
)
|
|
|
|
var v *viper.Viper
|
|
|
|
// Initialize sets up the viper configuration singleton
|
|
// Should be called once at application startup
|
|
func Initialize() error {
|
|
v = viper.New()
|
|
|
|
// Set config type to yaml (we only load config.yaml, not config.json)
|
|
v.SetConfigType("yaml")
|
|
|
|
// Explicitly locate config.yaml and use SetConfigFile to avoid picking up config.json
|
|
// Precedence: project .beads/config.yaml > ~/.config/bd/config.yaml > ~/.beads/config.yaml
|
|
configFileSet := false
|
|
|
|
// 1. Walk up from CWD to find project .beads/config.yaml
|
|
// This allows commands to work from subdirectories
|
|
cwd, err := os.Getwd()
|
|
if err == nil && !configFileSet {
|
|
// Walk up parent directories to find .beads/config.yaml
|
|
for dir := cwd; dir != filepath.Dir(dir); dir = filepath.Dir(dir) {
|
|
beadsDir := filepath.Join(dir, ".beads")
|
|
configPath := filepath.Join(beadsDir, "config.yaml")
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
// Found .beads/config.yaml - set it explicitly
|
|
v.SetConfigFile(configPath)
|
|
configFileSet = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. User config directory (~/.config/bd/config.yaml)
|
|
if !configFileSet {
|
|
if configDir, err := os.UserConfigDir(); err == nil {
|
|
configPath := filepath.Join(configDir, "bd", "config.yaml")
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
v.SetConfigFile(configPath)
|
|
configFileSet = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Home directory (~/.beads/config.yaml)
|
|
if !configFileSet {
|
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
|
configPath := filepath.Join(homeDir, ".beads", "config.yaml")
|
|
if _, err := os.Stat(configPath); err == nil {
|
|
v.SetConfigFile(configPath)
|
|
configFileSet = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Automatic environment variable binding
|
|
// Environment variables take precedence over config file
|
|
// E.g., BD_JSON, BD_NO_DAEMON, BD_ACTOR, BD_DB
|
|
v.SetEnvPrefix("BD")
|
|
|
|
// Replace hyphens and dots with underscores for env var mapping
|
|
// This allows BD_NO_DAEMON to map to "no-daemon" config key
|
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_"))
|
|
v.AutomaticEnv()
|
|
|
|
// Set defaults for all flags
|
|
v.SetDefault("json", false)
|
|
v.SetDefault("no-daemon", false)
|
|
v.SetDefault("no-auto-flush", false)
|
|
v.SetDefault("no-auto-import", false)
|
|
v.SetDefault("no-db", false)
|
|
v.SetDefault("db", "")
|
|
v.SetDefault("actor", "")
|
|
v.SetDefault("issue-prefix", "")
|
|
v.SetDefault("lock-timeout", "30s")
|
|
|
|
// Additional environment variables (not prefixed with BD_)
|
|
// These are bound explicitly for backward compatibility
|
|
_ = v.BindEnv("flush-debounce", "BEADS_FLUSH_DEBOUNCE")
|
|
_ = v.BindEnv("auto-start-daemon", "BEADS_AUTO_START_DAEMON")
|
|
_ = v.BindEnv("identity", "BEADS_IDENTITY")
|
|
_ = v.BindEnv("remote-sync-interval", "BEADS_REMOTE_SYNC_INTERVAL")
|
|
|
|
// Set defaults for additional settings
|
|
v.SetDefault("flush-debounce", "30s")
|
|
v.SetDefault("auto-start-daemon", true)
|
|
v.SetDefault("identity", "")
|
|
v.SetDefault("remote-sync-interval", "30s")
|
|
|
|
// Dolt configuration defaults
|
|
// Controls whether beads should automatically create Dolt commits after write commands.
|
|
// Values: off | on
|
|
v.SetDefault("dolt.auto-commit", "on")
|
|
|
|
// Routing configuration defaults
|
|
v.SetDefault("routing.mode", "")
|
|
v.SetDefault("routing.default", ".")
|
|
v.SetDefault("routing.maintainer", ".")
|
|
v.SetDefault("routing.contributor", "~/.beads-planning")
|
|
|
|
// 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)
|
|
|
|
// Create command defaults
|
|
v.SetDefault("create.require-description", false)
|
|
|
|
// Validation configuration defaults (bd-t7jq)
|
|
// Values: "warn" | "error" | "none"
|
|
// - "none": no validation (default, backwards compatible)
|
|
// - "warn": validate and print warnings but proceed
|
|
// - "error": validate and fail on missing sections
|
|
v.SetDefault("validation.on-create", "none")
|
|
v.SetDefault("validation.on-sync", "none")
|
|
|
|
// Hierarchy configuration defaults (GH#995)
|
|
// Maximum nesting depth for hierarchical IDs (e.g., bd-abc.1.2.3)
|
|
// Default matches types.MaxHierarchyDepth constant
|
|
v.SetDefault("hierarchy.max-depth", 3)
|
|
|
|
// Git configuration defaults (GH#600)
|
|
v.SetDefault("git.author", "") // Override commit author (e.g., "beads-bot <beads@example.com>")
|
|
v.SetDefault("git.no-gpg-sign", false) // Disable GPG signing for beads commits
|
|
|
|
// Directory-aware label scoping (GH#541)
|
|
// Maps directory patterns to labels for automatic filtering in monorepos
|
|
v.SetDefault("directory.labels", map[string]string{})
|
|
|
|
// External projects for cross-project dependency resolution (bd-h807)
|
|
// Maps project names to paths for resolving external: blocked_by references
|
|
v.SetDefault("external_projects", map[string]string{})
|
|
|
|
// Read config file if it was found
|
|
if configFileSet {
|
|
if err := v.ReadInConfig(); err != nil {
|
|
return fmt.Errorf("error reading config file: %w", err)
|
|
}
|
|
debug.Logf("Debug: loaded config from %s\n", v.ConfigFileUsed())
|
|
} else {
|
|
// No config.yaml found - use defaults and environment variables
|
|
debug.Logf("Debug: no config.yaml found; using defaults and environment variables\n")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ResetForTesting clears the config state, allowing Initialize() to be called again.
|
|
// This is intended for tests that need to change config.yaml between test steps.
|
|
// WARNING: Not thread-safe. Only call from single-threaded test contexts.
|
|
func ResetForTesting() {
|
|
v = nil
|
|
}
|
|
|
|
// ConfigSource represents where a configuration value came from
|
|
type ConfigSource string
|
|
|
|
const (
|
|
SourceDefault ConfigSource = "default"
|
|
SourceConfigFile ConfigSource = "config_file"
|
|
SourceEnvVar ConfigSource = "env_var"
|
|
SourceFlag ConfigSource = "flag"
|
|
)
|
|
|
|
// ConfigOverride represents a detected configuration override
|
|
type ConfigOverride struct {
|
|
Key string
|
|
EffectiveValue interface{}
|
|
OverriddenBy ConfigSource
|
|
OriginalSource ConfigSource
|
|
OriginalValue interface{}
|
|
}
|
|
|
|
// GetValueSource returns the source of a configuration value.
|
|
// Priority (highest to lowest): env var > config file > default
|
|
// Note: Flag overrides are handled separately in main.go since viper doesn't know about cobra flags.
|
|
func GetValueSource(key string) ConfigSource {
|
|
if v == nil {
|
|
return SourceDefault
|
|
}
|
|
|
|
// Check if value is set from environment variable
|
|
// Viper's IsSet returns true if the key is set from any source (env, config, or default)
|
|
// We need to check specifically for env var by looking at the env var directly
|
|
envKey := "BD_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_"))
|
|
if os.Getenv(envKey) != "" {
|
|
return SourceEnvVar
|
|
}
|
|
|
|
// Check BEADS_ prefixed env vars for legacy compatibility
|
|
beadsEnvKey := "BEADS_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_"))
|
|
if os.Getenv(beadsEnvKey) != "" {
|
|
return SourceEnvVar
|
|
}
|
|
|
|
// Check if value is set in config file (as opposed to being a default)
|
|
if v.InConfig(key) {
|
|
return SourceConfigFile
|
|
}
|
|
|
|
return SourceDefault
|
|
}
|
|
|
|
// CheckOverrides checks for configuration overrides and returns a list of detected overrides.
|
|
// This is useful for informing users when env vars or flags override config file values.
|
|
// flagOverrides is a map of key -> (flagValue, flagWasSet) for flags that were explicitly set.
|
|
func CheckOverrides(flagOverrides map[string]struct {
|
|
Value interface{}
|
|
WasSet bool
|
|
}) []ConfigOverride {
|
|
var overrides []ConfigOverride
|
|
|
|
for key, flagInfo := range flagOverrides {
|
|
if !flagInfo.WasSet {
|
|
continue
|
|
}
|
|
|
|
source := GetValueSource(key)
|
|
if source == SourceConfigFile || source == SourceEnvVar {
|
|
// Flag is overriding a config file or env var value
|
|
var originalValue interface{}
|
|
switch v := flagInfo.Value.(type) {
|
|
case bool:
|
|
originalValue = GetBool(key)
|
|
case string:
|
|
originalValue = GetString(key)
|
|
case int:
|
|
originalValue = GetInt(key)
|
|
default:
|
|
originalValue = v
|
|
}
|
|
|
|
overrides = append(overrides, ConfigOverride{
|
|
Key: key,
|
|
EffectiveValue: flagInfo.Value,
|
|
OverriddenBy: SourceFlag,
|
|
OriginalSource: source,
|
|
OriginalValue: originalValue,
|
|
})
|
|
}
|
|
}
|
|
|
|
// Check for env var overriding config file
|
|
if v != nil {
|
|
for _, key := range v.AllKeys() {
|
|
envSource := GetValueSource(key)
|
|
if envSource == SourceEnvVar && v.InConfig(key) {
|
|
// Env var is overriding config file value
|
|
// Get the config file value by temporarily unsetting the env
|
|
envKey := "BD_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_"))
|
|
envValue := os.Getenv(envKey)
|
|
if envValue == "" {
|
|
envKey = "BEADS_" + strings.ToUpper(strings.ReplaceAll(strings.ReplaceAll(key, "-", "_"), ".", "_"))
|
|
envValue = os.Getenv(envKey)
|
|
}
|
|
|
|
// Skip if no env var actually set (shouldn't happen but be safe)
|
|
if envValue == "" {
|
|
continue
|
|
}
|
|
|
|
overrides = append(overrides, ConfigOverride{
|
|
Key: key,
|
|
EffectiveValue: v.Get(key),
|
|
OverriddenBy: SourceEnvVar,
|
|
OriginalSource: SourceConfigFile,
|
|
OriginalValue: nil, // We can't easily get the config file value separately
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return overrides
|
|
}
|
|
|
|
// LogOverride logs a message about a configuration override in verbose mode.
|
|
func LogOverride(override ConfigOverride) {
|
|
var sourceDesc string
|
|
switch override.OriginalSource {
|
|
case SourceConfigFile:
|
|
sourceDesc = "config file"
|
|
case SourceEnvVar:
|
|
sourceDesc = "environment variable"
|
|
case SourceDefault:
|
|
sourceDesc = "default"
|
|
default:
|
|
sourceDesc = string(override.OriginalSource)
|
|
}
|
|
|
|
var overrideDesc string
|
|
switch override.OverriddenBy {
|
|
case SourceFlag:
|
|
overrideDesc = "command-line flag"
|
|
case SourceEnvVar:
|
|
overrideDesc = "environment variable"
|
|
default:
|
|
overrideDesc = string(override.OverriddenBy)
|
|
}
|
|
|
|
// Always emit to stderr when verbose mode is enabled (caller guards on verbose)
|
|
fmt.Fprintf(os.Stderr, "Config: %s overridden by %s (was: %v from %s, now: %v)\n",
|
|
override.Key, overrideDesc, override.OriginalValue, sourceDesc, override.EffectiveValue)
|
|
}
|
|
|
|
// GetString retrieves a string configuration value
|
|
func GetString(key string) string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return v.GetString(key)
|
|
}
|
|
|
|
// GetBool retrieves a boolean configuration value
|
|
func GetBool(key string) bool {
|
|
if v == nil {
|
|
return false
|
|
}
|
|
return v.GetBool(key)
|
|
}
|
|
|
|
// GetInt retrieves an integer configuration value
|
|
func GetInt(key string) int {
|
|
if v == nil {
|
|
return 0
|
|
}
|
|
return v.GetInt(key)
|
|
}
|
|
|
|
// GetDuration retrieves a duration configuration value
|
|
func GetDuration(key string) time.Duration {
|
|
if v == nil {
|
|
return 0
|
|
}
|
|
return v.GetDuration(key)
|
|
}
|
|
|
|
// Set sets a configuration value
|
|
func Set(key string, value interface{}) {
|
|
if v != nil {
|
|
v.Set(key, value)
|
|
}
|
|
}
|
|
|
|
// BindPFlag is reserved for future use if we want to bind Cobra flags directly to Viper
|
|
// For now, we handle flag precedence manually in PersistentPreRun
|
|
// Uncomment and implement if needed:
|
|
//
|
|
// func BindPFlag(key string, flag *pflag.Flag) error {
|
|
// if v == nil {
|
|
// return fmt.Errorf("viper not initialized")
|
|
// }
|
|
// return v.BindPFlag(key, flag)
|
|
// }
|
|
|
|
// AllSettings returns all configuration settings as a map
|
|
func AllSettings() map[string]interface{} {
|
|
if v == nil {
|
|
return map[string]interface{}{}
|
|
}
|
|
return v.AllSettings()
|
|
}
|
|
|
|
// ConfigFileUsed returns the path to the config file that was loaded.
|
|
// Returns empty string if no config file was found or viper is not initialized.
|
|
// This is useful for resolving relative paths from the config file's directory.
|
|
func ConfigFileUsed() string {
|
|
if v == nil {
|
|
return ""
|
|
}
|
|
return v.ConfigFileUsed()
|
|
}
|
|
|
|
// GetStringSlice retrieves a string slice configuration value
|
|
func GetStringSlice(key string) []string {
|
|
if v == nil {
|
|
return []string{}
|
|
}
|
|
return v.GetStringSlice(key)
|
|
}
|
|
|
|
// GetStringMapString retrieves a map[string]string configuration value
|
|
func GetStringMapString(key string) map[string]string {
|
|
if v == nil {
|
|
return map[string]string{}
|
|
}
|
|
return v.GetStringMapString(key)
|
|
}
|
|
|
|
// GetDirectoryLabels returns labels for the current working directory based on config.
|
|
// It checks directory.labels config for matching patterns.
|
|
// Returns nil if no labels are configured for the current directory.
|
|
func GetDirectoryLabels() []string {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
dirLabels := GetStringMapString("directory.labels")
|
|
if len(dirLabels) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Check each configured directory pattern
|
|
for pattern, label := range dirLabels {
|
|
// Support both exact match and suffix match
|
|
// e.g., "packages/maverick" matches "/path/to/repo/packages/maverick"
|
|
if strings.HasSuffix(cwd, pattern) || strings.HasSuffix(cwd, filepath.Clean(pattern)) {
|
|
return []string{label}
|
|
}
|
|
// Also try as a path prefix (user might be in a subdirectory)
|
|
if strings.Contains(cwd, "/"+pattern+"/") || strings.Contains(cwd, "/"+pattern) {
|
|
return []string{label}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// MultiRepoConfig contains configuration for multi-repo support
|
|
type MultiRepoConfig struct {
|
|
Primary string // Primary repo path (where canonical issues live)
|
|
Additional []string // Additional repos to hydrate from
|
|
}
|
|
|
|
// GetMultiRepoConfig retrieves multi-repo configuration
|
|
// Returns nil if multi-repo is not configured (single-repo mode)
|
|
func GetMultiRepoConfig() *MultiRepoConfig {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
|
|
// Check if repos.primary is set (indicates multi-repo mode)
|
|
primary := v.GetString("repos.primary")
|
|
if primary == "" {
|
|
return nil // Single-repo mode
|
|
}
|
|
|
|
return &MultiRepoConfig{
|
|
Primary: primary,
|
|
Additional: v.GetStringSlice("repos.additional"),
|
|
}
|
|
}
|
|
|
|
// GetExternalProjects returns the external_projects configuration.
|
|
// Maps project names to paths for cross-project dependency resolution.
|
|
// Example config.yaml:
|
|
//
|
|
// external_projects:
|
|
// beads: ../beads
|
|
// gastown: /absolute/path/to/gastown
|
|
func GetExternalProjects() map[string]string {
|
|
return GetStringMapString("external_projects")
|
|
}
|
|
|
|
// ResolveExternalProjectPath resolves a project name to its absolute path.
|
|
// Returns empty string if project not configured or path doesn't exist.
|
|
func ResolveExternalProjectPath(projectName string) string {
|
|
projects := GetExternalProjects()
|
|
path, ok := projects[projectName]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
|
|
// Resolve relative paths from repo root (parent of .beads/), NOT CWD.
|
|
// This ensures paths like "../beads" in config resolve correctly
|
|
// when running from different directories or in daemon context.
|
|
if !filepath.IsAbs(path) {
|
|
// Config is at .beads/config.yaml, so go up twice to get repo root
|
|
configFile := ConfigFileUsed()
|
|
if configFile != "" {
|
|
repoRoot := filepath.Dir(filepath.Dir(configFile)) // .beads/config.yaml -> repo/
|
|
path = filepath.Join(repoRoot, path)
|
|
} else {
|
|
// Fallback: resolve from CWD (legacy behavior)
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
path = filepath.Join(cwd, path)
|
|
}
|
|
}
|
|
|
|
// Verify path exists
|
|
if _, err := os.Stat(path); err != nil {
|
|
return ""
|
|
}
|
|
|
|
return path
|
|
}
|
|
|
|
// GetIdentity resolves the user's identity for messaging.
|
|
// Priority chain:
|
|
// 1. flagValue (if non-empty, from --identity flag)
|
|
// 2. BEADS_IDENTITY env var / config.yaml identity field (via viper)
|
|
// 3. git config user.name
|
|
// 4. hostname
|
|
//
|
|
// This is used as the sender field in bd mail commands.
|
|
func GetIdentity(flagValue string) string {
|
|
// 1. Command-line flag takes precedence
|
|
if flagValue != "" {
|
|
return flagValue
|
|
}
|
|
|
|
// 2. BEADS_IDENTITY env var or config.yaml identity (viper handles both)
|
|
if identity := GetString("identity"); identity != "" {
|
|
return identity
|
|
}
|
|
|
|
// 3. git config user.name
|
|
cmd := exec.Command("git", "config", "user.name")
|
|
if output, err := cmd.Output(); err == nil {
|
|
if gitUser := strings.TrimSpace(string(output)); gitUser != "" {
|
|
return gitUser
|
|
}
|
|
}
|
|
|
|
// 4. hostname
|
|
if hostname, err := os.Hostname(); err == nil && hostname != "" {
|
|
return hostname
|
|
}
|
|
|
|
return "unknown"
|
|
}
|
|
|
|
// SyncConfig holds the sync mode configuration.
|
|
type SyncConfig struct {
|
|
Mode SyncMode // 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"),
|
|
}
|
|
}
|
|
|
|
// ConflictConfig holds the conflict resolution configuration.
|
|
type ConflictConfig struct {
|
|
Strategy ConflictStrategy // newest, ours, theirs, manual (default for all fields)
|
|
Fields map[string]FieldStrategy // Per-field strategy overrides
|
|
}
|
|
|
|
// GetConflictConfig returns the current conflict resolution configuration.
|
|
func GetConflictConfig() ConflictConfig {
|
|
return ConflictConfig{
|
|
Strategy: GetConflictStrategy(),
|
|
Fields: GetFieldStrategies(),
|
|
}
|
|
}
|
|
|
|
// GetFieldStrategies retrieves per-field conflict resolution strategies from config.
|
|
// Returns a map of field name to strategy (e.g., {"labels": "union", "compaction_level": "max"}).
|
|
// Invalid strategies are logged and skipped.
|
|
//
|
|
// Config key: conflict.fields
|
|
// Example:
|
|
//
|
|
// conflict:
|
|
// strategy: newest
|
|
// fields:
|
|
// compaction_level: max
|
|
// labels: union
|
|
// waiters: union
|
|
// estimated_minutes: manual
|
|
func GetFieldStrategies() map[string]FieldStrategy {
|
|
result := make(map[string]FieldStrategy)
|
|
if v == nil {
|
|
return result
|
|
}
|
|
|
|
// Get the raw map from config
|
|
fieldsMap := v.GetStringMapString("conflict.fields")
|
|
if fieldsMap == nil {
|
|
return result
|
|
}
|
|
|
|
for field, strategyStr := range fieldsMap {
|
|
strategy := FieldStrategy(strings.ToLower(strings.TrimSpace(strategyStr)))
|
|
if !validFieldStrategies[strategy] {
|
|
logConfigWarning("Warning: invalid conflict.fields.%s strategy %q (valid: %s), skipping\n",
|
|
field, strategyStr, strings.Join(ValidFieldStrategies(), ", "))
|
|
continue
|
|
}
|
|
result[field] = strategy
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// GetFieldStrategy returns the merge strategy for a specific field.
|
|
// Returns the per-field strategy if configured, otherwise returns "newest" (default).
|
|
func GetFieldStrategy(field string) FieldStrategy {
|
|
fields := GetFieldStrategies()
|
|
if strategy, ok := fields[field]; ok {
|
|
return strategy
|
|
}
|
|
return FieldStrategyNewest // Default
|
|
}
|
|
|
|
// FederationConfig holds the federation (Dolt remote) configuration.
|
|
type FederationConfig struct {
|
|
Remote string // dolthub://org/beads, gs://bucket/beads, s3://bucket/beads
|
|
Sovereignty Sovereignty // T1, T2, T3, T4
|
|
}
|
|
|
|
// GetFederationConfig returns the current federation configuration.
|
|
func GetFederationConfig() FederationConfig {
|
|
return FederationConfig{
|
|
Remote: GetString("federation.remote"),
|
|
Sovereignty: GetSovereignty(),
|
|
}
|
|
}
|
|
|
|
// IsSyncModeValid checks if the given sync mode string is valid.
|
|
func IsSyncModeValid(mode string) bool {
|
|
return validSyncModes[SyncMode(mode)]
|
|
}
|
|
|
|
// IsConflictStrategyValid checks if the given conflict strategy string is valid.
|
|
func IsConflictStrategyValid(strategy string) bool {
|
|
return validConflictStrategies[ConflictStrategy(strategy)]
|
|
}
|
|
|
|
// IsSovereigntyValid checks if the given sovereignty tier string is valid.
|
|
// Note: empty string is valid (means no restriction).
|
|
func IsSovereigntyValid(sovereignty string) bool {
|
|
if sovereignty == "" {
|
|
return true
|
|
}
|
|
return validSovereigntyTiers[Sovereignty(sovereignty)]
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// GetCustomTypesFromYAML retrieves custom issue types from config.yaml.
|
|
// This is used as a fallback when the database doesn't have types.custom set yet
|
|
// (e.g., during bd init auto-import before the database is fully configured).
|
|
// Returns nil if no custom types are configured in config.yaml.
|
|
func GetCustomTypesFromYAML() []string {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
|
|
// Try to get types.custom from viper (config.yaml or env var)
|
|
value := v.GetString("types.custom")
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
|
|
// Parse comma-separated list
|
|
parts := strings.Split(value, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
// GetCustomStatusesFromYAML retrieves custom statuses from config.yaml.
|
|
// This is used as a fallback when the database doesn't have status.custom set yet
|
|
// or when the database connection is temporarily unavailable.
|
|
// Returns nil if no custom statuses are configured in config.yaml.
|
|
func GetCustomStatusesFromYAML() []string {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
|
|
// Try to get status.custom from viper (config.yaml or env var)
|
|
value := v.GetString("status.custom")
|
|
if value == "" {
|
|
return nil
|
|
}
|
|
|
|
// Parse comma-separated list
|
|
parts := strings.Split(value, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, p := range parts {
|
|
trimmed := strings.TrimSpace(p)
|
|
if trimmed != "" {
|
|
result = append(result, trimmed)
|
|
}
|
|
}
|
|
return result
|
|
}
|