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.
210 lines
5.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|