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:
parent
263b02b846
commit
d52e0715f2
@ -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
33
cmd/gitflow/prompt.go
Normal 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
52
cmd/gitflow/root.go
Normal 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
103
cmd/gitflow/scan.go
Normal 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
29
go.mod
@ -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
77
go.sum
@ -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
64
internal/app/app.go
Normal 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
74
internal/app/app_test.go
Normal 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
203
internal/config/config.go
Normal 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, ¬Found) {
|
||||||
|
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
|
||||||
|
}
|
||||||
209
internal/config/config_test.go
Normal file
209
internal/config/config_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user