Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 68 additions & 2 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
197 changes: 197 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down Expand Up @@ -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()

Expand Down