feat: phase 6 — polish: completions, notifications, themes, rules, docs

Close out the plan with the remaining polish items and a full README.

Shell completions (cmd/gitflow):
- New `completion [bash|zsh|fish|powershell]` command backed by cobra's
  generators, wired into the root command

Desktop notifications (internal/notify):
- Send() dispatches to notify-send (Linux) with an osascript fallback
  (macOS); Windows is a documented no-op for now; missing notifiers are
  silent, never errors
- watch --notify sends a notification listing repositories whose state
  changed since the previous frame

Color themes (internal/presenter):
- ThemeMode (dark/light) with ParseTheme; light uses bright ANSI variants
  (90-97) that stay legible on light backgrounds; threaded through the
  table, compact, and suggestions renderers; new --theme flag validated
  and dumped by config

Custom rules (internal/rules):
- Rule{name, field (ahead|behind|stash|changes), op (==,!=,<,<=,>,>=),
  value, label} with upfront validation in both Validate and Eval
- Config gains a `rules:` section (yaml/env only, no flag), validated at
  load; matches render as a "FLAGS (custom rules)" section via a new
  presenter.Flags renderer, shown in scan and watch frames

Docs:
- readme.md fully rewritten: features, install, usage, examples, flag
  table, status classes, configuration reference, AI agent behavior and
  --ai-execute guardrails, development layout, CI, and future work
- implementation.md gains an Implementation Progress section recording
  every phase branch and the deviations from the original plan

Multi-platform:
- Verified cross-compilation for windows/amd64 and darwin/arm64; the
  notify package is split behind build tags

Testing:
- rules: validation, operator semantics, Eval ordering, invalid-rule
  errors
- presenter: ParseTheme, light-theme bright codes (and absence of
  dark-theme codes), Flags rendering (empty = silent, matches render)
- config: rules loading from file, invalid-rule rejection, bad theme and
  bad provider rejection
- notify: no-op behaviour when no notifier is installed (skipped when one
  is, to avoid firing real notifications)

Verified: go build, go vet, go test -race (10 packages), gofmt clean,
windows/darwin cross-compile, completion generation, rules + light theme
smoke test, watch --notify graceful shutdown (exit 0, no orphans).
This commit is contained in:
dimitar 2026-08-02 08:52:50 +02:00
parent fe310188ac
commit 012fbf8ac5
20 changed files with 774 additions and 16 deletions

View File

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

View File

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

View File

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

View File

@ -291,3 +291,37 @@ Construct a prompt that includes:
| **M7 — Polish** | Phase 6: TUI, themes, completions, docs | 23 days |
**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) {
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)

View File

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

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) {
clearEnv(t)
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.
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)

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

View File

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

View File

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

View File

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

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