From 5ba92bdf9d7a2004e19edc813fc2e4dfb3132101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morten=20Klim=20S=C3=B8rensen?= Date: Thu, 23 Apr 2026 18:46:32 +0200 Subject: [PATCH] Implements global user config, respecting users configured XDG_CONFIG_HOME. Merge global config with project specific (if any) --- internal/config/config.go | 70 +++++++++++- internal/config/config_test.go | 197 +++++++++++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 2 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 4523b1ba..d012963b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,20 @@ import ( "gopkg.in/yaml.v3" ) +// UserConfigPath returns the path to the global user config file. +// It respects XDG_CONFIG_HOME: returns $XDG_CONFIG_HOME/chief/config.yaml when set, +// otherwise falls back to ~/.chief/config.yaml. +func UserConfigPath() string { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "chief", "config.yaml") + } + home, err := os.UserHomeDir() + if err != nil { + home = "~" + } + return filepath.Join(home, ".chief", "config.yaml") +} + const configFile = ".chief/config.yaml" // Config holds project-level settings for Chief. @@ -49,10 +63,62 @@ func Exists(baseDir string) bool { return err == nil } -// Load reads the config from .chief/config.yaml. -// Returns Default() when the file doesn't exist (no error). +// Merge combines user and project configs, with project values taking precedence. +// For every field, a non-zero value in project overwrites the value from user. +// A zero/empty value in project falls through to the user value. +func Merge(user, project *Config) *Config { + out := *user + if project.Theme != "" { + out.Theme = project.Theme + } + if project.Worktree.Setup != "" { + out.Worktree.Setup = project.Worktree.Setup + } + if project.OnComplete.Push { + out.OnComplete.Push = project.OnComplete.Push + } + if project.OnComplete.CreatePR { + out.OnComplete.CreatePR = project.OnComplete.CreatePR + } + if project.Agent.Provider != "" { + out.Agent.Provider = project.Agent.Provider + } + if project.Agent.CLIPath != "" { + out.Agent.CLIPath = project.Agent.CLIPath + } + return &out +} + +// Load reads the project config from .chief/config.yaml, merges it with the +// global user config (user values are overridden by project values), and returns +// the merged result. func Load(baseDir string) (*Config, error) { + userCfg, err := LoadUser() + if err != nil { + return nil, err + } + path := configPath(baseDir) + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return userCfg, nil + } + return nil, err + } + + projectCfg := Default() + if err := yaml.Unmarshal(data, projectCfg); err != nil { + return nil, err + } + + return Merge(userCfg, projectCfg), nil +} + +// LoadUser reads the global user config from UserConfigPath(). +// Returns Default() (no error) when the file does not exist. +func LoadUser() (*Config, error) { + path := UserConfigPath() data, err := os.ReadFile(path) if err != nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index dacee39c..fc00d690 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,9 +3,30 @@ package config import ( "os" "path/filepath" + "strings" "testing" ) +func TestUserConfigPath_WithXDG(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "/custom/xdg") + got := UserConfigPath() + want := "/custom/xdg/chief/config.yaml" + if got != want { + t.Errorf("expected %q, got %q", want, got) + } +} + +func TestUserConfigPath_WithoutXDG(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + got := UserConfigPath() + if !strings.HasSuffix(got, filepath.Join(".chief", "config.yaml")) { + t.Errorf("expected path ending in .chief/config.yaml, got %q", got) + } + if strings.HasPrefix(got, "~") { + t.Errorf("expected expanded home dir, got %q", got) + } +} + func TestDefault(t *testing.T) { cfg := Default() if cfg.Worktree.Setup != "" { @@ -62,6 +83,182 @@ func TestSaveAndLoad(t *testing.T) { } } +func TestLoadUser_FileAbsent(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + // No file written — directory exists but config.yaml does not. + cfg, err := LoadUser() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg == nil { + t.Fatal("expected non-nil config") + } +} + +func TestLoadUser_FilePresent(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgDir := filepath.Join(dir, "chief") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte("theme: gruvbox-dark\n"), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := LoadUser() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Theme != "gruvbox-dark" { + t.Errorf("expected theme %q, got %q", "gruvbox-dark", cfg.Theme) + } +} + +func TestLoadUser_FileMalformed(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfgDir := filepath.Join(dir, "chief") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte(":\tinvalid: yaml: [\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := LoadUser() + if err == nil { + t.Fatal("expected error for malformed YAML, got nil") + } +} + +func TestMerge_ProjectOverridesUser(t *testing.T) { + user := &Config{ + Theme: "user-theme", + Worktree: WorktreeConfig{Setup: "user-setup"}, + OnComplete: OnCompleteConfig{Push: false, CreatePR: false}, + Agent: AgentConfig{Provider: "claude", CLIPath: "/usr/bin/claude"}, + } + project := &Config{ + Theme: "project-theme", + Worktree: WorktreeConfig{Setup: "project-setup"}, + OnComplete: OnCompleteConfig{Push: true, CreatePR: true}, + Agent: AgentConfig{Provider: "codex", CLIPath: "/usr/bin/codex"}, + } + got := Merge(user, project) + if got.Theme != "project-theme" { + t.Errorf("Theme: want %q got %q", "project-theme", got.Theme) + } + if got.Worktree.Setup != "project-setup" { + t.Errorf("Worktree.Setup: want %q got %q", "project-setup", got.Worktree.Setup) + } + if !got.OnComplete.Push { + t.Error("OnComplete.Push: want true") + } + if !got.OnComplete.CreatePR { + t.Error("OnComplete.CreatePR: want true") + } + if got.Agent.Provider != "codex" { + t.Errorf("Agent.Provider: want %q got %q", "codex", got.Agent.Provider) + } + if got.Agent.CLIPath != "/usr/bin/codex" { + t.Errorf("Agent.CLIPath: want %q got %q", "/usr/bin/codex", got.Agent.CLIPath) + } +} + +func TestMerge_UserFillsGapWhenProjectEmpty(t *testing.T) { + user := &Config{ + Theme: "user-theme", + Worktree: WorktreeConfig{Setup: "user-setup"}, + OnComplete: OnCompleteConfig{Push: true, CreatePR: true}, + Agent: AgentConfig{Provider: "claude", CLIPath: "/usr/bin/claude"}, + } + project := Default() + got := Merge(user, project) + if got.Theme != "user-theme" { + t.Errorf("Theme: want %q got %q", "user-theme", got.Theme) + } + if got.Worktree.Setup != "user-setup" { + t.Errorf("Worktree.Setup: want %q got %q", "user-setup", got.Worktree.Setup) + } + if !got.OnComplete.Push { + t.Error("OnComplete.Push: want true") + } + if !got.OnComplete.CreatePR { + t.Error("OnComplete.CreatePR: want true") + } + if got.Agent.Provider != "claude" { + t.Errorf("Agent.Provider: want %q got %q", "claude", got.Agent.Provider) + } +} + +func TestMerge_BothEmpty(t *testing.T) { + got := Merge(Default(), Default()) + if got.Theme != "" { + t.Errorf("Theme: want empty got %q", got.Theme) + } + if got.Worktree.Setup != "" { + t.Errorf("Worktree.Setup: want empty got %q", got.Worktree.Setup) + } + if got.OnComplete.Push { + t.Error("OnComplete.Push: want false") + } + if got.OnComplete.CreatePR { + t.Error("OnComplete.CreatePR: want false") + } +} + +func TestEndToEnd_UserConfigOnly(t *testing.T) { + xdgDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgDir) + + cfgDir := filepath.Join(xdgDir, "chief") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte("theme: gruvbox-dark\n"), 0o644); err != nil { + t.Fatal(err) + } + + projectDir := t.TempDir() + cfg, err := Load(projectDir) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.Theme != "gruvbox-dark" { + t.Errorf("expected theme %q, got %q", "gruvbox-dark", cfg.Theme) + } +} + +func TestEndToEnd_ProjectOverridesUser(t *testing.T) { + xdgDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", xdgDir) + + cfgDir := filepath.Join(xdgDir, "chief") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgDir, "config.yaml"), []byte("theme: gruvbox-dark\n"), 0o644); err != nil { + t.Fatal(err) + } + + projectDir := t.TempDir() + chiefDir := filepath.Join(projectDir, ".chief") + if err := os.MkdirAll(chiefDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chiefDir, "config.yaml"), []byte("theme: dracula\n"), 0o644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(projectDir) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.Theme != "dracula" { + t.Errorf("expected theme %q, got %q", "dracula", cfg.Theme) + } +} + func TestExists(t *testing.T) { dir := t.TempDir()