merge: phase 6 polish into main
Some checks are pending
CI / Test (Go ${{ matrix.go }}) (1.24) (push) Waiting to run
CI / Test (Go ${{ matrix.go }}) (1.26) (push) Waiting to run
CI / Lint (push) Waiting to run

This commit is contained in:
dimitar 2026-08-02 08:52:53 +02:00
commit 982012c867
20 changed files with 774 additions and 16 deletions

View File

@ -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) {

View File

@ -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{

View File

@ -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.

View File

@ -291,3 +291,37 @@ Construct a prompt that includes:
| **M7 — Polish** | Phase 6: TUI, themes, completions, docs | 23 days | | **M7 — Polish** | Phase 6: TUI, themes, completions, docs | 23 days |
**Total estimated effort**: ~710 days for a solid v1.0 **Total estimated effort**: ~710 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.

View File

@ -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)

View File

@ -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,

View File

@ -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()

View 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)
}

View 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)
}
}

View 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
}

View 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
}

View File

@ -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)

View 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()
}

View File

@ -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",

View File

@ -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},

View File

@ -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")

View File

@ -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
View 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)
}

View 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
View File

@ -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