diff --git a/cmd/gitflow/main.go b/cmd/gitflow/main.go index 3b3e0b7..8c4c159 100644 --- a/cmd/gitflow/main.go +++ b/cmd/gitflow/main.go @@ -6,11 +6,11 @@ package main import ( "fmt" "os" - - "gitea.oblak.solutions/dimitar/gitFlow/internal/version" ) func main() { - fmt.Printf("gitflow %s\n", version.Version) - os.Exit(0) + if err := newRootCmd().Execute(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } } diff --git a/cmd/gitflow/prompt.go b/cmd/gitflow/prompt.go new file mode 100644 index 0000000..97aa89e --- /dev/null +++ b/cmd/gitflow/prompt.go @@ -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 +} diff --git a/cmd/gitflow/root.go b/cmd/gitflow/root.go new file mode 100644 index 0000000..87dc064 --- /dev/null +++ b/cmd/gitflow/root.go @@ -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 + }, + } +} diff --git a/cmd/gitflow/scan.go b/cmd/gitflow/scan.go new file mode 100644 index 0000000..967f646 --- /dev/null +++ b/cmd/gitflow/scan.go @@ -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) +} diff --git a/go.mod b/go.mod index 80fe06c..30b516e 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,31 @@ module gitea.oblak.solutions/dimitar/gitFlow 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 +) diff --git a/go.sum b/go.sum index 15c1db6..4aa9e03 100644 --- a/go.sum +++ b/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/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= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..adfbdd1 --- /dev/null +++ b/internal/app/app.go @@ -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 +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..4447518 --- /dev/null +++ b/internal/app/app_test.go @@ -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") + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3cc7975 --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..25b045e --- /dev/null +++ b/internal/config/config_test.go @@ -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) + } + } +}