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.
This commit is contained in:
dimitar 2026-08-02 08:38:55 +02:00
parent 263b02b846
commit d52e0715f2
10 changed files with 847 additions and 5 deletions

View File

@ -6,11 +6,11 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"gitea.oblak.solutions/dimitar/gitFlow/internal/version"
) )
func main() { func main() {
fmt.Printf("gitflow %s\n", version.Version) if err := newRootCmd().Execute(); err != nil {
os.Exit(0) fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
} }

33
cmd/gitflow/prompt.go Normal file
View File

@ -0,0 +1,33 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
// isTerminal reports whether f is a character device (i.e. a TTY).
func isTerminal(f *os.File) bool {
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
// promptDir asks the user for the parent directory to scan, defaulting to
// def when the input is empty.
func promptDir(def string) (string, error) {
fmt.Fprintf(os.Stderr, "Parent directory to scan [%s]: ", def)
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
if line == "" {
return def, nil
}
return line, nil
}

52
cmd/gitflow/root.go Normal file
View File

@ -0,0 +1,52 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"gitea.oblak.solutions/dimitar/gitFlow/internal/version"
)
// newRootCmd builds the top-level gitflow command.
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "gitflow",
Short: "Scan Git repositories and get suggested next steps",
Long: `gitflow discovers every Git repository under a parent directory,
scans each one's status, and presents the findings. It can rescan on a
schedule and, when enabled, uses an AI agent to suggest next actions for
repositories that need attention.`,
SilenceUsage: true,
SilenceErrors: true,
}
root.AddCommand(
newScanCmd(),
newConfigCmd(),
newVersionCmd(),
)
return root
}
// signalContext returns a context that is cancelled on SIGINT/SIGTERM so
// scans and watch loops shut down gracefully.
func signalContext() (context.Context, context.CancelFunc) {
return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
}
// newVersionCmd prints the build version.
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version information",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
fmt.Printf("gitflow %s\n", version.Version)
return nil
},
}
}

103
cmd/gitflow/scan.go Normal file
View File

@ -0,0 +1,103 @@
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/spf13/cobra"
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// newScanCmd runs a single scan pass and renders the result.
func newScanCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "scan",
Short: "Scan repositories under a directory once",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cmd.Flags())
if err != nil {
return err
}
// The README asks for the parent directory interactively;
// prompt only when nothing was provided and stdin is a TTY.
if !cmd.Flags().Changed("dir") && isTerminal(os.Stdin) {
if d, err := promptDir(cfg.Dir); err == nil {
cfg.Dir = d
}
}
ctx, stop := signalContext()
defer stop()
a, err := app.New(cfg)
if err != nil {
return err
}
result, err := a.ScanOnce(ctx)
if err != nil {
return err
}
// Interim rendering; phase 3 routes this through the
// presenter package (table/json/compact).
switch cfg.Format {
case "json":
return renderJSON(result)
default:
renderTableLines(result)
return nil
}
},
}
config.RegisterFlags(cmd.Flags())
return cmd
}
// newConfigCmd prints the effective configuration.
func newConfigCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Show the effective configuration",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cmd.Flags())
if err != nil {
return err
}
out, err := cfg.Dump()
if err != nil {
return err
}
fmt.Print(out)
return nil
},
}
config.RegisterFlags(cmd.Flags())
return cmd
}
// renderTableLines is the interim text renderer (replaced in phase 3).
func renderTableLines(result status.ScanResult) {
for _, repo := range result.Repos {
mark := "ok"
if repo.Status.NeedsAttention() {
mark = "!!"
}
fmt.Fprintf(os.Stdout, "%2s %-10s %s (branch %s)\n", mark, repo.Status, repo.Path, repo.Branch)
}
s := result.Summary()
fmt.Fprintf(os.Stdout, "\n%d repos | %d clean | %d need attention | %d errors\n",
s.Total, s.Clean, s.Attention, s.Errored)
}
// renderJSON serializes the scan result as indented JSON.
func renderJSON(result status.ScanResult) error {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(result)
}

29
go.mod
View File

