diff --git a/cmd/gitflow/root.go b/cmd/gitflow/root.go index b91d7eb..c3dd12c 100644 --- a/cmd/gitflow/root.go +++ b/cmd/gitflow/root.go @@ -29,10 +29,34 @@ repositories that need attention.`, newWatchCmd(), newConfigCmd(), newVersionCmd(), + newCompletionCmd(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 // scans and watch loops shut down gracefully. func signalContext() (context.Context, context.CancelFunc) { diff --git a/cmd/gitflow/scan.go b/cmd/gitflow/scan.go index dc1cedd..1a8d38d 100644 --- a/cmd/gitflow/scan.go +++ b/cmd/gitflow/scan.go @@ -11,6 +11,7 @@ import ( "gitea.oblak.solutions/dimitar/gitFlow/internal/app" "gitea.oblak.solutions/dimitar/gitFlow/internal/config" "gitea.oblak.solutions/dimitar/gitFlow/internal/presenter" + "gitea.oblak.solutions/dimitar/gitFlow/internal/rules" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" ) @@ -45,7 +46,10 @@ func newScanCmd() *cobra.Command { 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 { return err } @@ -55,6 +59,9 @@ func newScanCmd() *cobra.Command { if cfg.AI.Enabled && cfg.Format != "json" { renderSuggestions(ctx, cfg, opts, result) } + if err := renderRuleFlags(opts, cfg, result); err != nil { + return err + } return nil }, } @@ -92,6 +99,18 @@ func confirmSuggestion(s ai.Suggestion) (bool, error) { 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. func newConfigCmd() *cobra.Command { cmd := &cobra.Command{ diff --git a/cmd/gitflow/watch.go b/cmd/gitflow/watch.go index ab770f3..a60cc14 100644 --- a/cmd/gitflow/watch.go +++ b/cmd/gitflow/watch.go @@ -5,12 +5,14 @@ import ( "errors" "fmt" "os" + "strings" "time" "github.com/spf13/cobra" "gitea.oblak.solutions/dimitar/gitFlow/internal/app" "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/scheduler" "gitea.oblak.solutions/dimitar/gitFlow/pkg/status" @@ -44,7 +46,10 @@ func newWatchCmd() *cobra.Command { if err != nil { 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 first := true @@ -62,6 +67,12 @@ func newWatchCmd() *cobra.Command { if cfg.AI.Enabled && (first || len(status.Changed(prev, result)) > 0) { renderSuggestions(ctx, cfg, opts, result) } + if err := renderRuleFlags(opts, cfg, result); err != nil { + return err + } + if !first && cfg.Notify { + notifyChanges(prev, result) + } prev = result first = false return nil @@ -75,6 +86,20 @@ func newWatchCmd() *cobra.Command { 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 // output is not a terminal), renders the scan, and prints a footer with // changed repositories and the next scan time. diff --git a/implementation.md b/implementation.md index 52c1bf7..3f6547f 100644 --- a/implementation.md +++ b/implementation.md @@ -291,3 +291,37 @@ Construct a prompt that includes: | **M7 — Polish** | Phase 6: TUI, themes, completions, docs | 2–3 days | **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. diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e5306fa..4f536fc 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -23,7 +23,7 @@ func initGitRepo(t *testing.T, path string) { } 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 { t.Error("New(bad config) succeeded, want error") } @@ -34,7 +34,7 @@ func TestScanOnce(t *testing.T) { repo := filepath.Join(root, "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) if err != nil { t.Fatalf("New: %v", err) @@ -63,7 +63,7 @@ func TestScanOnce(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) if err != nil { t.Fatalf("New: %v", err) diff --git a/internal/config/config.go b/internal/config/config.go index 6cffdb5..272140c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -15,6 +15,8 @@ import ( "github.com/spf13/pflag" "github.com/spf13/viper" "gopkg.in/yaml.v3" + + "gitea.oblak.solutions/dimitar/gitFlow/internal/rules" ) // AIConfig holds AI agent settings. @@ -33,9 +35,12 @@ type Config struct { Interval time.Duration `yaml:"interval"` Format string `yaml:"format"` Color string `yaml:"color"` + Theme string `yaml:"theme"` + Notify bool `yaml:"notify"` Exclude []string `yaml:"exclude,omitempty"` MaxDepth int `yaml:"max_depth"` Workers int `yaml:"workers"` + Rules []rules.Rule `yaml:"rules,omitempty"` AI AIConfig `yaml:"ai"` ConfigFile string `yaml:"-"` // path of the loaded config file, if any } @@ -49,6 +54,8 @@ var flagKeys = []struct{ flag, key string }{ {"exclude", "exclude"}, {"max-depth", "max_depth"}, {"workers", "workers"}, + {"notify", "notify"}, + {"theme", "theme"}, {"ai", "ai.enabled"}, {"ai-provider", "ai.provider"}, {"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.Int("max-depth", 0, "maximum directory depth to scan (0 = unlimited)") 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.String("ai-provider", "openai", "AI provider: openai, ollama, or anthropic") f.String("ai-model", "gpt-4o", "AI model name") @@ -97,6 +106,8 @@ func Load(flags *pflag.FlagSet) (*Config, error) { Interval: v.GetDuration("interval"), Format: v.GetString("format"), Color: v.GetString("color"), + Theme: v.GetString("theme"), + Notify: v.GetBool("notify"), Exclude: v.GetStringSlice("exclude"), MaxDepth: v.GetInt("max_depth"), Workers: v.GetInt("workers"), @@ -113,6 +124,17 @@ func Load(flags *pflag.FlagSet) (*Config, error) { if err := cfg.Validate(); err != nil { 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 } @@ -124,6 +146,8 @@ func applyDefaults(v *viper.Viper) { v.SetDefault("exclude", []string{}) v.SetDefault("max_depth", 0) v.SetDefault("workers", 8) + v.SetDefault("notify", false) + v.SetDefault("theme", "dark") v.SetDefault("ai.enabled", false) v.SetDefault("ai.provider", "openai") v.SetDefault("ai.model", "gpt-4o") @@ -176,6 +200,11 @@ func (c *Config) Validate() error { default: 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 { return errors.New("config: interval must not be negative") } @@ -207,6 +236,9 @@ func (c *Config) Dump() (string, error) { "exclude": c.Exclude, "max_depth": c.MaxDepth, "workers": c.Workers, + "notify": c.Notify, + "theme": c.Theme, + "rules": c.Rules, "ai": map[string]any{ "enabled": c.AI.Enabled, "provider": c.AI.Provider, diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 25b045e..04ee294 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) { clearEnv(t) home := t.TempDir() diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..6d92010 --- /dev/null +++ b/internal/notify/notify.go @@ -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) +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..1bcb841 --- /dev/null +++ b/internal/notify/notify_test.go @@ -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) + } +} diff --git a/internal/notify/notify_unix.go b/internal/notify/notify_unix.go new file mode 100644 index 0000000..12ed2e9 --- /dev/null +++ b/internal/notify/notify_unix.go @@ -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 +} diff --git a/internal/notify/notify_windows.go b/internal/notify/notify_windows.go new file mode 100644 index 0000000..222d1aa --- /dev/null +++ b/internal/notify/notify_windows.go @@ -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 +} diff --git a/internal/presenter/compact.go b/internal/presenter/compact.go index f786f67..8b46798 100644 --- a/internal/presenter/compact.go +++ b/internal/presenter/compact.go @@ -15,7 +15,7 @@ type CompactFormatter struct { // Format implements Formatter. 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 { sym := statusSymbol(repo.Status) line := r.paint(repo.Status, sym) + " " + shortPath(repo.Path) diff --git a/internal/presenter/flags.go b/internal/presenter/flags.go new file mode 100644 index 0000000..02cce86 --- /dev/null +++ b/internal/presenter/flags.go @@ -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() +} diff --git a/internal/presenter/presenter.go b/internal/presenter/presenter.go index 71eb069..63febf8 100644 --- a/internal/presenter/presenter.go +++ b/internal/presenter/presenter.go @@ -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. type Options struct { Color ColorMode + Theme ThemeMode } // Formatter renders a ScanResult to a writer. @@ -80,7 +98,10 @@ type renderer struct { 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 switch mode { case ColorAlways: @@ -93,6 +114,17 @@ func newRenderer(mode ColorMode, w io.Writer) renderer { if !useColor { 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{ green: "\x1b[32m", yellow: "\x1b[33m", diff --git a/internal/presenter/presenter_test.go b/internal/presenter/presenter_test.go index 256c38e..7a14bd5 100644 --- a/internal/presenter/presenter_test.go +++ b/internal/presenter/presenter_test.go @@ -8,6 +8,7 @@ import ( "time" "gitea.oblak.solutions/dimitar/gitFlow/internal/ai" + "gitea.oblak.solutions/dimitar/gitFlow/internal/rules" "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) { sugs := []ai.Suggestion{ {RepoPath: "/home/user/projects/web", Action: "commit", Message: "commit your work", Command: "git add -A && git commit -m wip", Priority: 2}, diff --git a/internal/presenter/suggestions.go b/internal/presenter/suggestions.go index d904c21..623e31e 100644 --- a/internal/presenter/suggestions.go +++ b/internal/presenter/suggestions.go @@ -16,7 +16,7 @@ func Suggestions(w io.Writer, opts Options, suggestions []ai.Suggestion) error { return nil } - r := newRenderer(opts.Color, w) + r := newRendererTheme(opts.Color, opts.Theme, w) fmt.Fprintln(w, "\nAI SUGGESTIONS") tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) fmt.Fprintln(tw, "REPOSITORY\tACTION\tPRIORITY\tMESSAGE\tCOMMAND") diff --git a/internal/presenter/table.go b/internal/presenter/table.go index 106c829..8a21f5a 100644 --- a/internal/presenter/table.go +++ b/internal/presenter/table.go @@ -18,7 +18,7 @@ type TableFormatter struct { // Format implements Formatter. 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) fmt.Fprintln(tw, "REPOSITORY\tBRANCH\tSTATUS\tAHEAD/BEHIND\tCHANGES\tSTASH") diff --git a/internal/rules/rules.go b/internal/rules/rules.go new file mode 100644 index 0000000..e7654cf --- /dev/null +++ b/internal/rules/rules.go @@ -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) +} diff --git a/internal/rules/rules_test.go b/internal/rules/rules_test.go new file mode 100644 index 0000000..be49e67 --- /dev/null +++ b/internal/rules/rules_test.go @@ -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") + } +} diff --git a/readme.md b/readme.md index 6288545..3759db3 100644 --- a/readme.md +++ b/readme.md @@ -1,8 +1,198 @@ -# git flow is CLI app writen go. +# gitflow -App will explore [] all git repos leaving on our machine. -it will ask for parent directory. -will scan status of our repos -and present it to the user. -app will scan repos in set interval [and present findings, integrated ai agent - will suggest next steps/actions] +**gitflow** is a CLI tool written in Go that explores every Git repository +under a parent directory, scans each one's status, and presents the +findings. It can rescan on a schedule — flagging repositories as they +change — and, when enabled, uses an integrated AI agent to suggest the next +actions for repositories that need attention. + +## 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