gitFlow/internal/config/config_test.go
dimitar d52e0715f2 feat: phase 2 — CLI commands and configuration resolution
Add the Cobra-based command surface and the flag/env/config-file
resolution layer that all commands share.

Configuration (internal/config):
- Load() resolves settings with the documented precedence flags > env >
  config file > defaults, via viper: GITFLOW_-prefixed env vars with
  dot-to-underscore mapping, plus ~/.gitflow.yaml (or $GITFLOW_CONFIG)
- RegisterFlags/NewFlagSet own the flag definitions so every command and
  the tests share a single source of truth
- Config/Validate/Dump cover dir, interval, format (table/json/compact),
  exclude globs, max depth, worker count, and the AI block (enabled,
  provider, model, api_key_env, base_url); ConfigFile records the loaded
  path; Dump renders the effective config as human-readable YAML with the
  interval as a duration string

App orchestration (internal/app):
- New() validates the configuration at the boundary (fail fast)
- ScanOnce() runs discovery then a concurrent status scan, warning on
  stderr and continuing when discovery is only partially successful
  (e.g. permission-denied subtrees), and bundles everything into a
  ScanResult

CLI (cmd/gitflow):
- root command with scan / config / version subcommands
- scan: resolves config, prompts for the parent directory when stdin is a
  TTY and --dir was not given (per the README), runs a single pass, and
  renders the result — interim plain/JSON output until phase 3 lands the
  presenter package
- config: prints the effective configuration
- version: prints the build version (ldflags-injectable)
- signalContext() wires SIGINT/SIGTERM into a cancellable context for
  graceful shutdown

Testing:
- config: defaults, flag overrides, env overrides, flag-beats-env
  precedence, config file loading (including duration and slice values),
  GITFLOW_CONFIG path override, validation failures, and Dump output
- app: config validation on New, end-to-end ScanOnce over a real temp
  repo, and missing-directory errors

Verified: go build, go vet, go test -race, gofmt clean; manual smoke of
`gitflow version`, `gitflow config`, and `gitflow scan -d <dir>` against a
scratch directory with a dirty repo.
2026-08-02 08:38:55 +02:00

210 lines
5.0 KiB
Go

package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/pflag"
)
// clearEnv removes every GITFLOW_ variable so tests start from a known
// state regardless of the developer's shell.
func clearEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, "GITFLOW_") {
key := strings.SplitN(kv, "=", 2)[0]
os.Unsetenv(key)
}
}
}
func loadWithFlags(t *testing.T, set func(f *pflag.FlagSet)) (*Config, error) {
t.Helper()
fs := NewFlagSet()
if set != nil {
set(fs)
}
return Load(fs)
}
func TestDefaults(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home) // no config file present
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Dir != "." {
t.Errorf("Dir = %q, want %q", cfg.Dir, ".")
}
if cfg.Interval != 0 || cfg.MaxDepth != 0 {
t.Errorf("Interval/MaxDepth = %v/%d, want 0/0", cfg.Interval, cfg.MaxDepth)
}
if cfg.Format != "table" || cfg.Workers != 8 {
t.Errorf("Format/Workers = %q/%d, want table/8", cfg.Format, cfg.Workers)
}
if cfg.AI.Provider != "openai" || cfg.AI.Model != "gpt-4o" || cfg.AI.APIKeyEnv != "OPENAI_API_KEY" {
t.Errorf("AI defaults wrong: %+v", cfg.AI)
}
}
func TestFlagOverrides(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
cfg, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("format", "compact"); err != nil {
t.Fatal(err)
}
if err := f.Set("dir", "/tmp/x"); err != nil {
t.Fatal(err)
}
if err := f.Set("workers", "4"); err != nil {
t.Fatal(err)
}
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "compact" || cfg.Dir != "/tmp/x" || cfg.Workers != 4 {
t.Errorf("flag overrides not applied: %+v", cfg)
}
}
func TestEnvOverrides(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("GITFLOW_FORMAT", "json")
t.Setenv("GITFLOW_MAX_DEPTH", "3")
t.Setenv("GITFLOW_AI_PROVIDER", "ollama")
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "json" || cfg.MaxDepth != 3 || cfg.AI.Provider != "ollama" {
t.Errorf("env overrides not applied: %+v", cfg)
}
}
func TestFlagBeatsEnv(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("GITFLOW_FORMAT", "json")
cfg, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("format", "compact"); err != nil {
t.Fatal(err)
}
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "compact" {
t.Errorf("Format = %q, want compact (flag must beat env)", cfg.Format)
}
}
func TestConfigFile(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
content := "dir: /home/user/projects\nformat: json\nmax_depth: 2\ninterval: 5m\nexclude:\n - node_modules\n - vendor\n"
if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Dir != "/home/user/projects" || cfg.Format != "json" || cfg.MaxDepth != 2 {
t.Errorf("file values not applied: %+v", cfg)
}
if cfg.Interval != 5*time.Minute {
t.Errorf("Interval = %v, want 5m", cfg.Interval)
}
if len(cfg.Exclude) != 2 || cfg.Exclude[0] != "node_modules" || cfg.Exclude[1] != "vendor" {
t.Errorf("Exclude = %v, want [node_modules vendor]", cfg.Exclude)
}
if cfg.ConfigFile != filepath.Join(home, ".gitflow.yaml") {
t.Errorf("ConfigFile = %q, want %q", cfg.ConfigFile, filepath.Join(home, ".gitflow.yaml"))
}
}
func TestConfigPathEnvOverride(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
alt := filepath.Join(home, "custom.yaml")
if err := os.WriteFile(alt, []byte("format: compact\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("GITFLOW_CONFIG", alt)
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "compact" {
t.Errorf("Format = %q, want compact from GITFLOW_CONFIG file", cfg.Format)
}
}
func TestValidateRejectsBadFormat(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("format", "xml"); err != nil {
t.Fatal(err)
}
}); err == nil {
t.Error("Load(bad format) succeeded, want error")
}
}
func TestValidateRejectsBadWorkers(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("workers", "0"); err != nil {
t.Fatal(err)
}
}); err == nil {
t.Error("Load(workers=0) succeeded, want error")
}
}
func TestDump(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
out, err := cfg.Dump()
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, want := range []string{"dir:", "interval:", "format:", "workers:", "ai:", "provider:"} {
if !strings.Contains(out, want) {
t.Errorf("Dump() missing %q:\n%s", want, out)
}
}
}