@ -2,4 +2,31 @@ module gitea.oblak.solutions/dimitar/gitFlow
go 1.24 go 1.24
require golang.org/x/sync v0.12.0 require (
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
github.com/spf13/viper v1.19.0
golang.org/x/sync v0.12.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

77
go.sum
View File

@ -1,2 +1,79 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

64
internal/app/app.go Normal file
View File

@ -0,0 +1,64 @@
// Package app wires discovery, status scanning, and presentation into the
// runnable operations used by the CLI commands.
package app
import (
"context"
"fmt"
"os"
"time"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/internal/scanner"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// App coordinates the components built from the resolved configuration.
type App struct {
cfg *config.Config
discoverer *scanner.Discoverer
scanner *scanner.Scanner
warn func(format string, args ...any)
}
// New builds an App from a validated configuration.
func New(cfg *config.Config) (*App, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
return &App{
cfg: cfg,
discoverer: scanner.NewDiscoverer(
scanner.WithExclude(cfg.Exclude...),
scanner.WithMaxDepth(cfg.MaxDepth),
),
scanner: scanner.NewScanner(scanner.WithWorkers(cfg.Workers)),
warn: func(format string, args ...any) {
fmt.Fprintf(os.Stderr, "warning: "+format+"\n", args...)
},
}, nil
}
// ScanOnce discovers repositories under the configured directory and
// snapshots their status in a single pass.
func (a *App) ScanOnce(ctx context.Context) (status.ScanResult, error) {
repos, err := a.discoverer.Discover(ctx, a.cfg.Dir)
if err != nil {
if len(repos) == 0 {
return status.ScanResult{}, err
}
// Partial discovery (e.g. permission denied on some subtree):
// warn and continue with what was found.
a.warn("%v", err)
}
infos, err := a.scanner.Scan(ctx, repos)
if err != nil {
return status.ScanResult{}, err
}
return status.ScanResult{
ScannedAt: time.Now(),
ParentDir: a.cfg.Dir,
Repos: infos,
}, nil
}

74
internal/app/app_test.go Normal file
View File

@ -0,0 +1,74 @@
package app
import (
"context"
"os/exec"
"path/filepath"
"testing"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
func initGitRepo(t *testing.T, path string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cmd := exec.Command("git", "init", "-q", "-b", "main", filepath.Base(path))
cmd.Dir = filepath.Dir(path)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
}
func TestNewValidatesConfig(t *testing.T) {
bad := &config.Config{Dir: "", Format: "xml", Workers: 0}
if _, err := New(bad); err == nil {
t.Error("New(bad config) succeeded, want error")
}
}
func TestScanOnce(t *testing.T) {
root := t.TempDir()
repo := filepath.Join(root, "repo")
initGitRepo(t, repo)
cfg := &config.Config{Dir: root, Format: "table", Workers: 4}
a, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
result, err := a.ScanOnce(context.Background())
if err != nil {
t.Fatalf("ScanOnce: %v", err)
}
if result.ParentDir != root {
t.Errorf("ParentDir = %q, want %q", result.ParentDir, root)
}
if len(result.Repos) != 1 {
t.Fatalf("ScanOnce found %d repos, want 1", len(result.Repos))
}
info := result.Repos[0]
if info.Path != repo {
t.Errorf("Repo path = %q, want %q", info.Path, repo)
}
if info.Status != status.StatusClean {
t.Errorf("Repo status = %v, want clean", info.Status)
}
if result.ScannedAt.IsZero() {
t.Error("ScannedAt is zero")
}
}
func TestScanOnceMissingDir(t *testing.T) {
cfg := &config.Config{Dir: filepath.Join(t.TempDir(), "missing"), Format: "table", Workers: 4}
a, err := New(cfg)
if err != nil {
t.Fatalf("New: %v", err)
}
if _, err := a.ScanOnce(context.Background()); err == nil {
t.Error("ScanOnce(missing dir) succeeded, want error")
}
}

203
internal/config/config.go Normal file
View File

@ -0,0 +1,203 @@
// Package config resolves and validates gitflow settings from CLI flags,
// environment variables, and an optional YAML configuration file.
//
// Precedence (highest first): flags, environment variables, config file,
// built-in defaults.
package config
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
)
// AIConfig holds AI agent settings.
type AIConfig struct {
Enabled bool `yaml:"enabled"`
Provider string `yaml:"provider"` // openai or ollama
Model string `yaml:"model"` // model name; empty lets the provider choose
APIKeyEnv string `yaml:"api_key_env"` // env var holding the API key
BaseURL string `yaml:"base_url"` // provider endpoint override
}
// Config is the fully resolved runtime configuration.
type Config struct {
Dir string `yaml:"dir"`
Interval time.Duration `yaml:"interval"`
Format string `yaml:"format"`
Exclude []string `yaml:"exclude,omitempty"`
MaxDepth int `yaml:"max_depth"`
Workers int `yaml:"workers"`
AI AIConfig `yaml:"ai"`
ConfigFile string `yaml:"-"` // path of the loaded config file, if any
}
// flagKeys maps CLI flag names to their viper keys.
var flagKeys = []struct{ flag, key string }{
{"dir", "dir"},
{"interval", "interval"},
{"format", "format"},
{"exclude", "exclude"},
{"max-depth", "max_depth"},
{"workers", "workers"},
{"ai", "ai.enabled"},
{"ai-provider", "ai.provider"},
{"ai-model", "ai.model"},
}
// RegisterFlags defines every gitflow flag on f. Call Load with the same
// FlagSet to resolve the effective configuration.
func RegisterFlags(f *pflag.FlagSet) {
f.StringP("dir", "d", ".", "parent directory to scan")
f.DurationP("interval", "i", 0, "rescan interval (e.g. 30s, 5m); 0 runs once")
f.StringP("format", "f", "table", "output format: table, json, or compact")
f.StringSlice("exclude", nil, "glob patterns of directories to skip (repeatable)")
f.Int("max-depth", 0, "maximum directory depth to scan (0 = unlimited)")
f.Int("workers", 8, "number of concurrent git scans")
f.Bool("ai", false, "enable AI suggestions")
f.String("ai-provider", "openai", "AI provider: openai or ollama")
f.String("ai-model", "gpt-4o", "AI model name")
}
// NewFlagSet returns a FlagSet with every gitflow flag registered.
func NewFlagSet() *pflag.FlagSet {
fs := pflag.NewFlagSet("gitflow", pflag.ContinueOnError)
RegisterFlags(fs)
return fs
}
// Load resolves the effective configuration from flags, environment, and
// config file, then validates it.
func Load(flags *pflag.FlagSet) (*Config, error) {
v := viper.New()
applyDefaults(v)
v.SetEnvPrefix("GITFLOW")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if err := readConfigFile(v); err != nil {
return nil, err
}
bindFlags(v, flags)
cfg := &Config{
Dir: v.GetString("dir"),
Interval: v.GetDuration("interval"),
Format: v.GetString("format"),
Exclude: v.GetStringSlice("exclude"),
MaxDepth: v.GetInt("max_depth"),
Workers: v.GetInt("workers"),
ConfigFile: v.ConfigFileUsed(),
AI: AIConfig{
Enabled: v.GetBool("ai.enabled"),
Provider: v.GetString("ai.provider"),
Model: v.GetString("ai.model"),
APIKeyEnv: v.GetString("ai.api_key_env"),
BaseURL: v.GetString("ai.base_url"),
},
}
if err := cfg.Validate(); err != nil {
return nil, err
}
return cfg, nil
}
func applyDefaults(v *viper.Viper) {
v.SetDefault("dir", ".")
v.SetDefault("interval", 0)
v.SetDefault("format", "table")
v.SetDefault("exclude", []string{})
v.SetDefault("max_depth", 0)
v.SetDefault("workers", 8)
v.SetDefault("ai.enabled", false)
v.SetDefault("ai.provider", "openai")
v.SetDefault("ai.model", "gpt-4o")
v.SetDefault("ai.api_key_env", "OPENAI_API_KEY")
v.SetDefault("ai.base_url", "")
}
// readConfigFile loads ~/.gitflow.yaml (or $GITFLOW_CONFIG when set). A
// missing config file is not an error; a malformed one is.
func readConfigFile(v *viper.Viper) error {
if path := os.Getenv("GITFLOW_CONFIG"); path != "" {
v.SetConfigFile(path)
} else {
v.SetConfigName(".gitflow")
v.SetConfigType("yaml")
v.AddConfigPath("$HOME")
v.AddConfigPath(".")
}
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if errors.As(err, &notFound) {
return nil
}
return fmt.Errorf("config: %w", err)
}
return nil
}
func bindFlags(v *viper.Viper, flags *pflag.FlagSet) {
for _, fk := range flagKeys {
if fl := flags.Lookup(fk.flag); fl != nil {
_ = v.BindPFlag(fk.key, fl)
}
}
}
// Validate rejects configuration that cannot be used.
func (c *Config) Validate() error {
if c.Dir == "" {
return errors.New("config: dir must not be empty")
}
switch c.Format {
case "table", "json", "compact":
default:
return fmt.Errorf("config: unsupported format %q (want table, json, or compact)", c.Format)
}
if c.Interval < 0 {
return errors.New("config: interval must not be negative")
}
if c.MaxDepth < 0 {
return errors.New("config: max-depth must not be negative")
}
if c.Workers < 1 {
return errors.New("config: workers must be at least 1")
}
if c.AI.Enabled && c.AI.Provider == "" {
return errors.New("config: ai provider must not be empty")
}
return nil
}
// Dump renders the effective configuration as human-readable YAML, with the
// interval shown as a duration string.
func (c *Config) Dump() (string, error) {
v := map[string]any{
"config_file": c.ConfigFile,
"dir": c.Dir,
"interval": c.Interval.String(),
"format": c.Format,
"exclude": c.Exclude,
"max_depth": c.MaxDepth,
"workers": c.Workers,
"ai": map[string]any{
"enabled": c.AI.Enabled,
"provider": c.AI.Provider,
"model": c.AI.Model,
"api_key_env": c.AI.APIKeyEnv,
"base_url": c.AI.BaseURL,
},
}
out, err := yaml.Marshal(v)
if err != nil {
return "", err
}
return string(out), nil
}

View File

@ -0,0 +1,209 @@
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)
}
}
}