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:
parent
fe310188ac
commit
012fbf8ac5
@ -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) {
|
||||
|
||||
@ -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{
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
9
internal/notify/notify.go
Normal file
9
internal/notify/notify.go
Normal file
@ -0,0 +1,9 @@
|
||||
// Package notify sends desktop notifications when repositories change while
|
||||
// watching. Platforms without a notifier are silent no-ops, never errors.
|
||||
package notify
|
||||
|
||||
// Send posts a desktop notification. It returns nil when no notifier is
|
||||
// available; a non-nil error means a notifier was found but failed.
|
||||
func Send(title, body string) error {
|
||||
return send(title, body)
|
||||
}
|
||||
27
internal/notify/notify_test.go
Normal file
27
internal/notify/notify_test.go
Normal file
@ -0,0 +1,27 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// hasNotifier reports whether a desktop notifier is installed, so tests can
|
||||
// avoid firing real notifications on developer machines.
|
||||
func hasNotifier() bool {
|
||||
for _, bin := range []string{"notify-send", "osascript"} {
|
||||
if _, err := exec.LookPath(bin); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestSendWithoutNotifier(t *testing.T) {
|
||||
if hasNotifier() {
|
||||
t.Skip("a desktop notifier is installed; skipping to avoid firing notifications")
|
||||
}
|
||||
// Without a notifier, Send must be a silent no-op.
|
||||
if err := Send("gitflow test", "no notifier available"); err != nil {
|
||||
t.Errorf("Send: %v", err)
|
||||
}
|
||||
}
|
||||
21
internal/notify/notify_unix.go
Normal file
21
internal/notify/notify_unix.go
Normal file
@ -0,0 +1,21 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// send prefers notify-send (Linux) and falls back to osascript (macOS).
|
||||
// If neither is installed the notification is dropped silently.
|
||||
func send(title, body string) error {
|
||||
if _, err := exec.LookPath("notify-send"); err == nil {
|
||||
return exec.Command("notify-send", "-a", "gitflow", "--", title, body).Run()
|
||||
}
|
||||
if _, err := exec.LookPath("osascript"); err == nil {
|
||||
script := fmt.Sprintf("display notification %q with title %q", body, title)
|
||||
return exec.Command("osascript", "-e", script).Run()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
9
internal/notify/notify_windows.go
Normal file
9
internal/notify/notify_windows.go
Normal file
@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
package notify
|
||||
|
||||
// send is a no-op on Windows for now; a PowerShell toast bridge can be
|
||||
// added later.
|
||||
func send(title, body string) error {
|
||||
return nil
|
||||
}
|
||||
@ -15,7 +15,7 @@ type CompactFormatter struct {
|
||||
|
||||
// Format implements Formatter.
|
||||
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)
|
||||
|
||||
25
internal/presenter/flags.go
Normal file
25
internal/presenter/flags.go
Normal file
@ -0,0 +1,25 @@
|
||||
package presenter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
|
||||
)
|
||||
|
||||
// Flags renders user-rule matches below a scan result.
|
||||
func Flags(w io.Writer, opts Options, flags []rules.Flag) error {
|
||||
if len(flags) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := newRendererTheme(opts.Color, opts.Theme, w)
|
||||
fmt.Fprintln(w, "\nFLAGS (custom rules)")
|
||||
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "REPOSITORY\tFLAG")
|
||||
for _, f := range flags {
|
||||
fmt.Fprintf(tw, "%s\t%s\n", shortPath(f.RepoPath), r.yellow+f.Label+r.reset)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@ -35,9 +35,27 @@ func ParseColorMode(s string) ColorMode {
|
||||
}
|
||||
}
|
||||
|
||||
// ThemeMode selects the ANSI palette used for colored output.
|
||||
type ThemeMode int
|
||||
|
||||
const (
|
||||
ThemeDark ThemeMode = iota // classic dark-terminal colors
|
||||
ThemeLight // brighter hues for light backgrounds
|
||||
)
|
||||
|
||||
// ParseTheme converts a CLI string to a ThemeMode; anything unknown falls
|
||||
// back to dark.
|
||||
func ParseTheme(s string) ThemeMode {
|
||||
if s == "light" {
|
||||
return ThemeLight
|
||||
}
|
||||
return ThemeDark
|
||||
}
|
||||
|
||||
// Options control rendering behaviour.
|
||||
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",
|
||||
|
||||
@ -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},
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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
104
internal/rules/rules.go
Normal file
@ -0,0 +1,104 @@
|
||||
// Package rules evaluates user-defined threshold rules against a scan
|
||||
// result, producing labels for repositories that match (e.g. flag a branch
|
||||
// as "stale" when it is 10+ commits behind).
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
// Rule flags a repository when a numeric field crosses a threshold.
|
||||
type Rule struct {
|
||||
Name string `yaml:"name"` // descriptive name, for config output
|
||||
Field string `yaml:"field"` // ahead, behind, stash, or changes
|
||||
Op string `yaml:"op"` // ==, !=, <, <=, >, >=
|
||||
Value int `yaml:"value"` // threshold to compare against
|
||||
Label string `yaml:"label"` // flag label, e.g. "critical"
|
||||
}
|
||||
|
||||
// Validate checks that the rule can be evaluated.
|
||||
func (r Rule) Validate() error {
|
||||
switch r.Field {
|
||||
case "ahead", "behind", "stash", "changes":
|
||||
default:
|
||||
return fmt.Errorf("rules: %s: unknown field %q (want ahead, behind, stash, or changes)", r.Name, r.Field)
|
||||
}
|
||||
switch r.Op {
|
||||
case "==", "!=", "<", "<=", ">", ">=":
|
||||
default:
|
||||
return fmt.Errorf("rules: %s: unknown operator %q (want ==, !=, <, <=, >, >=)", r.Name, r.Op)
|
||||
}
|
||||
if r.Label == "" {
|
||||
return fmt.Errorf("rules: %s: label must not be empty", r.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flag is a rule match on a repository.
|
||||
type Flag struct {
|
||||
RepoPath string
|
||||
Label string
|
||||
}
|
||||
|
||||
// Matches reports whether the rule matches the repository.
|
||||
func (r Rule) Matches(repo status.RepoInfo) (bool, error) {
|
||||
v, err := fieldValue(r.Field, repo)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch r.Op {
|
||||
case "==":
|
||||
return v == r.Value, nil
|
||||
case "!=":
|
||||
return v != r.Value, nil
|
||||
case "<":
|
||||
return v < r.Value, nil
|
||||
case "<=":
|
||||
return v <= r.Value, nil
|
||||
case ">":
|
||||
return v > r.Value, nil
|
||||
case ">=":
|
||||
return v >= r.Value, nil
|
||||
}
|
||||
return false, fmt.Errorf("rules: %s: unknown operator %q", r.Name, r.Op)
|
||||
}
|
||||
|
||||
// Eval applies the rules to the result and returns every match, in scan
|
||||
// order. Rules are validated up front; an invalid rule is an error.
|
||||
func Eval(rules []Rule, result status.ScanResult) ([]Flag, error) {
|
||||
for _, rule := range rules {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var out []Flag
|
||||
for _, rule := range rules {
|
||||
for _, repo := range result.Repos {
|
||||
ok, err := rule.Matches(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
out = append(out, Flag{RepoPath: repo.Path, Label: rule.Label})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fieldValue extracts the numeric field a rule compares against.
|
||||
func fieldValue(field string, repo status.RepoInfo) (int, error) {
|
||||
switch field {
|
||||
case "ahead":
|
||||
return repo.AheadBy, nil
|
||||
case "behind":
|
||||
return repo.BehindBy, nil
|
||||
case "stash":
|
||||
return repo.StashCount, nil
|
||||
case "changes":
|
||||
return repo.FileCount(), nil
|
||||
}
|
||||
return 0, fmt.Errorf("rules: unknown field %q", field)
|
||||
}
|
||||
84
internal/rules/rules_test.go
Normal file
84
internal/rules/rules_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
func TestRuleValidate(t *testing.T) {
|
||||
good := Rule{Name: "stale", Field: "behind", Op: ">=", Value: 10, Label: "stale"}
|
||||
if err := good.Validate(); err != nil {
|
||||
t.Errorf("Validate(good): %v", err)
|
||||
}
|
||||
bad := []Rule{
|
||||
{Name: "a", Field: "nope", Op: ">=", Label: "x"},
|
||||
{Name: "b", Field: "behind", Op: "~", Label: "x"},
|
||||
{Name: "c", Field: "behind", Op: ">=", Label: ""},
|
||||
}
|
||||
for _, r := range bad {
|
||||
if err := r.Validate(); err == nil {
|
||||
t.Errorf("Validate(%+v) succeeded, want error", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleMatches(t *testing.T) {
|
||||
repo := status.RepoInfo{BehindBy: 12, StashCount: 1, ModifiedFiles: []string{"a"}}
|
||||
|
||||
cases := []struct {
|
||||
rule Rule
|
||||
want bool
|
||||
}{
|
||||
{Rule{Field: "behind", Op: ">=", Value: 10}, true},
|
||||
{Rule{Field: "behind", Op: ">=", Value: 13}, false},
|
||||
{Rule{Field: "behind", Op: "==", Value: 12}, true},
|
||||
{Rule{Field: "stash", Op: ">", Value: 0}, true},
|
||||
{Rule{Field: "stash", Op: "==", Value: 0}, false},
|
||||
{Rule{Field: "changes", Op: ">=", Value: 1}, true},
|
||||
{Rule{Field: "ahead", Op: "==", Value: 0}, true},
|
||||
{Rule{Field: "changes", Op: "!=", Value: 3}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := tc.rule.Matches(repo)
|
||||
if err != nil {
|
||||
t.Errorf("Matches(%+v): %v", tc.rule, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("Matches(%+v) = %v, want %v", tc.rule, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEval(t *testing.T) {
|
||||
rules := []Rule{
|
||||
{Name: "stale", Field: "behind", Op: ">=", Value: 10, Label: "stale"},
|
||||
{Name: "dirty", Field: "changes", Op: ">=", Value: 1, Label: "dirty"},
|
||||
}
|
||||
result := status.ScanResult{Repos: []status.RepoInfo{
|
||||
{Path: "/a", BehindBy: 15},
|
||||
{Path: "/b", ModifiedFiles: []string{"x"}},
|
||||
{Path: "/c"},
|
||||
}}
|
||||
flags, err := Eval(rules, result)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval: %v", err)
|
||||
}
|
||||
want := []Flag{{RepoPath: "/a", Label: "stale"}, {RepoPath: "/b", Label: "dirty"}}
|
||||
if len(flags) != len(want) {
|
||||
t.Fatalf("Eval returned %d flags, want %d: %+v", len(flags), len(want), flags)
|
||||
}
|
||||
for i, f := range flags {
|
||||
if f != want[i] {
|
||||
t.Errorf("flag[%d] = %+v, want %+v", i, f, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalInvalidRule(t *testing.T) {
|
||||
rules := []Rule{{Name: "bad", Field: "nope", Op: ">=", Label: "x"}}
|
||||
if _, err := Eval(rules, status.ScanResult{}); err == nil {
|
||||
t.Error("Eval(invalid rule) succeeded, want error")
|
||||
}
|
||||
}
|
||||
204
readme.md
204
readme.md
@ -1,8 +1,198 @@
|
||||
# git flow is CLI app writen go.
|
||||
# gitflow
|
||||
|
||||
App will explore [] all git repos leaving on our machine.
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user