Close out the plan with the remaining polish items and a full README.
Shell completions (cmd/gitflow):
- New `completion [bash|zsh|fish|powershell]` command backed by cobra's
generators, wired into the root command
Desktop notifications (internal/notify):
- Send() dispatches to notify-send (Linux) with an osascript fallback
(macOS); Windows is a documented no-op for now; missing notifiers are
silent, never errors
- watch --notify sends a notification listing repositories whose state
changed since the previous frame
Color themes (internal/presenter):
- ThemeMode (dark/light) with ParseTheme; light uses bright ANSI variants
(90-97) that stay legible on light backgrounds; threaded through the
table, compact, and suggestions renderers; new --theme flag validated
and dumped by config
Custom rules (internal/rules):
- Rule{name, field (ahead|behind|stash|changes), op (==,!=,<,<=,>,>=),
value, label} with upfront validation in both Validate and Eval
- Config gains a `rules:` section (yaml/env only, no flag), validated at
load; matches render as a "FLAGS (custom rules)" section via a new
presenter.Flags renderer, shown in scan and watch frames
Docs:
- readme.md fully rewritten: features, install, usage, examples, flag
table, status classes, configuration reference, AI agent behavior and
--ai-execute guardrails, development layout, CI, and future work
- implementation.md gains an Implementation Progress section recording
every phase branch and the deviations from the original plan
Multi-platform:
- Verified cross-compilation for windows/amd64 and darwin/arm64; the
notify package is split behind build tags
Testing:
- rules: validation, operator semantics, Eval ordering, invalid-rule
errors
- presenter: ParseTheme, light-theme bright codes (and absence of
dark-theme codes), Flags rendering (empty = silent, matches render)
- config: rules loading from file, invalid-rule rejection, bad theme and
bad provider rejection
- notify: no-op behaviour when no notifier is installed (skipped when one
is, to avoid firing real notifications)
Verified: go build, go vet, go test -race (10 packages), gofmt clean,
windows/darwin cross-compile, completion generation, rules + light theme
smoke test, watch --notify graceful shutdown (exit 0, no orphans).
275 lines
6.9 KiB
Go
275 lines
6.9 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 TestConfigRules(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
content := "rules:\n - name: stale\n field: behind\n op: '>='\n value: 10\n label: stale\n - name: dirty\n field: changes\n op: '>='\n value: 1\n label: dirty\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 len(cfg.Rules) != 2 {
|
|
t.Fatalf("Rules = %+v, want 2 rules", cfg.Rules)
|
|
}
|
|
if cfg.Rules[0].Field != "behind" || cfg.Rules[0].Op != ">=" || cfg.Rules[0].Value != 10 || cfg.Rules[0].Label != "stale" {
|
|
t.Errorf("rule[0] = %+v", cfg.Rules[0])
|
|
}
|
|
}
|
|
|
|
func TestConfigRejectsInvalidRules(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
content := "rules:\n - name: bad\n field: nope\n op: '>='\n value: 1\n label: x\n"
|
|
if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := loadWithFlags(t, nil); err == nil {
|
|
t.Error("Load(invalid rule) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsBadTheme(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
|
|
if err := f.Set("theme", "neon"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}); err == nil {
|
|
t.Error("Load(bad theme) succeeded, want error")
|
|
}
|
|
}
|
|
|
|
func TestValidateRejectsBadProvider(t *testing.T) {
|
|
clearEnv(t)
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
|
|
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
|
|
if err := f.Set("ai", "true"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := f.Set("ai-provider", "magic"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}); err == nil {
|
|
t.Error("Load(bad ai provider) 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)
|
|
}
|
|
}
|
|
}
|