merge: phase 6 polish into main
This commit is contained in:
commit
982012c867
@ -29,10 +29,34 @@ repositories that need attention.`,
|
|||||||
newWatchCmd(),
|
newWatchCmd(),
|
||||||
newConfigCmd(),
|
newConfigCmd(),
|
||||||
newVersionCmd(),
|
newVersionCmd(),
|
||||||
|
newCompletionCmd(root),
|
||||||
)
|
)
|
||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newCompletionCmd generates shell completion scripts for the root command.
|
||||||
|
func newCompletionCmd(root *cobra.Command) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "completion [bash|zsh|fish|powershell]",
|
||||||
|
Short: "Generate a shell completion script",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
switch args[0] {
|
||||||
|
case "bash":
|
||||||
|
return root.GenBashCompletion(os.Stdout)
|
||||||
|
case "zsh":
|
||||||
|
return root.GenZshCompletion(os.Stdout)
|
||||||
|
case "fish":
|
||||||
|
return root.GenFishCompletion(os.Stdout, true)
|
||||||
|
case "powershell":
|
||||||
|
return root.GenPowerShellCompletion(os.Stdout)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unknown shell %q (want bash, zsh, fish, or powershell)", args[0])
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// signalContext returns a context that is cancelled on SIGINT/SIGTERM so
|
// signalContext returns a context that is cancelled on SIGINT/SIGTERM so
|
||||||
// scans and watch loops shut down gracefully.
|
// scans and watch loops shut down gracefully.
|
||||||
func signalContext() (context.Context, context.CancelFunc) {
|
func signalContext() (context.Context, context.CancelFunc) {
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import (
|
|||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -45,7 +46,10 @@ func newScanCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := presenter.Options{Color: presenter.ParseColorMode(cfg.Color)}
|
opts := presenter.Options{
|
||||||
|
Color: presenter.ParseColorMode(cfg.Color),
|
||||||
|
Theme: presenter.ParseTheme(cfg.Theme),
|
||||||
|
}
|
||||||
if err := presenter.Present(os.Stdout, cfg.Format, result, opts); err != nil {
|
if err := presenter.Present(os.Stdout, cfg.Format, result, opts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -55,6 +59,9 @@ func newScanCmd() *cobra.Command {
|
|||||||
if cfg.AI.Enabled && cfg.Format != "json" {
|
if cfg.AI.Enabled && cfg.Format != "json" {
|
||||||
renderSuggestions(ctx, cfg, opts, result)
|
renderSuggestions(ctx, cfg, opts, result)
|
||||||
}
|
}
|
||||||
|
if err := renderRuleFlags(opts, cfg, result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -92,6 +99,18 @@ func confirmSuggestion(s ai.Suggestion) (bool, error) {
|
|||||||
return promptYesNo(false, "Run in %s: $ %s", s.RepoPath, s.Command)
|
return promptYesNo(false, "Run in %s: $ %s", s.RepoPath, s.Command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// renderRuleFlags evaluates the configured rules and renders any matches.
|
||||||
|
func renderRuleFlags(opts presenter.Options, cfg *config.Config, result status.ScanResult) error {
|
||||||
|
if len(cfg.Rules) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
flags, err := rules.Eval(cfg.Rules, result)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return presenter.Flags(os.Stdout, opts, flags)
|
||||||
|
}
|
||||||
|
|
||||||
// newConfigCmd prints the effective configuration.
|
// newConfigCmd prints the effective configuration.
|
||||||
func newConfigCmd() *cobra.Command {
|
func newConfigCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
|
|||||||
@ -5,12 +5,14 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/notify"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/scheduler"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/scheduler"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
@ -44,7 +46,10 @@ func newWatchCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
opts := presenter.Options{Color: presenter.ParseColorMode(cfg.Color)}
|
opts := presenter.Options{
|
||||||
|
Color: presenter.ParseColorMode(cfg.Color),
|
||||||
|
Theme: presenter.ParseTheme(cfg.Theme),
|
||||||
|
}
|
||||||
|
|
||||||
var prev status.ScanResult
|
var prev status.ScanResult
|
||||||
first := true
|
first := true
|
||||||
@ -62,6 +67,12 @@ func newWatchCmd() *cobra.Command {
|
|||||||
if cfg.AI.Enabled && (first || len(status.Changed(prev, result)) > 0) {
|
if cfg.AI.Enabled && (first || len(status.Changed(prev, result)) > 0) {
|
||||||
renderSuggestions(ctx, cfg, opts, result)
|
renderSuggestions(ctx, cfg, opts, result)
|
||||||
}
|
}
|
||||||
|
if err := renderRuleFlags(opts, cfg, result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !first && cfg.Notify {
|
||||||
|
notifyChanges(prev, result)
|
||||||
|
}
|
||||||
prev = result
|
prev = result
|
||||||
first = false
|
first = false
|
||||||
return nil
|
return nil
|
||||||
@ -75,6 +86,20 @@ func newWatchCmd() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notifyChanges sends a desktop notification listing repositories whose
|
||||||
|
// state changed since the previous frame.
|
||||||
|
func notifyChanges(prev, result status.ScanResult) {
|
||||||
|
changed := status.Changed(prev, result)
|
||||||
|
if len(changed) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(changed))
|
||||||
|
for _, r := range changed {
|
||||||
|
names = append(names, r.Name)
|
||||||
|
}
|
||||||
|
_ = notify.Send("gitflow: changes detected", strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
// renderWatchFrame clears the screen (or prints a timestamp header when
|
// renderWatchFrame clears the screen (or prints a timestamp header when
|
||||||
// output is not a terminal), renders the scan, and prints a footer with
|
// output is not a terminal), renders the scan, and prints a footer with
|
||||||
// changed repositories and the next scan time.
|
// changed repositories and the next scan time.
|
||||||
|
|||||||
@ -291,3 +291,37 @@ Construct a prompt that includes:
|
|||||||
| **M7 — Polish** | Phase 6: TUI, themes, completions, docs | 2–3 days |
|
| **M7 — Polish** | Phase 6: TUI, themes, completions, docs | 2–3 days |
|
||||||
|
|
||||||
**Total estimated effort**: ~7–10 days for a solid v1.0
|
**Total estimated effort**: ~7–10 days for a solid v1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Progress
|
||||||
|
|
||||||
|
All phases from this plan are implemented and merged into `main` (branch
|
||||||
|
per phase, merged with `--no-ff`, no remote pushes).
|
||||||
|
|
||||||
|
| Phase | Branch | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| 0 — Scaffolding | `feat/phase-0-scaffolding` | ✅ done |
|
||||||
|
| 1 — Discovery & scanning | `feat/phase-1-discovery-scanning` | ✅ done |
|
||||||
|
| 2 — CLI & configuration | `feat/phase-2-cli-config` | ✅ done |
|
||||||
|
| 3 — Presentation | `feat/phase-3-presentation` | ✅ done |
|
||||||
|
| 4 — Scheduler / watch | `feat/phase-4-scheduler` | ✅ done |
|
||||||
|
| 5 — AI agent | `feat/phase-5-ai` | ✅ done |
|
||||||
|
| 6 — Polish | `feat/phase-6-polish` | ✅ done |
|
||||||
|
|
||||||
|
### Deviations from the plan
|
||||||
|
|
||||||
|
- **`LastFetch` field dropped** (phase 1): the only reliable source is a
|
||||||
|
reflog of the remote-tracking ref, which does not exist on fresh clones;
|
||||||
|
noted as future work instead.
|
||||||
|
- **Full bubbletea TUI deferred** (phase 6): the plan listed it as
|
||||||
|
optional; watch mode + completions + themes + notifications were
|
||||||
|
implemented instead.
|
||||||
|
- **Webhooks deferred** (phase 6): desktop notifications cover the
|
||||||
|
alerting case; Slack/Discord noted as future work.
|
||||||
|
- **AI defaults**: `ai-model` defaults to `gpt-4o` (plan) with
|
||||||
|
provider-specific fallbacks (ollama → `llama3.2`, anthropic →
|
||||||
|
`claude-3-5-haiku-latest`); `--ai-execute` implemented with an
|
||||||
|
allowlist + per-command confirmation instead of a bare auto-run.
|
||||||
|
- **testify** was not added: stdlib `testing` covers all suites, keeping
|
||||||
|
the dependency tree minimal.
|
||||||
|
|||||||
@ -23,7 +23,7 @@ func initGitRepo(t *testing.T, path string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNewValidatesConfig(t *testing.T) {
|
func TestNewValidatesConfig(t *testing.T) {
|
||||||
bad := &config.Config{Dir: "", Format: "xml", Workers: 0}
|
bad := &config.Config{Dir: "", Format: "xml", Theme: "dark", Workers: 0}
|
||||||
if _, err := New(bad); err == nil {
|
if _, err := New(bad); err == nil {
|
||||||
t.Error("New(bad config) succeeded, want error")
|
t.Error("New(bad config) succeeded, want error")
|
||||||
}
|
}
|
||||||
@ -34,7 +34,7 @@ func TestScanOnce(t *testing.T) {
|
|||||||
repo := filepath.Join(root, "repo")
|
repo := filepath.Join(root, "repo")
|
||||||
initGitRepo(t, repo)
|
initGitRepo(t, repo)
|
||||||
|
|
||||||
cfg := &config.Config{Dir: root, Format: "table", Color: "auto", Workers: 4}
|
cfg := &config.Config{Dir: root, Format: "table", Color: "auto", Theme: "dark", Workers: 4}
|
||||||
a, err := New(cfg)
|
a, err := New(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
@ -63,7 +63,7 @@ func TestScanOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScanOnceMissingDir(t *testing.T) {
|
func TestScanOnceMissingDir(t *testing.T) {
|
||||||
cfg := &config.Config{Dir: filepath.Join(t.TempDir(), "missing"), Format: "table", Color: "auto", Workers: 4}
|
cfg := &config.Config{Dir: filepath.Join(t.TempDir(), "missing"), Format: "table", Color: "auto", Theme: "dark", Workers: 4}
|
||||||
a, err := New(cfg)
|
a, err := New(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import (
|
|||||||
"github.com/spf13/pflag"
|
"github.com/spf13/pflag"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
|
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AIConfig holds AI agent settings.
|
// AIConfig holds AI agent settings.
|
||||||
@ -33,9 +35,12 @@ type Config struct {
|
|||||||
Interval time.Duration `yaml:"interval"`
|
Interval time.Duration `yaml:"interval"`
|
||||||
Format string `yaml:"format"`
|
Format string `yaml:"format"`
|
||||||
Color string `yaml:"color"`
|
Color string `yaml:"color"`
|
||||||
|
Theme string `yaml:"theme"`
|
||||||
|
Notify bool `yaml:"notify"`
|
||||||
Exclude []string `yaml:"exclude,omitempty"`
|
Exclude []string `yaml:"exclude,omitempty"`
|
||||||
MaxDepth int `yaml:"max_depth"`
|
MaxDepth int `yaml:"max_depth"`
|
||||||
Workers int `yaml:"workers"`
|
Workers int `yaml:"workers"`
|
||||||
|
Rules []rules.Rule `yaml:"rules,omitempty"`
|
||||||
AI AIConfig `yaml:"ai"`
|
AI AIConfig `yaml:"ai"`
|
||||||
ConfigFile string `yaml:"-"` // path of the loaded config file, if any
|
ConfigFile string `yaml:"-"` // path of the loaded config file, if any
|
||||||
}
|
}
|
||||||
@ -49,6 +54,8 @@ var flagKeys = []struct{ flag, key string }{
|
|||||||
{"exclude", "exclude"},
|
{"exclude", "exclude"},
|
||||||
{"max-depth", "max_depth"},
|
{"max-depth", "max_depth"},
|
||||||
{"workers", "workers"},
|
{"workers", "workers"},
|
||||||
|
{"notify", "notify"},
|
||||||
|
{"theme", "theme"},
|
||||||
{"ai", "ai.enabled"},
|
{"ai", "ai.enabled"},
|
||||||
{"ai-provider", "ai.provider"},
|
{"ai-provider", "ai.provider"},
|
||||||
{"ai-model", "ai.model"},
|
{"ai-model", "ai.model"},
|
||||||
@ -65,6 +72,8 @@ func RegisterFlags(f *pflag.FlagSet) {
|
|||||||
f.StringSlice("exclude", nil, "glob patterns of directories to skip (repeatable)")
|
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("max-depth", 0, "maximum directory depth to scan (0 = unlimited)")
|
||||||
f.Int("workers", 8, "number of concurrent git scans")
|
f.Int("workers", 8, "number of concurrent git scans")
|
||||||
|
f.Bool("notify", false, "send desktop notifications when watch sees changes")
|
||||||
|
f.String("theme", "dark", "color theme: dark or light")
|
||||||
f.Bool("ai", false, "enable AI suggestions")
|
f.Bool("ai", false, "enable AI suggestions")
|
||||||
f.String("ai-provider", "openai", "AI provider: openai, ollama, or anthropic")
|
f.String("ai-provider", "openai", "AI provider: openai, ollama, or anthropic")
|
||||||
f.String("ai-model", "gpt-4o", "AI model name")
|
f.String("ai-model", "gpt-4o", "AI model name")
|
||||||
@ -97,6 +106,8 @@ func Load(flags *pflag.FlagSet) (*Config, error) {
|
|||||||
Interval: v.GetDuration("interval"),
|
Interval: v.GetDuration("interval"),
|
||||||
Format: v.GetString("format"),
|
Format: v.GetString("format"),
|
||||||
Color: v.GetString("color"),
|
Color: v.GetString("color"),
|
||||||
|
Theme: v.GetString("theme"),
|
||||||
|
Notify: v.GetBool("notify"),
|
||||||
Exclude: v.GetStringSlice("exclude"),
|
Exclude: v.GetStringSlice("exclude"),
|
||||||
MaxDepth: v.GetInt("max_depth"),
|
MaxDepth: v.GetInt("max_depth"),
|
||||||
Workers: v.GetInt("workers"),
|
Workers: v.GetInt("workers"),
|
||||||
@ -113,6 +124,17 @@ func Load(flags *pflag.FlagSet) (*Config, error) {
|
|||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Rules are not exposed as flags; read them from the file/env only.
|
||||||
|
var rulesList []rules.Rule
|
||||||
|
if err := v.UnmarshalKey("rules", &rulesList); err != nil {
|
||||||
|
return nil, fmt.Errorf("config: rules: %w", err)
|
||||||
|
}
|
||||||
|
cfg.Rules = rulesList
|
||||||
|
for _, r := range cfg.Rules {
|
||||||
|
if err := r.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,6 +146,8 @@ func applyDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("exclude", []string{})
|
v.SetDefault("exclude", []string{})
|
||||||
v.SetDefault("max_depth", 0)
|
v.SetDefault("max_depth", 0)
|
||||||
v.SetDefault("workers", 8)
|
v.SetDefault("workers", 8)
|
||||||
|
v.SetDefault("notify", false)
|
||||||
|
v.SetDefault("theme", "dark")
|
||||||
v.SetDefault("ai.enabled", false)
|
v.SetDefault("ai.enabled", false)
|
||||||
v.SetDefault("ai.provider", "openai")
|
v.SetDefault("ai.provider", "openai")
|
||||||
v.SetDefault("ai.model", "gpt-4o")
|
v.SetDefault("ai.model", "gpt-4o")
|
||||||
@ -176,6 +200,11 @@ func (c *Config) Validate() error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("config: unsupported color mode %q (want auto, always, or never)", c.Color)
|
return fmt.Errorf("config: unsupported color mode %q (want auto, always, or never)", c.Color)
|
||||||
}
|
}
|
||||||
|
switch c.Theme {
|
||||||
|
case "dark", "light":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("config: unsupported theme %q (want dark or light)", c.Theme)
|
||||||
|
}
|
||||||
if c.Interval < 0 {
|
if c.Interval < 0 {
|
||||||
return errors.New("config: interval must not be negative")
|
return errors.New("config: interval must not be negative")
|
||||||
}
|
}
|
||||||
@ -207,6 +236,9 @@ func (c *Config) Dump() (string, error) {
|
|||||||
"exclude": c.Exclude,
|
"exclude": c.Exclude,
|
||||||
"max_depth": c.MaxDepth,
|
"max_depth": c.MaxDepth,
|
||||||
"workers": c.Workers,
|
"workers": c.Workers,
|
||||||
|
"notify": c.Notify,
|
||||||
|
"theme": c.Theme,
|
||||||
|
"rules": c.Rules,
|
||||||
"ai": map[string]any{
|
"ai": map[string]any{
|
||||||
"enabled": c.AI.Enabled,
|
"enabled": c.AI.Enabled,
|
||||||
"provider": c.AI.Provider,
|
"provider": c.AI.Provider,
|
||||||
|
|||||||
@ -188,6 +188,71 @@ func TestValidateRejectsBadWorkers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConfigRules(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
content := "rules:\n - name: stale\n field: behind\n op: '>='\n value: 10\n label: stale\n - name: dirty\n field: changes\n op: '>='\n value: 1\n label: dirty\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := loadWithFlags(t, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
if len(cfg.Rules) != 2 {
|
||||||
|
t.Fatalf("Rules = %+v, want 2 rules", cfg.Rules)
|
||||||
|
}
|
||||||
|
if cfg.Rules[0].Field != "behind" || cfg.Rules[0].Op != ">=" || cfg.Rules[0].Value != 10 || cfg.Rules[0].Label != "stale" {
|
||||||
|
t.Errorf("rule[0] = %+v", cfg.Rules[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigRejectsInvalidRules(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
content := "rules:\n - name: bad\n field: nope\n op: '>='\n value: 1\n label: x\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(home, ".gitflow.yaml"), []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := loadWithFlags(t, nil); err == nil {
|
||||||
|
t.Error("Load(invalid rule) succeeded, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsBadTheme(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
|
||||||
|
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
|
||||||
|
if err := f.Set("theme", "neon"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}); err == nil {
|
||||||
|
t.Error("Load(bad theme) succeeded, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRejectsBadProvider(t *testing.T) {
|
||||||
|
clearEnv(t)
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
|
||||||
|
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
|
||||||
|
if err := f.Set("ai", "true"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := f.Set("ai-provider", "magic"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}); err == nil {
|
||||||
|
t.Error("Load(bad ai provider) succeeded, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDump(t *testing.T) {
|
func TestDump(t *testing.T) {
|
||||||
clearEnv(t)
|
clearEnv(t)
|
||||||
home := t.TempDir()
|
home := t.TempDir()
|
||||||
|
|||||||
9
internal/notify/notify.go
Normal file
9
internal/notify/notify.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
// Package notify sends desktop notifications when repositories change while
|
||||||
|
// watching. Platforms without a notifier are silent no-ops, never errors.
|
||||||
|
package notify
|
||||||
|
|
||||||
|
// Send posts a desktop notification. It returns nil when no notifier is
|
||||||
|
// available; a non-nil error means a notifier was found but failed.
|
||||||
|
func Send(title, body string) error {
|
||||||
|
return send(title, body)
|
||||||
|
}
|
||||||
27
internal/notify/notify_test.go
Normal file
27
internal/notify/notify_test.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hasNotifier reports whether a desktop notifier is installed, so tests can
|
||||||
|
// avoid firing real notifications on developer machines.
|
||||||
|
func hasNotifier() bool {
|
||||||
|
for _, bin := range []string{"notify-send", "osascript"} {
|
||||||
|
if _, err := exec.LookPath(bin); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendWithoutNotifier(t *testing.T) {
|
||||||
|
if hasNotifier() {
|
||||||
|
t.Skip("a desktop notifier is installed; skipping to avoid firing notifications")
|
||||||
|
}
|
||||||
|
// Without a notifier, Send must be a silent no-op.
|
||||||
|
if err := Send("gitflow test", "no notifier available"); err != nil {
|
||||||
|
t.Errorf("Send: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
21
internal/notify/notify_unix.go
Normal file
21
internal/notify/notify_unix.go
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
//go:build linux || darwin
|
||||||
|
|
||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
)
|
||||||
|
|
||||||
|
// send prefers notify-send (Linux) and falls back to osascript (macOS).
|
||||||
|
// If neither is installed the notification is dropped silently.
|
||||||
|
func send(title, body string) error {
|
||||||
|
if _, err := exec.LookPath("notify-send"); err == nil {
|
||||||
|
return exec.Command("notify-send", "-a", "gitflow", "--", title, body).Run()
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("osascript"); err == nil {
|
||||||
|
script := fmt.Sprintf("display notification %q with title %q", body, title)
|
||||||
|
return exec.Command("osascript", "-e", script).Run()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
9
internal/notify/notify_windows.go
Normal file
9
internal/notify/notify_windows.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package notify
|
||||||
|
|
||||||
|
// send is a no-op on Windows for now; a PowerShell toast bridge can be
|
||||||
|
// added later.
|
||||||
|
func send(title, body string) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@ -15,7 +15,7 @@ type CompactFormatter struct {
|
|||||||
|
|
||||||
// Format implements Formatter.
|
// Format implements Formatter.
|
||||||
func (c *CompactFormatter) Format(w io.Writer, result status.ScanResult) error {
|
func (c *CompactFormatter) Format(w io.Writer, result status.ScanResult) error {
|
||||||
r := newRenderer(c.opts.Color, w)
|
r := newRendererTheme(c.opts.Color, c.opts.Theme, w)
|
||||||
for _, repo := range result.Repos {
|
for _, repo := range result.Repos {
|
||||||
sym := statusSymbol(repo.Status)
|
sym := statusSymbol(repo.Status)
|
||||||
line := r.paint(repo.Status, sym) + " " + shortPath(repo.Path)
|
line := r.paint(repo.Status, sym) + " " + shortPath(repo.Path)
|
||||||
|
|||||||
25
internal/presenter/flags.go
Normal file
25
internal/presenter/flags.go
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
package presenter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"text/tabwriter"
|
||||||
|
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flags renders user-rule matches below a scan result.
|
||||||
|
func Flags(w io.Writer, opts Options, flags []rules.Flag) error {
|
||||||
|
if len(flags) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
r := newRendererTheme(opts.Color, opts.Theme, w)
|
||||||
|
fmt.Fprintln(w, "\nFLAGS (custom rules)")
|
||||||
|
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "REPOSITORY\tFLAG")
|
||||||
|
for _, f := range flags {
|
||||||
|
fmt.Fprintf(tw, "%s\t%s\n", shortPath(f.RepoPath), r.yellow+f.Label+r.reset)
|
||||||
|
}
|
||||||
|
return tw.Flush()
|
||||||
|
}
|
||||||
@ -35,9 +35,27 @@ func ParseColorMode(s string) ColorMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ThemeMode selects the ANSI palette used for colored output.
|
||||||
|
type ThemeMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
ThemeDark ThemeMode = iota // classic dark-terminal colors
|
||||||
|
ThemeLight // brighter hues for light backgrounds
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseTheme converts a CLI string to a ThemeMode; anything unknown falls
|
||||||
|
// back to dark.
|
||||||
|
func ParseTheme(s string) ThemeMode {
|
||||||
|
if s == "light" {
|
||||||
|
return ThemeLight
|
||||||
|
}
|
||||||
|
return ThemeDark
|
||||||
|
}
|
||||||
|
|
||||||
// Options control rendering behaviour.
|
// Options control rendering behaviour.
|
||||||
type Options struct {
|
type Options struct {
|
||||||
Color ColorMode
|
Color ColorMode
|
||||||
|
Theme ThemeMode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Formatter renders a ScanResult to a writer.
|
// Formatter renders a ScanResult to a writer.
|
||||||
@ -80,7 +98,10 @@ type renderer struct {
|
|||||||
reset string
|
reset string
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRenderer(mode ColorMode, w io.Writer) renderer {
|
// newRendererTheme resolves ANSI escape codes according to the color mode,
|
||||||
|
// the theme, the destination, and the NO_COLOR convention
|
||||||
|
// (https://no-color.org).
|
||||||
|
func newRendererTheme(mode ColorMode, theme ThemeMode, w io.Writer) renderer {
|
||||||
useColor := false
|
useColor := false
|
||||||
switch mode {
|
switch mode {
|
||||||
case ColorAlways:
|
case ColorAlways:
|
||||||
@ -93,6 +114,17 @@ func newRenderer(mode ColorMode, w io.Writer) renderer {
|
|||||||
if !useColor {
|
if !useColor {
|
||||||
return renderer{}
|
return renderer{}
|
||||||
}
|
}
|
||||||
|
if theme == ThemeLight {
|
||||||
|
// Bright variants (90-97) stay legible on light backgrounds.
|
||||||
|
return renderer{
|
||||||
|
green: "\x1b[92m",
|
||||||
|
yellow: "\x1b[93m",
|
||||||
|
red: "\x1b[91m",
|
||||||
|
cyan: "\x1b[96m",
|
||||||
|
blue: "\x1b[94m",
|
||||||
|
reset: "\x1b[0m",
|
||||||
|
}
|
||||||
|
}
|
||||||
return renderer{
|
return renderer{
|
||||||
green: "\x1b[32m",
|
green: "\x1b[32m",
|
||||||
yellow: "\x1b[33m",
|
yellow: "\x1b[33m",
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -147,6 +148,63 @@ func TestForRejectsUnknownFormat(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseTheme(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
want ThemeMode
|
||||||
|
}{
|
||||||
|
{"dark", ThemeDark},
|
||||||
|
{"light", ThemeLight},
|
||||||
|
{"bogus", ThemeDark},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := ParseTheme(tc.in); got != tc.want {
|
||||||
|
t.Errorf("ParseTheme(%q) = %v, want %v", tc.in, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLightThemeUsesBrightCodes(t *testing.T) {
|
||||||
|
t.Setenv("NO_COLOR", "") // light theme must not be disabled by NO_COLOR in always mode
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := Present(&buf, "table", sampleResult(), Options{Color: ColorAlways, Theme: ThemeLight}); err != nil {
|
||||||
|
t.Fatalf("Present: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
if !strings.Contains(out, "\x1b[92m") && !strings.Contains(out, "\x1b[93m") {
|
||||||
|
t.Errorf("light theme did not emit bright codes:\n%q", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "\x1b[32m") {
|
||||||
|
t.Errorf("light theme emitted a dark-theme code:\n%q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFlags(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
opts := Options{Color: ColorNever}
|
||||||
|
if err := Flags(&buf, opts, nil); err != nil {
|
||||||
|
t.Fatalf("Flags(empty): %v", err)
|
||||||
|
}
|
||||||
|
if buf.Len() != 0 {
|
||||||
|
t.Errorf("Flags(empty) wrote %q, want nothing", buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
flags := []rules.Flag{
|
||||||
|
{RepoPath: "/home/user/projects/lib", Label: "stale"},
|
||||||
|
{RepoPath: "/home/user/projects/web", Label: "dirty"},
|
||||||
|
}
|
||||||
|
buf.Reset()
|
||||||
|
if err := Flags(&buf, opts, flags); err != nil {
|
||||||
|
t.Fatalf("Flags: %v", err)
|
||||||
|
}
|
||||||
|
out := buf.String()
|
||||||
|
for _, want := range []string{"FLAGS (custom rules)", "stale", "dirty", "REPOSITORY"} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("flags output missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSuggestions(t *testing.T) {
|
func TestSuggestions(t *testing.T) {
|
||||||
sugs := []ai.Suggestion{
|
sugs := []ai.Suggestion{
|
||||||
{RepoPath: "/home/user/projects/web", Action: "commit", Message: "commit your work", Command: "git add -A && git commit -m wip", Priority: 2},
|
{RepoPath: "/home/user/projects/web", Action: "commit", Message: "commit your work", Command: "git add -A && git commit -m wip", Priority: 2},
|
||||||
|
|||||||
@ -16,7 +16,7 @@ func Suggestions(w io.Writer, opts Options, suggestions []ai.Suggestion) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
r := newRenderer(opts.Color, w)
|
r := newRendererTheme(opts.Color, opts.Theme, w)
|
||||||
fmt.Fprintln(w, "\nAI SUGGESTIONS")
|
fmt.Fprintln(w, "\nAI SUGGESTIONS")
|
||||||
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
|
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
|
||||||
fmt.Fprintln(tw, "REPOSITORY\tACTION\tPRIORITY\tMESSAGE\tCOMMAND")
|
fmt.Fprintln(tw, "REPOSITORY\tACTION\tPRIORITY\tMESSAGE\tCOMMAND")
|
||||||
|
|||||||
@ -18,7 +18,7 @@ type TableFormatter struct {
|
|||||||
|
|
||||||
// Format implements Formatter.
|
// Format implements Formatter.
|
||||||
func (t *TableFormatter) Format(w io.Writer, result status.ScanResult) error {
|
func (t *TableFormatter) Format(w io.Writer, result status.ScanResult) error {
|
||||||
r := newRenderer(t.opts.Color, w)
|
r := newRendererTheme(t.opts.Color, t.opts.Theme, w)
|
||||||
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
|
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
|
||||||
|
|
||||||
fmt.Fprintln(tw, "REPOSITORY\tBRANCH\tSTATUS\tAHEAD/BEHIND\tCHANGES\tSTASH")
|
fmt.Fprintln(tw, "REPOSITORY\tBRANCH\tSTATUS\tAHEAD/BEHIND\tCHANGES\tSTASH")
|
||||||
|
|||||||
104
internal/rules/rules.go
Normal file
104
internal/rules/rules.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
// Package rules evaluates user-defined threshold rules against a scan
|
||||||
|
// result, producing labels for repositories that match (e.g. flag a branch
|
||||||
|
// as "stale" when it is 10+ commits behind).
|
||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Rule flags a repository when a numeric field crosses a threshold.
|
||||||
|
type Rule struct {
|
||||||
|
Name string `yaml:"name"` // descriptive name, for config output
|
||||||
|
Field string `yaml:"field"` // ahead, behind, stash, or changes
|
||||||
|
Op string `yaml:"op"` // ==, !=, <, <=, >, >=
|
||||||
|
Value int `yaml:"value"` // threshold to compare against
|
||||||
|
Label string `yaml:"label"` // flag label, e.g. "critical"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks that the rule can be evaluated.
|
||||||
|
func (r Rule) Validate() error {
|
||||||
|
switch r.Field {
|
||||||
|
case "ahead", "behind", "stash", "changes":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("rules: %s: unknown field %q (want ahead, behind, stash, or changes)", r.Name, r.Field)
|
||||||
|
}
|
||||||
|
switch r.Op {
|
||||||
|
case "==", "!=", "<", "<=", ">", ">=":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("rules: %s: unknown operator %q (want ==, !=, <, <=, >, >=)", r.Name, r.Op)
|
||||||
|
}
|
||||||
|
if r.Label == "" {
|
||||||
|
return fmt.Errorf("rules: %s: label must not be empty", r.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flag is a rule match on a repository.
|
||||||
|
type Flag struct {
|
||||||
|
RepoPath string
|
||||||
|
Label string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches reports whether the rule matches the repository.
|
||||||
|
func (r Rule) Matches(repo status.RepoInfo) (bool, error) {
|
||||||
|
v, err := fieldValue(r.Field, repo)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
switch r.Op {
|
||||||
|
case "==":
|
||||||
|
return v == r.Value, nil
|
||||||
|
case "!=":
|
||||||
|
return v != r.Value, nil
|
||||||
|
case "<":
|
||||||
|
return v < r.Value, nil
|
||||||
|
case "<=":
|
||||||
|
return v <= r.Value, nil
|
||||||
|
case ">":
|
||||||
|
return v > r.Value, nil
|
||||||
|
case ">=":
|
||||||
|
return v >= r.Value, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("rules: %s: unknown operator %q", r.Name, r.Op)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Eval applies the rules to the result and returns every match, in scan
|
||||||
|
// order. Rules are validated up front; an invalid rule is an error.
|
||||||
|
func Eval(rules []Rule, result status.ScanResult) ([]Flag, error) {
|
||||||
|
for _, rule := range rules {
|
||||||
|
if err := rule.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []Flag
|
||||||
|
for _, rule := range rules {
|
||||||
|
for _, repo := range result.Repos {
|
||||||
|
ok, err := rule.Matches(repo)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
out = append(out, Flag{RepoPath: repo.Path, Label: rule.Label})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fieldValue extracts the numeric field a rule compares against.
|
||||||
|
func fieldValue(field string, repo status.RepoInfo) (int, error) {
|
||||||
|
switch field {
|
||||||
|
case "ahead":
|
||||||
|
return repo.AheadBy, nil
|
||||||
|
case "behind":
|
||||||
|
return repo.BehindBy, nil
|
||||||
|
case "stash":
|
||||||
|
return repo.StashCount, nil
|
||||||
|
case "changes":
|
||||||
|
return repo.FileCount(), nil
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("rules: unknown field %q", field)
|
||||||
|
}
|
||||||
84
internal/rules/rules_test.go
Normal file
84
internal/rules/rules_test.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package rules
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuleValidate(t *testing.T) {
|
||||||
|
good := Rule{Name: "stale", Field: "behind", Op: ">=", Value: 10, Label: "stale"}
|
||||||
|
if err := good.Validate(); err != nil {
|
||||||
|
t.Errorf("Validate(good): %v", err)
|
||||||
|
}
|
||||||
|
bad := []Rule{
|
||||||
|
{Name: "a", Field: "nope", Op: ">=", Label: "x"},
|
||||||
|
{Name: "b", Field: "behind", Op: "~", Label: "x"},
|
||||||
|
{Name: "c", Field: "behind", Op: ">=", Label: ""},
|
||||||
|
}
|
||||||
|
for _, r := range bad {
|
||||||
|
if err := r.Validate(); err == nil {
|
||||||
|
t.Errorf("Validate(%+v) succeeded, want error", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleMatches(t *testing.T) {
|
||||||
|
repo := status.RepoInfo{BehindBy: 12, StashCount: 1, ModifiedFiles: []string{"a"}}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
rule Rule
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{Rule{Field: "behind", Op: ">=", Value: 10}, true},
|
||||||
|
{Rule{Field: "behind", Op: ">=", Value: 13}, false},
|
||||||
|
{Rule{Field: "behind", Op: "==", Value: 12}, true},
|
||||||
|
{Rule{Field: "stash", Op: ">", Value: 0}, true},
|
||||||
|
{Rule{Field: "stash", Op: "==", Value: 0}, false},
|
||||||
|
{Rule{Field: "changes", Op: ">=", Value: 1}, true},
|
||||||
|
{Rule{Field: "ahead", Op: "==", Value: 0}, true},
|
||||||
|
{Rule{Field: "changes", Op: "!=", Value: 3}, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
got, err := tc.rule.Matches(repo)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Matches(%+v): %v", tc.rule, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("Matches(%+v) = %v, want %v", tc.rule, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEval(t *testing.T) {
|
||||||
|
rules := []Rule{
|
||||||
|
{Name: "stale", Field: "behind", Op: ">=", Value: 10, Label: "stale"},
|
||||||
|
{Name: "dirty", Field: "changes", Op: ">=", Value: 1, Label: "dirty"},
|
||||||
|
}
|
||||||
|
result := status.ScanResult{Repos: []status.RepoInfo{
|
||||||
|
{Path: "/a", BehindBy: 15},
|
||||||
|
{Path: "/b", ModifiedFiles: []string{"x"}},
|
||||||
|
{Path: "/c"},
|
||||||
|
}}
|
||||||
|
flags, err := Eval(rules, result)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Eval: %v", err)
|
||||||
|
}
|
||||||
|
want := []Flag{{RepoPath: "/a", Label: "stale"}, {RepoPath: "/b", Label: "dirty"}}
|
||||||
|
if len(flags) != len(want) {
|
||||||
|
t.Fatalf("Eval returned %d flags, want %d: %+v", len(flags), len(want), flags)
|
||||||
|
}
|
||||||
|
for i, f := range flags {
|
||||||
|
if f != want[i] {
|
||||||
|
t.Errorf("flag[%d] = %+v, want %+v", i, f, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvalInvalidRule(t *testing.T) {
|
||||||
|
rules := []Rule{{Name: "bad", Field: "nope", Op: ">=", Label: "x"}}
|
||||||
|
if _, err := Eval(rules, status.ScanResult{}); err == nil {
|
||||||
|
t.Error("Eval(invalid rule) succeeded, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
204
readme.md
204
readme.md
@ -1,8 +1,198 @@
|
|||||||
# git flow is CLI app writen go.
|
# gitflow
|
||||||
|
|
||||||
App will explore [] all git repos leaving on our machine.
|
**gitflow** is a CLI tool written in Go that explores every Git repository
|
||||||
it will ask for parent directory.
|
under a parent directory, scans each one's status, and presents the
|
||||||
will scan status of our repos
|
findings. It can rescan on a schedule — flagging repositories as they
|
||||||
and present it to the user.
|
change — and, when enabled, uses an integrated AI agent to suggest the next
|
||||||
app will scan repos in set interval [and present findings, integrated ai agent
|
actions for repositories that need attention.
|
||||||
will suggest next steps/actions]
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Repo discovery** — walks a directory tree and finds working trees,
|
||||||
|
linked worktrees/submodule checkouts, and bare repositories, without
|
||||||
|
descending into `.git` internals
|
||||||
|
- **Status scanning** — parses `git status --porcelain=v2` for each repo:
|
||||||
|
branch, detached HEAD, staged/modified/untracked files, ahead/behind
|
||||||
|
counts, stash count, and remote URL; scans run concurrently with a
|
||||||
|
bounded worker pool
|
||||||
|
- **Three output formats** — aligned colorized table (default), indented
|
||||||
|
JSON for scripting, and a compact one-line-per-repo view
|
||||||
|
- **Watch mode** — rescan on an interval with change detection, desktop
|
||||||
|
notifications, and graceful Ctrl-C shutdown
|
||||||
|
- **AI agent** — OpenAI, Ollama (local), or Anthropic providers suggest
|
||||||
|
concrete next steps (`commit`, `push`, `pull`, …) for repositories that
|
||||||
|
need attention
|
||||||
|
- **Custom rules** — user-defined threshold rules flag repositories (e.g.
|
||||||
|
"behind ≥ 10 commits" ⇒ `stale`)
|
||||||
|
- **Light/dark themes**, `NO_COLOR` support, and shell completions
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Requires Go 1.24+ and `git` on the `PATH`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go install gitea.oblak.solutions/dimitar/gitFlow/cmd/gitflow@latest
|
||||||
|
# or build from a checkout:
|
||||||
|
make build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```
|
||||||
|
gitflow scan [flags] Scan repositories under a directory once
|
||||||
|
gitflow watch [flags] Repeatedly scan on an interval
|
||||||
|
gitflow config Show the effective configuration
|
||||||
|
gitflow completion [shell] Generate shell completions (bash/zsh/fish/powershell)
|
||||||
|
gitflow version Print version information
|
||||||
|
```
|
||||||
|
|
||||||
|
When no `--dir` is given and stdin is a terminal, gitflow asks for the
|
||||||
|
parent directory to scan, per the README's original design.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# One-shot scan of ~/projects (prompts for the directory if omitted)
|
||||||
|
gitflow scan
|
||||||
|
|
||||||
|
# JSON output for scripting
|
||||||
|
gitflow scan -d ~/projects -f json
|
||||||
|
|
||||||
|
# Skip dependency directories and cap traversal depth
|
||||||
|
gitflow scan -d ~ --exclude node_modules --exclude vendor --max-depth 3
|
||||||
|
|
||||||
|
# Watch every 30 seconds, notifying when repositories change
|
||||||
|
gitflow watch -d ~/projects -i 30s --notify
|
||||||
|
|
||||||
|
# AI suggestions via OpenAI (exports OPENAI_API_KEY or ai.api_key_env)
|
||||||
|
gitflow scan -d ~/projects --ai
|
||||||
|
|
||||||
|
# AI suggestions via a local Ollama server
|
||||||
|
gitflow scan -d ~/projects --ai --ai-provider ollama --ai-model llama3.2
|
||||||
|
|
||||||
|
# Custom rules
|
||||||
|
gitflow scan -d ~/projects # with rules: in ~/.gitflow.yaml
|
||||||
|
|
||||||
|
# Shell completion
|
||||||
|
eval "$(gitflow completion bash)"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flags
|
||||||
|
|
||||||
|
| Flag | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `-d, --dir` | `.` | Parent directory to scan |
|
||||||
|
| `-i, --interval` | `0` | Rescan interval (e.g. `30s`, `5m`); `0` runs once |
|
||||||
|
| `-f, --format` | `table` | Output format: `table`, `json`, or `compact` |
|
||||||
|
| `--color` | `auto` | Color output: `auto`, `always`, or `never` |
|
||||||
|
| `--theme` | `dark` | Color theme: `dark` or `light` |
|
||||||
|
| `--exclude` | – | Glob patterns of directories to skip (repeatable) |
|
||||||
|
| `--max-depth` | `0` | Maximum directory depth to scan (`0` = unlimited) |
|
||||||
|
| `--workers` | `8` | Number of concurrent git scans |
|
||||||
|
| `--notify` | `false` | Desktop notifications on watch changes |
|
||||||
|
| `--ai` | `false` | Enable AI suggestions |
|
||||||
|
| `--ai-provider` | `openai` | `openai`, `ollama`, or `anthropic` |
|
||||||
|
| `--ai-model` | `gpt-4o` | Model name (provider default when empty) |
|
||||||
|
| `--ai-execute` | `false` | Run confirmed AI-suggested commands (experimental) |
|
||||||
|
|
||||||
|
### Status classes
|
||||||
|
|
||||||
|
`clean` · `modified` · `ahead` · `behind` · `diverged` · `detached` ·
|
||||||
|
`bare` · `error`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Settings are resolved with the precedence **flags > environment >
|
||||||
|
`~/.gitflow.yaml` > defaults**. Environment variables use a `GITFLOW_`
|
||||||
|
prefix with dots as underscores (e.g. `GITFLOW_FORMAT=json`,
|
||||||
|
`GITFLOW_AI_PROVIDER=ollama`). Use `GITFLOW_CONFIG=/path/to/file.yaml` to
|
||||||
|
point at a specific config file.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.gitflow.yaml
|
||||||
|
dir: ~/projects
|
||||||
|
interval: 5m
|
||||||
|
format: table
|
||||||
|
color: auto
|
||||||
|
theme: dark
|
||||||
|
notify: true
|
||||||
|
max_depth: 3
|
||||||
|
workers: 8
|
||||||
|
exclude:
|
||||||
|
- node_modules
|
||||||
|
- vendor
|
||||||
|
ai:
|
||||||
|
enabled: true
|
||||||
|
provider: openai # openai | ollama | anthropic
|
||||||
|
model: gpt-4o
|
||||||
|
api_key_env: OPENAI_API_KEY
|
||||||
|
base_url: "" # provider endpoint override
|
||||||
|
execute: false # run confirmed AI-suggested commands
|
||||||
|
rules:
|
||||||
|
- name: stale
|
||||||
|
field: behind # ahead | behind | stash | changes
|
||||||
|
op: ">=" # == | != | < | <= | > | >=
|
||||||
|
value: 10
|
||||||
|
label: stale
|
||||||
|
```
|
||||||
|
|
||||||
|
## AI agent
|
||||||
|
|
||||||
|
With `--ai` (or `ai.enabled`), gitflow renders a summary of the scan to the
|
||||||
|
configured provider and displays a `AI SUGGESTIONS` section below the
|
||||||
|
table, color-coded by priority:
|
||||||
|
|
||||||
|
```
|
||||||
|
AI SUGGESTIONS
|
||||||
|
REPOSITORY ACTION PRIORITY MESSAGE COMMAND
|
||||||
|
~/projects/web commit high commit the untracked file git add -A && git commit -m wip
|
||||||
|
```
|
||||||
|
|
||||||
|
Suggestions include `repo_path`, `action`, `message`, a concrete `command`
|
||||||
|
when one is safe, and a `priority` (low/medium/high). AI failures degrade
|
||||||
|
to a warning — a scan result is always shown. In watch mode the agent is
|
||||||
|
consulted only on the first frame and when something changed, so the
|
||||||
|
provider is not called on every interval.
|
||||||
|
|
||||||
|
### `--ai-execute` (experimental)
|
||||||
|
|
||||||
|
Runs the commands of AI suggestions **only** after explicit per-command
|
||||||
|
confirmation (`y/N`) and **only** for actions on an allowlist
|
||||||
|
(`commit`, `push`, `pull`, `stash`, `checkout`) — LLM output can never run
|
||||||
|
arbitrary shell commands. Treat this feature as experimental.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```
|
||||||
|
make build # build the binary (VERSION=... to stamp the version)
|
||||||
|
make test # run the full test suite
|
||||||
|
make lint # golangci-lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Layout:
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/gitflow/ CLI commands (scan, watch, config, version, completion)
|
||||||
|
internal/ai/ AI providers (OpenAI, Ollama, Anthropic) + prompt + execution
|
||||||
|
internal/app/ orchestration: discovery → scan → result
|
||||||
|
internal/config/ flags, env, and config-file resolution
|
||||||
|
internal/git/ porcelain v2 git wrapper
|
||||||
|
internal/notify/ desktop notifications (Linux/macOS; Windows no-op)
|
||||||
|
internal/presenter/ table / json / compact output + colors + themes
|
||||||
|
internal/rules/ user-defined threshold rules
|
||||||
|
internal/scanner/ repository discovery + concurrent status scanning
|
||||||
|
internal/scheduler/ interval loop with graceful shutdown
|
||||||
|
internal/version/ ldflags-injectable version
|
||||||
|
pkg/status/ shared domain model (RepoInfo, RepoStatus, ScanResult)
|
||||||
|
```
|
||||||
|
|
||||||
|
CI runs build, vet, and tests (with `-race`) on Go 1.24/1.26 plus
|
||||||
|
golangci-lint.
|
||||||
|
|
||||||
|
## Future work
|
||||||
|
|
||||||
|
- Interactive TUI (bubbletea) for navigating repositories and triggering
|
||||||
|
AI suggestions
|
||||||
|
- Webhook alerts (Slack/Discord) instead of desktop notifications
|
||||||
|
- `ai_execute` hardening and a wider command allowlist
|
||||||
|
- Fetch-history tracking to populate per-repo `last_fetch` metadata
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user