Compare commits

...

14 Commits

Author SHA1 Message Date
982012c867 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
2026-08-02 08:52:53 +02:00
012fbf8ac5 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).
2026-08-02 08:52:50 +02:00
fe310188ac merge: phase 5 AI integration into main 2026-08-02 08:48:30 +02:00
aee63bf321 feat: phase 5 — AI agent integration (OpenAI, Ollama, Anthropic)
Add the AI layer that turns scan results into suggested next actions.

Provider model (internal/ai):
- Provider interface with Name() and Suggest(ctx, ScanResult) returning
  []Suggestion (repo_path, action, message, command, priority 0-2)
- NewProvider factory resolves the configured provider and enforces that
  cloud providers have their API key in the configured env var; Ollama
  needs no key
- OpenAIProvider: Chat Completions with response_format json_object
- OllamaProvider: local /api/chat with format:json for structured output
- AnthropicProvider: Messages API with system prompt and key/version
  headers; all three fall back to provider-appropriate defaults for
  base_url and model
- Shared client: 60s timeout, 4 MiB response cap, JSON encode/decode,
  HTTP error surfaces the upstream status and body

Prompt design (BuildPrompt):
- Renders the full repository status table (name/status/branch/ahead/
  behind/changes) plus strict output rules: exact suggestion schema,
  allowed actions, smallest-safe-step guidance, no invented repositories,
  return [] when healthy

Parsing (parseSuggestions):
- Tolerates ```json fences and surrounding prose, clamps priorities to
  0-2, caps the result, and rejects replies without a JSON array

Suggestion display (presenter.Suggestions):
- "AI SUGGESTIONS" table (repository/action/priority/message/command)
  rendered below the scan table with priority color-coded (red/yellow/
  green); empty results say all repositories are healthy

Guarded execution (--ai-execute, experimental):
- RunConfirmed executes a suggestion's command inside its repository only
  after explicit per-command y/N confirmation, and only for actions on an
  allowlist (commit/push/pull/stash/checkout) so LLM output can never run
  arbitrary shell commands; cancellation aborts remaining suggestions

CLI wiring:
- scan: AI block after the table when --ai is set and format is not json
  (JSON streams stay machine-readable); failures degrade to warnings
- watch: AI is queried only on the first frame and when something changed
  since the previous frame, to avoid hammering the provider every interval
- New --ai-execute flag and provider validation in config (openai/ollama/
  anthropic), included in the config dump

Testing:
- httptest-based provider tests verifying request shape (model, auth
  headers, path), response parsing, API error bodies, HTTP failures, and
  cancellation
- Parse tests: plain/fenced/prose replies, empty arrays, garbage,
  truncated JSON, priority clamping
- Execution tests: unsafe actions and empty commands never run, declined
  confirmations are skipped, confirmed commands execute in the repo dir,
  failing commands surface errors
- Presenter suggestion table and empty-state tests

Verified: go build, go vet, go test -race, gofmt clean; end-to-end smoke
test against a local fake Ollama server (request shape confirmed, table +
suggestions rendered) and the missing-API-key warning path.
2026-08-02 08:48:27 +02:00
6e945fc13b merge: phase 4 scheduler into main 2026-08-02 08:44:33 +02:00
0c9c07e8de feat: phase 4 — periodic scanning (watch mode) with change detection
Add the scheduling layer and the watch command so gitflow can rescan
repositories on an interval and surface state changes.

Scheduler (internal/scheduler):
- Scheduler runs a callback immediately and then on a time.Ticker until
  the context is cancelled; interval <= 0 means a single run
- Graceful shutdown: cancellation is never reported as an error (checked
  on the first run and after every run), so Ctrl-C / SIGTERM exit cleanly
- A non-cancellation run error stops the loop and is propagated

Change detection (pkg/status):
- Changed(prev, next) compares snapshots per repository path — status,
  branch, detached flag, ahead/behind, stash count, and file counts —
  and returns the repositories whose observable state differs, including
  repositories that newly appear

Watch command (cmd/gitflow):
- newWatchCmd validates that --interval is positive, prompts for the
  parent directory on a TTY when --dir is absent, and drives the
  scheduler with ScanOnce
- Each frame clears the screen on a terminal (or prints a RFC3339 header
  when output is piped, so watch doubles as a lightweight logger) and
  renders through the presenter
- Footer shows changed repositories since the previous frame ("▲ name:
  status") and the next scan time; JSON format emits one document per
  frame for scripting
- SIGINT/SIGTERM handled via signalContext for a clean stop

Testing:
- Scheduler: single run, periodic repetition, stop-on-error, pre-cancelled
  context, and cancellation-during-run (no error reported)
- status.Changed: unchanged repos ignored; status, file-count, and
  newly-appeared repos detected

Verified: go build, go vet, go test -race, gofmt clean; manual watch
smoke test over a scratch directory with 1s interval — frames render,
SIGINT and SIGTERM both exit 0 with no orphaned processes.
2026-08-02 08:44:29 +02:00
5fe5eb5af8 merge: phase 3 presentation into main 2026-08-02 08:41:21 +02:00
c9ff44298c feat: phase 3 — presentation layer (table, JSON, compact)
Replace the interim renderers with a dedicated presenter package that
formats scan results in three ways.

Presenter (internal/presenter):
- Formatter interface with Present()/For() dispatch on format name:
  table, json, compact
- TableFormatter: aligned tabwriter table with REPOSITORY / BRANCH /
  STATUS / AHEAD-BEHIND / CHANGES / STASH columns, per-repo change
  summaries like "2M 1U", error details inline, and a colored summary
  line ("N repos | N clean | N need attention | N errors")
- JSONFormatter: indented document with scanned_at, parent_dir, repos,
  and an aggregate summary for scripting; statuses render as string
  labels ("modified") instead of raw integers
- CompactFormatter: one line per repo with color-coded status symbols
  (✓ ✗ ↑ ↓ ⇄ ◉ ▢ !) plus branch, ahead/behind, and change counts
- Color handling: auto/always/never modes, TTY detection, and the
  NO_COLOR convention (explicit --color=always still wins); paths have
  $HOME collapsed to "~" in table and compact views

Domain model (pkg/status):
- JSON tags on RepoInfo/ScanResult/Summary for clean field names
- RepoStatus now marshals to its string label and unmarshals from both
  string labels and numeric values, so JSON output round-trips

Configuration (internal/config):
- New --color flag (auto/always/never) validated in Load and included in
  the config dump; keyed as "color" in viper

CLI (cmd/gitflow):
- scan now routes through presenter.Present with the resolved color mode;
  the interim renderers are removed

Testing:
- Table content (columns, change summaries, error text, summary line) and
  absence of escape codes with ColorNever
- ColorAlways emits ANSI codes even under NO_COLOR; auto stays clean on
  non-terminal writers
- JSON decodes back into the domain types (string statuses round-trip)
- Compact symbols and counts; unknown formats rejected
- RepoStatus JSON round trip covers every status

Verified: go build, go vet, go test -race, gofmt clean; manual smoke of
table / compact / forced-color / JSON output against a scratch directory.
2026-08-02 08:41:18 +02:00
baa2b8d910 merge: phase 2 CLI & config into main 2026-08-02 08:38:58 +02:00
d52e0715f2 feat: phase 2 — CLI commands and configuration resolution
Add the Cobra-based command surface and the flag/env/config-file
resolution layer that all commands share.

Configuration (internal/config):
- Load() resolves settings with the documented precedence flags > env >
  config file > defaults, via viper: GITFLOW_-prefixed env vars with
  dot-to-underscore mapping, plus ~/.gitflow.yaml (or $GITFLOW_CONFIG)
- RegisterFlags/NewFlagSet own the flag definitions so every command and
  the tests share a single source of truth
- Config/Validate/Dump cover dir, interval, format (table/json/compact),
  exclude globs, max depth, worker count, and the AI block (enabled,
  provider, model, api_key_env, base_url); ConfigFile records the loaded
  path; Dump renders the effective config as human-readable YAML with the
  interval as a duration string

App orchestration (internal/app):
- New() validates the configuration at the boundary (fail fast)
- ScanOnce() runs discovery then a concurrent status scan, warning on
  stderr and continuing when discovery is only partially successful
  (e.g. permission-denied subtrees), and bundles everything into a
  ScanResult

CLI (cmd/gitflow):
- root command with scan / config / version subcommands
- scan: resolves config, prompts for the parent directory when stdin is a
  TTY and --dir was not given (per the README), runs a single pass, and
  renders the result — interim plain/JSON output until phase 3 lands the
  presenter package
- config: prints the effective configuration
- version: prints the build version (ldflags-injectable)
- signalContext() wires SIGINT/SIGTERM into a cancellable context for
  graceful shutdown

Testing:
- config: defaults, flag overrides, env overrides, flag-beats-env
  precedence, config file loading (including duration and slice values),
  GITFLOW_CONFIG path override, validation failures, and Dump output
- app: config validation on New, end-to-end ScanOnce over a real temp
  repo, and missing-directory errors

Verified: go build, go vet, go test -race, gofmt clean; manual smoke of
`gitflow version`, `gitflow config`, and `gitflow scan -d <dir>` against a
scratch directory with a dirty repo.
2026-08-02 08:38:55 +02:00
263b02b846 merge: phase 1 discovery & scanning into main 2026-08-02 08:35:44 +02:00
ae23019277 feat: phase 1 — repository discovery and status scanning
Implement the core data layer that everything else builds on: walking a
parent directory for Git repositories and snapshotting each repository's
state via porcelain commands.

Domain model (pkg/status):
- RepoStatus enum with zero value = StatusUnknown (invalid/unset), covering
  clean / modified / ahead / behind / diverged / detached / bare / error
- RepoInfo snapshot: path, name, remote URL, branch, detached flag, staged /
  modified / untracked file lists, ahead/behind counts, stash count, error
- ScanResult with computed Summary counters and NeedsAttention filter

Git wrapper (internal/git):
- Executor built with functional options (binary, timeout, env), defaults to
  "git" with a 10s per-command timeout so a hung repository can never stall
  a scan; context cancellation honoured via exec.CommandContext
- Status() validates with rev-parse --is-bare-repository (bare repos are
  reported as StatusBare and skipped), then parses `git status
  --porcelain=v2 --branch -z`
- Parsing uses the NUL-separated v2 format so paths with spaces, quotes,
  and tabs survive verbatim (no C-quoting to decode); rename records (type
  2) and ahead/behind lines (# branch.ab) are handled, unknown tokens are
  ignored for forward compatibility
- Best-effort remote URL (git config --get-regexp) and stash count

Discovery (internal/scanner/discover.go):
- WalkDir-based traversal that never descends into .git internals
- Detects working trees via .git directory or .git pointer file (linked
  worktrees, submodule checkouts) and bare repos via a *.git directory
  carrying its own HEAD/objects/refs
- WithExclude glob patterns (matched against full path and base name) and
  WithMaxDepth depth limiting as functional options
- Walk errors (e.g. permission denied) are aggregated and returned
  alongside partial results via errors.Join; cancellation is propagated

Scanner (internal/scanner/scanner.go):
- Concurrent status scanning with a bounded worker pool (errgroup +
  SetLimit, default 8 workers)
- Per-repo failures become StatusError entries instead of aborting the
  pass; a cancelled context aborts the whole scan

Testing:
- Table-driven parser tests with canned NUL-separated porcelain output
- Integration tests against a real git binary (clean / modified / staged /
  stashed / detached / bare / non-repo)
- Discovery tests over a fixture tree with nested repos, a bare repo, a
  linked worktree, and an exclusion target
- Scanner tests for mixed success/failure, bounded concurrency, and
  cancellation; everything runs under -race

Note: LastFetch from the original plan was dropped — the only reliable
source is a reflog of the remote-tracking ref, which does not exist on
fresh clones, so it would always be misleading. The model remains
extensible if a fetch-history feature is wanted later.
2026-08-02 08:35:41 +02:00
48c6438ae7 merge: phase 0 scaffolding into main 2026-08-02 08:30:25 +02:00
9761adf3f9 feat: phase 0 — project scaffolding and build tooling
Bootstrap the gitflow module so subsequent phases build on a verified,
linted foundation.

- go.mod: initialize module gitea.oblak.solutions/dimitar/gitFlow at go 1.24
- cmd/gitflow/main.go: minimal entrypoint that prints the version string;
  this is a placeholder that Phase 2 (CLI & config) replaces with Cobra
- internal/version: version metadata injectable at build time via ldflags,
  with a Makefile target wiring VERSION through
- Makefile: build / test / fmt / vet / lint / install / clean targets with
  ldflags version injection
- .golangci.yml: enable errcheck, govet, staticcheck, gosimple, ineffassign,
  unused, misspell, gofmt, goimports
- .github/workflows/ci.yml: GitHub Actions CI running build + vet + tests
  (with -race) across Go 1.24/1.26, plus a golangci-lint job
- .gitignore: build artifacts, test outputs, editor cruft, and local config
  overrides (never committed)

Verified locally: go build, go vet, gofmt clean, binary prints version.
2026-08-02 08:30:23 +02:00
52 changed files with 4810 additions and 7 deletions

37
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
name: Test (Go ${{ matrix.go }})
runs-on: ubuntu-latest
strategy:
matrix:
go: ["1.24", "1.26"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Build
run: go build ./...
- name: Vet
run: go vet ./...
- name: Test
run: go test -race ./...
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: "1.26"
- uses: golangci/golangci-lint-action@v6
with:
version: latest

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
# Binaries and build artifacts
/gitflow
/dist/
*.exe
# Test artifacts
*.test
coverage.out
# Editor / OS cruft
.DS_Store
.idea/
.vscode/
*.swp
# Local config overrides (never commit personal settings)
.gitflow.local.yaml

24
.golangci.yml Normal file
View File

@ -0,0 +1,24 @@
# golangci-lint configuration for gitflow.
# Docs: https://golangci-lint.run/usage/configuration/
run:
timeout: 5m
linters:
enable:
- errcheck # unchecked errors
- govet # go vet
- staticcheck # static analysis
- gosimple # simplify code
- ineffassign # ineffective assignments
- unused # unused declarations
- misspell # typos in comments/strings
- gofmt # formatting
- goimports # import grouping
issues:
exclude-rules:
# Tests may use underscores to match fixture naming.
- path: _test\.go
linters:
- errcheck

35
Makefile Normal file
View File

@ -0,0 +1,35 @@
BINARY := gitflow
MODULE := gitea.oblak.solutions/dimitar/gitFlow
VERSION ?= dev
LDFLAGS := -ldflags "-X $(MODULE)/internal/version.Version=$(VERSION)"
.PHONY: build test fmt vet lint install clean
## build: compile the gitflow binary into the repo root
build:
go build $(LDFLAGS) -o $(BINARY) ./cmd/gitflow
## test: run the full test suite
test:
go test ./...
## fmt: format all Go sources
fmt:
gofmt -l -w .
## vet: run go vet over all packages
vet:
go vet ./...
## lint: run golangci-lint (requires golangci-lint on PATH)
lint:
golangci-lint run
## install: install gitflow into GOBIN
install:
go install $(LDFLAGS) ./cmd/gitflow
## clean: remove build artifacts
clean:
rm -f $(BINARY)
rm -f coverage.out

16
cmd/gitflow/main.go Normal file
View File

@ -0,0 +1,16 @@
// Command gitflow is a CLI tool that discovers Git repositories under a
// user-specified parent directory, scans their status, and presents findings
// with AI-driven suggestions for next actions.
package main
import (
"fmt"
"os"
)
func main() {
if err := newRootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}

56
cmd/gitflow/prompt.go Normal file
View File

@ -0,0 +1,56 @@
package main
import (
"bufio"
"fmt"
"os"
"strings"
)
// isTerminal reports whether f is a character device (i.e. a TTY).
func isTerminal(f *os.File) bool {
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
// promptDir asks the user for the parent directory to scan, defaulting to
// def when the input is empty.
func promptDir(def string) (string, error) {
fmt.Fprintf(os.Stderr, "Parent directory to scan [%s]: ", def)
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return "", err
}
line = strings.TrimSpace(line)
if line == "" {
return def, nil
}
return line, nil
}
// promptYesNo asks a y/N question on stderr and returns the answer.
func promptYesNo(def bool, format string, args ...any) (bool, error) {
suffix := "[y/N]"
if def {
suffix = "[Y/n]"
}
fmt.Fprintf(os.Stderr, format+" "+suffix+": ", args...)
line, err := bufio.NewReader(os.Stdin).ReadString('\n')
if err != nil {
return false, err
}
switch strings.ToLower(strings.TrimSpace(line)) {
case "y", "yes":
return true, nil
case "n", "no":
return false, nil
case "":
return def, nil
default:
return false, nil
}
}

77
cmd/gitflow/root.go Normal file
View File

@ -0,0 +1,77 @@
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"gitea.oblak.solutions/dimitar/gitFlow/internal/version"
)
// newRootCmd builds the top-level gitflow command.
func newRootCmd() *cobra.Command {
root := &cobra.Command{
Use: "gitflow",
Short: "Scan Git repositories and get suggested next steps",
Long: `gitflow discovers every Git repository under a parent directory,
scans each one's status, and presents the findings. It can rescan on a
schedule and, when enabled, uses an AI agent to suggest next actions for
repositories that need attention.`,
SilenceUsage: true,
SilenceErrors: true,
}
root.AddCommand(
newScanCmd(),
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) {
return signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
}
// newVersionCmd prints the build version.
func newVersionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version information",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
fmt.Printf("gitflow %s\n", version.Version)
return nil
},
}
}

135
cmd/gitflow/scan.go Normal file
View File

@ -0,0 +1,135 @@
package main
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
"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"
)
// newScanCmd runs a single scan pass and renders the result.
func newScanCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "scan",
Short: "Scan repositories under a directory once",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cmd.Flags())
if err != nil {
return err
}
// The README asks for the parent directory interactively;
// prompt only when nothing was provided and stdin is a TTY.
if !cmd.Flags().Changed("dir") && isTerminal(os.Stdin) {
if d, err := promptDir(cfg.Dir); err == nil {
cfg.Dir = d
}
}
ctx, stop := signalContext()
defer stop()
a, err := app.New(cfg)
if err != nil {
return err
}
result, err := a.ScanOnce(ctx)
if err != nil {
return err
}
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
}
// AI suggestions are a human-facing section; they are skipped
// in JSON mode so the machine-readable stream stays clean.
if cfg.AI.Enabled && cfg.Format != "json" {
renderSuggestions(ctx, cfg, opts, result)
}
if err := renderRuleFlags(opts, cfg, result); err != nil {
return err
}
return nil
},
}
config.RegisterFlags(cmd.Flags())
return cmd
}
// renderSuggestions asks the configured AI provider for next actions,
// renders them, and — when enabled — runs confirmed commands. Failures are
// warnings: a scan result is still useful without AI.
func renderSuggestions(ctx context.Context, cfg *config.Config, opts presenter.Options, result status.ScanResult) {
provider, err := ai.NewProvider(cfg.AI)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
return
}
suggestions, err := provider.Suggest(ctx, result)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: AI suggestions unavailable: %v\n", err)
return
}
if err := presenter.Suggestions(os.Stdout, opts, suggestions); err != nil {
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
return
}
if cfg.AI.Execute && isTerminal(os.Stdin) {
if err := ai.RunConfirmed(ctx, suggestions, confirmSuggestion); err != nil {
fmt.Fprintf(os.Stderr, "warning: %v\n", err)
}
}
}
// confirmSuggestion asks the user to approve running a suggested command.
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{
Use: "config",
Short: "Show the effective configuration",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cmd.Flags())
if err != nil {
return err
}
out, err := cfg.Dump()
if err != nil {
return err
}
fmt.Print(out)
return nil
},
}
config.RegisterFlags(cmd.Flags())
return cmd
}

125
cmd/gitflow/watch.go Normal file
View File

@ -0,0 +1,125 @@
package main
import (
"context"
"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"
)
// newWatchCmd repeatedly scans on an interval until interrupted.
func newWatchCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "watch",
Short: "Repeatedly scan repositories on an interval",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(cmd.Flags())
if err != nil {
return err
}
if cfg.Interval <= 0 {
return errors.New("watch requires a positive --interval (e.g. --interval 30s)")
}
// Same interactive prompt as scan, per the README.
if !cmd.Flags().Changed("dir") && isTerminal(os.Stdin) {
if d, err := promptDir(cfg.Dir); err == nil {
cfg.Dir = d
}
}
ctx, stop := signalContext()
defer stop()
a, err := app.New(cfg)
if err != nil {
return err
}
opts := presenter.Options{
Color: presenter.ParseColorMode(cfg.Color),
Theme: presenter.ParseTheme(cfg.Theme),
}
var prev status.ScanResult
first := true
run := func(ctx context.Context) error {
result, err := a.ScanOnce(ctx)
if err != nil {
return err
}
if cfg.Format == "json" {
return presenter.Present(os.Stdout, cfg.Format, result, opts)
}
renderWatchFrame(result, prev, first, cfg, opts)
// Ask the AI only on the first frame and when something
// changed, so the provider is not hammered every interval.
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
}
sched := scheduler.New(cfg.Interval, run)
return sched.Run(ctx)
},
}
config.RegisterFlags(cmd.Flags())
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.
func renderWatchFrame(result, prev status.ScanResult, first bool, cfg *config.Config, opts presenter.Options) {
if isTerminal(os.Stdout) {
fmt.Fprint(os.Stdout, "\x1b[2J\x1b[H")
} else {
fmt.Fprintf(os.Stdout, "--- %s ---\n", time.Now().Format(time.RFC3339))
}
if err := presenter.Present(os.Stdout, cfg.Format, result, opts); err != nil {
fmt.Fprintf(os.Stderr, "render: %v\n", err)
return
}
if !first {
for _, r := range status.Changed(prev, result) {
fmt.Fprintf(os.Stdout, "▲ %s: %s\n", r.Name, r.Status)
}
}
fmt.Fprintf(os.Stdout, "watching %s every %s — next scan at %s (Ctrl-C to stop)\n",
cfg.Dir, cfg.Interval, time.Now().Add(cfg.Interval).Format("15:04:05"))
}

32
go.mod Normal file
View File

@ -0,0 +1,32 @@
module gitea.oblak.solutions/dimitar/gitFlow
go 1.24
require (
github.com/spf13/cobra v1.9.1
github.com/spf13/pflag v1.0.6
github.com/spf13/viper v1.19.0
golang.org/x/sync v0.12.0
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

79
go.sum Normal file
View File

@ -0,0 +1,79 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

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.

54
internal/ai/ai.go Normal file
View File

@ -0,0 +1,54 @@
// Package ai turns scan results into suggested next actions using an LLM
// provider (OpenAI, Ollama, or Anthropic).
package ai
import (
"context"
"fmt"
"os"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// Suggestion is one recommended next action for a repository. The JSON tags
// match the schema the providers are instructed to emit.
type Suggestion struct {
RepoPath string `json:"repo_path"` // absolute path of the repository
Action string `json:"action"` // commit, push, pull, stash, create_pr, cleanup, inspect, ...
Message string `json:"message"` // human-readable explanation
Command string `json:"command"` // suggested shell command, if any
Priority int `json:"priority"` // 0 = low, 1 = medium, 2 = high
}
// Provider turns a scan result into suggestions.
type Provider interface {
// Name identifies the provider for logging and errors.
Name() string
// Suggest asks the provider for next actions on the given result.
Suggest(ctx context.Context, result status.ScanResult) ([]Suggestion, error)
}
// NewProvider builds the provider named in the AI configuration. Cloud
// providers require their API key to be present in the configured
// environment variable; Ollama is local and needs no key.
func NewProvider(cfg config.AIConfig) (Provider, error) {
switch cfg.Provider {
case "openai":
key := os.Getenv(cfg.APIKeyEnv)
if key == "" {
return nil, fmt.Errorf("ai: %s is not set; export it or set ai.api_key_env", cfg.APIKeyEnv)
}
return NewOpenAI(cfg, key), nil
case "anthropic":
key := os.Getenv(cfg.APIKeyEnv)
if key == "" {
return nil, fmt.Errorf("ai: %s is not set; export it or set ai.api_key_env", cfg.APIKeyEnv)
}
return NewAnthropic(cfg, key), nil
case "ollama":
return NewOllama(cfg), nil
default:
return nil, fmt.Errorf("ai: unknown provider %q (want openai, ollama, or anthropic)", cfg.Provider)
}
}

266
internal/ai/ai_test.go Normal file
View File

@ -0,0 +1,266 @@
package ai
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
func sampleResult() status.ScanResult {
return status.ScanResult{
ScannedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
ParentDir: "/home/user/projects",
Repos: []status.RepoInfo{
{Path: "/home/user/projects/api", Name: "api", Branch: "main", Status: status.StatusClean},
{Path: "/home/user/projects/web", Name: "web", Branch: "feat/login", Status: status.StatusModified, ModifiedFiles: []string{"a.go"}},
{Path: "/home/user/projects/lib", Name: "lib", Branch: "main", Status: status.StatusBehind, BehindBy: 5},
},
}
}
func suggestionsJSON() string {
return `[{"repo_path":"/home/user/projects/web","action":"commit","message":"commit your changes","command":"git add -A && git commit -m wip","priority":2},
{"repo_path":"/home/user/projects/lib","action":"pull","message":"pull latest","command":"git pull","priority":1}]`
}
func TestNewProviderOpenAIRequiresKey(t *testing.T) {
t.Setenv("GITFLOW_TEST_AI_KEY", "")
if _, err := NewProvider(config.AIConfig{Provider: "openai", APIKeyEnv: "GITFLOW_TEST_AI_KEY"}); err == nil {
t.Error("NewProvider(openai, no key) succeeded, want error")
}
}
func TestNewProviderAnthropicRequiresKey(t *testing.T) {
t.Setenv("GITFLOW_TEST_AI_KEY", "")
if _, err := NewProvider(config.AIConfig{Provider: "anthropic", APIKeyEnv: "GITFLOW_TEST_AI_KEY"}); err == nil {
t.Error("NewProvider(anthropic, no key) succeeded, want error")
}
}
func TestNewProviderOllamaNeedsNoKey(t *testing.T) {
p, err := NewProvider(config.AIConfig{Provider: "ollama"})
if err != nil {
t.Fatalf("NewProvider(ollama): %v", err)
}
if p.Name() != "ollama" {
t.Errorf("Name = %q, want ollama", p.Name())
}
}
func TestNewProviderUnknown(t *testing.T) {
if _, err := NewProvider(config.AIConfig{Provider: "magic"}); err == nil {
t.Error("NewProvider(magic) succeeded, want error")
}
}
func TestOpenAIProvider(t *testing.T) {
var gotReq chatRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
t.Errorf("Authorization = %q, want Bearer test-key", got)
}
if got := r.URL.Path; got != "/chat/completions" {
t.Errorf("path = %q, want /chat/completions", got)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/json")
content := "```json\n" + suggestionsJSON() + "\n```"
resp, _ := json.Marshal(map[string]any{
"choices": []any{map[string]any{
"message": map[string]any{"role": "assistant", "content": content},
}},
})
_, _ = w.Write(resp)
}))
defer srv.Close()
p := NewOpenAI(config.AIConfig{BaseURL: srv.URL, Model: "gpt-test"}, "test-key")
sugs, err := p.Suggest(context.Background(), sampleResult())
if err != nil {
t.Fatalf("Suggest: %v", err)
}
if gotReq.Model != "gpt-test" {
t.Errorf("model = %q, want gpt-test", gotReq.Model)
}
if len(gotReq.Messages) != 2 || gotReq.Messages[0].Role != "system" {
t.Errorf("messages malformed: %+v", gotReq.Messages)
}
if len(sugs) != 2 {
t.Fatalf("got %d suggestions, want 2", len(sugs))
}
if sugs[0].Action != "commit" || sugs[0].Priority != 2 || sugs[0].RepoPath != "/home/user/projects/web" {
t.Errorf("suggestion[0] = %+v", sugs[0])
}
}
func TestOpenAIProviderErrorBody(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"error":{"message":"insufficient quota"}}`))
}))
defer srv.Close()
p := NewOpenAI(config.AIConfig{BaseURL: srv.URL, Model: "gpt-test"}, "k")
if _, err := p.Suggest(context.Background(), sampleResult()); err == nil {
t.Error("Suggest succeeded with API error body, want error")
}
}
func TestOpenAIProviderHTTPFailure(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "boom", http.StatusInternalServerError)
}))
defer srv.Close()
p := NewOpenAI(config.AIConfig{BaseURL: srv.URL, Model: "gpt-test"}, "k")
if _, err := p.Suggest(context.Background(), sampleResult()); err == nil {
t.Error("Suggest succeeded with 500, want error")
}
}
func TestOllamaProvider(t *testing.T) {
var gotReq ollamaRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Path; got != "/api/chat" {
t.Errorf("path = %q, want /api/chat", got)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatal(err)
}
resp, _ := json.Marshal(map[string]any{
"message": map[string]any{"role": "assistant", "content": suggestionsJSON()},
"error": "",
})
_, _ = w.Write(resp)
}))
defer srv.Close()
p := NewOllama(config.AIConfig{BaseURL: srv.URL, Model: "llama-test"})
sugs, err := p.Suggest(context.Background(), sampleResult())
if err != nil {
t.Fatalf("Suggest: %v", err)
}
if gotReq.Model != "llama-test" || gotReq.Format != "json" || gotReq.Stream {
t.Errorf("request malformed: %+v", gotReq)
}
if len(sugs) != 2 {
t.Errorf("got %d suggestions, want 2", len(sugs))
}
}
func TestOllamaProviderErrorField(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"error":"model not found"}`))
}))
defer srv.Close()
p := NewOllama(config.AIConfig{BaseURL: srv.URL})
if _, err := p.Suggest(context.Background(), sampleResult()); err == nil {
t.Error("Suggest succeeded with error field, want error")
}
}
func TestAnthropicProvider(t *testing.T) {
var gotReq anthropicRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("x-api-key"); got != "test-key" {
t.Errorf("x-api-key = %q", got)
}
if got := r.Header.Get("anthropic-version"); got != anthropicVersion {
t.Errorf("anthropic-version = %q", got)
}
if err := json.NewDecoder(r.Body).Decode(&gotReq); err != nil {
t.Fatal(err)
}
resp, _ := json.Marshal(map[string]any{
"content": []any{map[string]any{"type": "text", "text": suggestionsJSON()}},
})
_, _ = w.Write(resp)
}))
defer srv.Close()
p := NewAnthropic(config.AIConfig{BaseURL: srv.URL, Model: "claude-test"}, "test-key")
sugs, err := p.Suggest(context.Background(), sampleResult())
if err != nil {
t.Fatalf("Suggest: %v", err)
}
if gotReq.MaxTokens != 1024 || gotReq.System == "" {
t.Errorf("request malformed: %+v", gotReq)
}
if len(sugs) != 2 {
t.Errorf("got %d suggestions, want 2", len(sugs))
}
}
func TestParseSuggestions(t *testing.T) {
plain := `[{"repo_path":"/a","action":"push","message":"m","command":"git push","priority":2}]`
if got, err := parseSuggestions(plain); err != nil || len(got) != 1 {
t.Errorf("plain: got %v, err %v", got, err)
}
fenced := "Here you go:\n```json\n" + plain + "\n```\nHope that helps!"
if got, err := parseSuggestions(fenced); err != nil || len(got) != 1 {
t.Errorf("fenced: got %v, err %v", got, err)
}
if got, err := parseSuggestions("[]"); err != nil || len(got) != 0 {
t.Errorf("empty: got %v, err %v", got, err)
}
if _, err := parseSuggestions("no json here"); err == nil {
t.Error("garbage accepted, want error")
}
bad := `[{"repo_path":` // truncated JSON
if _, err := parseSuggestions(bad); err == nil {
t.Error("truncated JSON accepted, want error")
}
}
func TestParseSuggestionsClampsPriority(t *testing.T) {
out, err := parseSuggestions(`[{"priority":7},{"priority":-3},{"priority":1}]`)
if err != nil {
t.Fatal(err)
}
want := []int{2, 0, 1}
for i, w := range want {
if out[i].Priority != w {
t.Errorf("suggestion[%d] priority = %d, want %d", i, out[i].Priority, w)
}
}
}
func TestBuildPrompt(t *testing.T) {
prompt := BuildPrompt(sampleResult())
for _, want := range []string{
`"/home/user/projects"`, "api", "web", "lib", "feat/login",
"repo_path", "priority", "Return ONLY a JSON array",
} {
if !strings.Contains(prompt, want) {
t.Errorf("prompt missing %q", want)
}
}
if strings.Contains(prompt, "\x00") {
t.Error("prompt contains NUL bytes")
}
}
// TestProviderHonorsCancellation ensures a cancelled context fails fast
// instead of waiting for the upstream.
func TestProviderHonorsCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
p := NewOpenAI(config.AIConfig{BaseURL: "http://127.0.0.1:1"}, "k")
if _, err := p.Suggest(ctx, sampleResult()); err == nil {
t.Error("Suggest(cancelled ctx) succeeded, want error")
}
}

82
internal/ai/anthropic.go Normal file
View File

@ -0,0 +1,82 @@
package ai
import (
"context"
"fmt"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
const (
defaultAnthropicBaseURL = "https://api.anthropic.com/v1"
anthropicVersion = "2023-06-01"
)
// AnthropicProvider talks to the Anthropic Messages API.
type AnthropicProvider struct {
model string
baseURL string
apiKey string
client *client
}
// NewAnthropic builds an Anthropic provider. base_url and model fall back
// to sensible defaults when unset in the configuration.
func NewAnthropic(cfg config.AIConfig, apiKey string) *AnthropicProvider {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = defaultAnthropicBaseURL
}
model := cfg.Model
if model == "" {
model = "claude-3-5-haiku-latest"
}
return &AnthropicProvider{model: model, baseURL: baseURL, apiKey: apiKey, client: newClient()}
}
// Name implements Provider.
func (p *AnthropicProvider) Name() string { return "anthropic" }
type anthropicRequest struct {
Model string `json:"model"`
MaxTokens int `json:"max_tokens"`
System string `json:"system"`
Messages []chatMessage `json:"messages"`
}
type anthropicResponse struct {
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Suggest implements Provider.
func (p *AnthropicProvider) Suggest(ctx context.Context, result status.ScanResult) ([]Suggestion, error) {
req := anthropicRequest{
Model: p.model,
MaxTokens: 1024,
System: "You are gitflow, a concise Git repository health assistant. You reply with JSON only.",
Messages: []chatMessage{{Role: "user", Content: BuildPrompt(result)}},
}
var resp anthropicResponse
err := p.client.postJSON(ctx, p.baseURL+"/messages",
map[string]string{"x-api-key": p.apiKey, "anthropic-version": anthropicVersion}, req, &resp)
if err != nil {
return nil, err
}
if resp.Error != nil {
return nil, fmt.Errorf("ai: anthropic: %s", resp.Error.Message)
}
for _, c := range resp.Content {
if c.Type == "text" && c.Text != "" {
return parseSuggestions(c.Text)
}
}
return nil, fmt.Errorf("ai: anthropic: empty response")
}

56
internal/ai/client.go Normal file
View File

@ -0,0 +1,56 @@
package ai
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// client is a small LLM HTTP client with a bounded response size and a
// timeout so a slow or chatty upstream cannot hang a scan.
type client struct {
httpc *http.Client
}
func newClient() *client {
return &client{httpc: &http.Client{Timeout: 60 * time.Second}}
}
// postJSON sends payload as JSON and decodes the response body into out.
func (c *client) postJSON(ctx context.Context, url string, headers map[string]string, payload, out any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("ai: encode request: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("ai: %w", err)
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := c.httpc.Do(req)
if err != nil {
return fmt.Errorf("ai: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 4<<20)) // 4 MiB cap
if err != nil {
return fmt.Errorf("ai: read response: %w", err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("ai: %s: %s", resp.Status, strings.TrimSpace(string(data)))
}
if err := json.Unmarshal(data, out); err != nil {
return fmt.Errorf("ai: decode response: %w", err)
}
return nil
}

58
internal/ai/execute.go Normal file
View File

@ -0,0 +1,58 @@
package ai
import (
"context"
"fmt"
"os/exec"
"strings"
)
// safeActions lists the actions whose suggested commands may be executed
// with explicit user confirmation. Anything else is treated as advisory
// only, because the LLM output must never be able to run arbitrary
// commands on the user's machine.
var safeActions = map[string]bool{
"commit": true,
"push": true,
"pull": true,
"stash": true,
"checkout": true,
}
// ConfirmFunc asks the user whether to run a suggestion's command.
type ConfirmFunc func(Suggestion) (bool, error)
// RunConfirmed runs the commands of confirmed suggestions in their
// repository working directories. Suggestions whose action is not in the
// allowlist are never executed. Cancellation stops the remaining
// suggestions.
func RunConfirmed(ctx context.Context, suggestions []Suggestion, confirm ConfirmFunc) error {
for _, s := range suggestions {
if s.Command == "" || !safeActions[s.Action] {
continue
}
ok, err := confirm(s)
if err != nil || !ok {
continue
}
if err := runCommand(ctx, s); err != nil {
return fmt.Errorf("ai: %s: %w", s.RepoPath, err)
}
}
return nil
}
// runCommand executes the suggestion's command inside its repository and
// prints its output.
func runCommand(ctx context.Context, s Suggestion) error {
cmd := exec.CommandContext(ctx, "sh", "-c", s.Command)
cmd.Dir = s.RepoPath
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("run %q: %w: %s", s.Command, err, strings.TrimSpace(string(out)))
}
if text := strings.TrimSpace(string(out)); text != "" {
fmt.Println(text)
}
return nil
}

View File

@ -0,0 +1,73 @@
package ai
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestRunConfirmedSkipsUnsafeActions(t *testing.T) {
dir := t.TempDir()
sugs := []Suggestion{
{RepoPath: dir, Action: "rm_rf", Command: "echo unsafe"}, // not in allowlist
{RepoPath: dir, Action: "push", Command: ""}, // no command
}
confirmed := 0
err := RunConfirmed(context.Background(), sugs, func(s Suggestion) (bool, error) {
confirmed++
return true, nil
})
if err != nil {
t.Fatalf("RunConfirmed: %v", err)
}
if confirmed != 0 {
t.Errorf("confirm called %d times, want 0 (all suggestions skipped)", confirmed)
}
}
func TestRunConfirmedSkipsUnconfirmed(t *testing.T) {
dir := t.TempDir()
sugs := []Suggestion{
{RepoPath: dir, Action: "pull", Command: "true"},
}
err := RunConfirmed(context.Background(), sugs, func(s Suggestion) (bool, error) {
return false, nil
})
if err != nil {
t.Fatalf("RunConfirmed: %v", err)
}
}
func TestRunConfirmedExecutesConfirmed(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "out.txt")
sugs := []Suggestion{
{RepoPath: dir, Action: "stash", Command: "printf ok > " + filepath.Base(target)},
}
err := RunConfirmed(context.Background(), sugs, func(s Suggestion) (bool, error) {
return true, nil
})
if err != nil {
t.Fatalf("RunConfirmed: %v", err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatalf("command did not run: %v", err)
}
if string(data) != "ok" {
t.Errorf("output = %q, want ok", data)
}
}
func TestRunConfirmedReturnsCommandError(t *testing.T) {
sugs := []Suggestion{
{RepoPath: t.TempDir(), Action: "pull", Command: "exit 3"},
}
err := RunConfirmed(context.Background(), sugs, func(s Suggestion) (bool, error) {
return true, nil
})
if err == nil {
t.Error("RunConfirmed succeeded for failing command, want error")
}
}

69
internal/ai/ollama.go Normal file
View File

@ -0,0 +1,69 @@
package ai
import (
"context"
"fmt"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
const defaultOllamaBaseURL = "http://localhost:11434"
// OllamaProvider talks to a local Ollama server. No API key is needed.
type OllamaProvider struct {
model string
baseURL string
client *client
}
// NewOllama builds an Ollama provider. base_url and model fall back to
// sensible defaults when unset in the configuration.
func NewOllama(cfg config.AIConfig) *OllamaProvider {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = defaultOllamaBaseURL
}
model := cfg.Model
if model == "" {
model = "llama3.2"
}
return &OllamaProvider{model: model, baseURL: baseURL, client: newClient()}
}
// Name implements Provider.
func (p *OllamaProvider) Name() string { return "ollama" }
type ollamaRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
Format string `json:"format"` // "json" forces structured output
Stream bool `json:"stream"`
}
type ollamaResponse struct {
Message chatMessage `json:"message"`
Error string `json:"error"`
}
// Suggest implements Provider.
func (p *OllamaProvider) Suggest(ctx context.Context, result status.ScanResult) ([]Suggestion, error) {
req := ollamaRequest{
Model: p.model,
Messages: []chatMessage{
{Role: "system", Content: "You are gitflow, a concise Git repository health assistant. You reply with JSON only."},
{Role: "user", Content: BuildPrompt(result)},
},
Format: "json",
Stream: false,
}
var resp ollamaResponse
if err := p.client.postJSON(ctx, p.baseURL+"/api/chat", nil, req, &resp); err != nil {
return nil, err
}
if resp.Error != "" {
return nil, fmt.Errorf("ai: ollama: %s", resp.Error)
}
return parseSuggestions(resp.Message.Content)
}

87
internal/ai/openai.go Normal file
View File

@ -0,0 +1,87 @@
package ai
import (
"context"
"fmt"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
const defaultOpenAIBaseURL = "https://api.openai.com/v1"
// OpenAIProvider talks to the OpenAI Chat Completions API.
type OpenAIProvider struct {
model string
baseURL string
apiKey string
client *client
}
// NewOpenAI builds an OpenAI provider. base_url and model fall back to
// sensible defaults when unset in the configuration.
func NewOpenAI(cfg config.AIConfig, apiKey string) *OpenAIProvider {
baseURL := cfg.BaseURL
if baseURL == "" {
baseURL = defaultOpenAIBaseURL
}
model := cfg.Model
if model == "" {
model = "gpt-4o"
}
return &OpenAIProvider{model: model, baseURL: baseURL, apiKey: apiKey, client: newClient()}
}
// Name implements Provider.
func (p *OpenAIProvider) Name() string { return "openai" }
// chatMessage is a single chat turn, shared with the other providers.
type chatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type chatRequest struct {
Model string `json:"model"`
Messages []chatMessage `json:"messages"`
ResponseFormat *responseFormat `json:"response_format,omitempty"`
}
type responseFormat struct {
Type string `json:"type"` // "json_object"
}
type chatResponse struct {
Choices []struct {
Message chatMessage `json:"message"`
} `json:"choices"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
// Suggest implements Provider.
func (p *OpenAIProvider) Suggest(ctx context.Context, result status.ScanResult) ([]Suggestion, error) {
req := chatRequest{
Model: p.model,
Messages: []chatMessage{
{Role: "system", Content: "You are gitflow, a concise Git repository health assistant. You reply with JSON only."},
{Role: "user", Content: BuildPrompt(result)},
},
ResponseFormat: &responseFormat{Type: "json_object"},
}
var resp chatResponse
err := p.client.postJSON(ctx, p.baseURL+"/chat/completions",
map[string]string{"Authorization": "Bearer " + p.apiKey}, req, &resp)
if err != nil {
return nil, err
}
if resp.Error != nil {
return nil, fmt.Errorf("ai: openai: %s", resp.Error.Message)
}
if len(resp.Choices) == 0 {
return nil, fmt.Errorf("ai: openai: empty response")
}
return parseSuggestions(resp.Choices[0].Message.Content)
}

66
internal/ai/parse.go Normal file
View File

@ -0,0 +1,66 @@
package ai
import (
"encoding/json"
"fmt"
"strings"
)
// parseSuggestions extracts []Suggestion from an LLM text reply, tolerating
// ```json fences and surrounding prose. Priorities are clamped to 0..2 and
// the result is capped to a sane number of items.
func parseSuggestions(reply string) ([]Suggestion, error) {
text := stripFences(strings.TrimSpace(reply))
start := strings.IndexByte(text, '[')
end := strings.LastIndexByte(text, ']')
if start == -1 || end == -1 || end <= start {
return nil, fmt.Errorf("ai: no JSON array in reply: %s", truncate(reply, 200))
}
var out []Suggestion
if err := json.Unmarshal([]byte(text[start:end+1]), &out); err != nil {
return nil, fmt.Errorf("ai: parse suggestions: %w", err)
}
if len(out) > 50 {
out = out[:50]
}
for i := range out {
if out[i].Priority < 0 {
out[i].Priority = 0
}
if out[i].Priority > 2 {
out[i].Priority = 2
}
}
return out, nil
}
// stripFences removes ```...``` code fences so fenced JSON parses cleanly.
func stripFences(s string) string {
if !strings.Contains(s, "```") {
return s
}
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
inFence := false
for _, line := range lines {
if strings.HasPrefix(strings.TrimSpace(line), "```") {
inFence = !inFence
continue
}
if inFence {
out = append(out, line)
}
}
if len(out) == 0 {
return s // fences never closed; fall back to raw content
}
return strings.Join(out, "\n")
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}

69
internal/ai/prompt.go Normal file
View File

@ -0,0 +1,69 @@
package ai
import (
"fmt"
"strings"
"time"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// BuildPrompt renders a scan result as an LLM instruction: a repository
// status table plus strict output rules for the suggestion schema.
func BuildPrompt(result status.ScanResult) string {
var b strings.Builder
fmt.Fprintf(&b, "You are gitflow, a Git repository health assistant. "+
"A scan of %q at %s found %d repositories.\n\n",
result.ParentDir, result.ScannedAt.Format(time.RFC3339), len(result.Repos))
b.WriteString("Repository status table:\n")
b.WriteString("| name | status | branch | ahead | behind | changes |\n")
b.WriteString("|---|---|---|---|---|---|\n")
for _, r := range result.Repos {
fmt.Fprintf(&b, "| %s | %s | %s | %d | %d | %s |\n",
r.Name, r.Status, branchLabel(r), r.AheadBy, r.BehindBy, changeSummary(r))
}
attention := result.NeedsAttention()
fmt.Fprintf(&b, "\n%d of %d repositories need attention.\n\n", len(attention), len(result.Repos))
b.WriteString(`Return ONLY a JSON array of suggestions. Each suggestion has exactly these fields:
- "repo_path": absolute path of the repository
- "action": one of "commit", "push", "pull", "stash", "create_pr", "cleanup", "inspect"
- "message": one short sentence explaining the next step
- "command": a concrete git command to run, or "" if none is safe
- "priority": 0 (low), 1 (medium), or 2 (high)
Rules:
- Only suggest actions for repositories that need attention.
- Prefer the smallest safe step; never suggest destructive commands.
- Do not invent repositories that are not in the table.
- If nothing needs attention, return [].
`)
return b.String()
}
func branchLabel(r status.RepoInfo) string {
if r.Branch == "" {
return "(none)"
}
return r.Branch
}
// changeSummary renders staged/modified/untracked counts for the prompt.
func changeSummary(r status.RepoInfo) string {
parts := make([]string, 0, 3)
if n := len(r.StagedFiles); n > 0 {
parts = append(parts, fmt.Sprintf("%d staged", n))
}
if n := len(r.ModifiedFiles); n > 0 {
parts = append(parts, fmt.Sprintf("%d modified", n))
}
if n := len(r.UntrackedFiles); n > 0 {
parts = append(parts, fmt.Sprintf("%d untracked", n))
}
if len(parts) == 0 {
return "none"
}
return strings.Join(parts, ", ")
}

64
internal/app/app.go Normal file
View File

@ -0,0 +1,64 @@
// Package app wires discovery, status scanning, and presentation into the
// runnable operations used by the CLI commands.
package app
import (
"context"
"fmt"
"os"
"time"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/internal/scanner"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// App coordinates the components built from the resolved configuration.
type App struct {
cfg *config.Config
discoverer *scanner.Discoverer
scanner *scanner.Scanner
warn func(format string, args ...any)
}
// New builds an App from a validated configuration.
func New(cfg *config.Config) (*App, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
return &App{
cfg: cfg,
discoverer: scanner.NewDiscoverer(
scanner.WithExclude(cfg.Exclude...),
scanner.WithMaxDepth(cfg.MaxDepth),
),
scanner: scanner.NewScanner(scanner.WithWorkers(cfg.Workers)),
warn: func(format string, args ...any) {
fmt.Fprintf(os.Stderr, "warning: "+format+"\n", args...)
},
}, nil
}
// ScanOnce discovers repositories under the configured directory and
// snapshots their status in a single pass.
func (a *App) ScanOnce(ctx context.Context) (status.ScanResult, error) {
repos, err := a.discoverer.Discover(ctx, a.cfg.Dir)
if err != nil {
if len(repos) == 0 {
return status.ScanResult{}, err
}
// Partial discovery (e.g. permission denied on some subtree):
// warn and continue with what was found.
a.warn("%v", err)
}
infos, err := a.scanner.Scan(ctx, repos)
if err != nil {
return status.ScanResult{}, err
}
return status.ScanResult{
ScannedAt: time.Now(),
ParentDir: a.cfg.Dir,
Repos: infos,
}, nil
}

74
internal/app/app_test.go Normal file
View File

@ -0,0 +1,74 @@
package app
import (
"context"
"os/exec"
"path/filepath"
"testing"
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
func initGitRepo(t *testing.T, path string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cmd := exec.Command("git", "init", "-q", "-b", "main", filepath.Base(path))
cmd.Dir = filepath.Dir(path)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
}
func TestNewValidatesConfig(t *testing.T) {
bad := &config.Config{Dir: "", Format: "xml", Theme: "dark", Workers: 0}
if _, err := New(bad); err == nil {
t.Error("New(bad config) succeeded, want error")
}
}
func TestScanOnce(t *testing.T) {
root := t.TempDir()
repo := filepath.Join(root, "repo")
initGitRepo(t, repo)
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)
}
result, err := a.ScanOnce(context.Background())
if err != nil {
t.Fatalf("ScanOnce: %v", err)
}
if result.ParentDir != root {
t.Errorf("ParentDir = %q, want %q", result.ParentDir, root)
}
if len(result.Repos) != 1 {
t.Fatalf("ScanOnce found %d repos, want 1", len(result.Repos))
}
info := result.Repos[0]
if info.Path != repo {
t.Errorf("Repo path = %q, want %q", info.Path, repo)
}
if info.Status != status.StatusClean {
t.Errorf("Repo status = %v, want clean", info.Status)
}
if result.ScannedAt.IsZero() {
t.Error("ScannedAt is zero")
}
}
func TestScanOnceMissingDir(t *testing.T) {
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)
}
if _, err := a.ScanOnce(context.Background()); err == nil {
t.Error("ScanOnce(missing dir) succeeded, want error")
}
}

256
internal/config/config.go Normal file
View File

@ -0,0 +1,256 @@
// Package config resolves and validates gitflow settings from CLI flags,
// environment variables, and an optional YAML configuration file.
//
// Precedence (highest first): flags, environment variables, config file,
// built-in defaults.
package config
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"gopkg.in/yaml.v3"
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
)
// AIConfig holds AI agent settings.
type AIConfig struct {
Enabled bool `yaml:"enabled"`
Provider string `yaml:"provider"` // openai, ollama, or anthropic
Model string `yaml:"model"` // model name; empty lets the provider choose
APIKeyEnv string `yaml:"api_key_env"` // env var holding the API key
BaseURL string `yaml:"base_url"` // provider endpoint override
Execute bool `yaml:"execute"` // run confirmed AI-suggested commands (experimental)
}
// Config is the fully resolved runtime configuration.
type Config struct {
Dir string `yaml:"dir"`
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
}
// flagKeys maps CLI flag names to their viper keys.
var flagKeys = []struct{ flag, key string }{
{"dir", "dir"},
{"interval", "interval"},
{"format", "format"},
{"color", "color"},
{"exclude", "exclude"},
{"max-depth", "max_depth"},
{"workers", "workers"},
{"notify", "notify"},
{"theme", "theme"},
{"ai", "ai.enabled"},
{"ai-provider", "ai.provider"},
{"ai-model", "ai.model"},
{"ai-execute", "ai.execute"},
}
// RegisterFlags defines every gitflow flag on f. Call Load with the same
// FlagSet to resolve the effective configuration.
func RegisterFlags(f *pflag.FlagSet) {
f.StringP("dir", "d", ".", "parent directory to scan")
f.DurationP("interval", "i", 0, "rescan interval (e.g. 30s, 5m); 0 runs once")
f.StringP("format", "f", "table", "output format: table, json, or compact")
f.String("color", "auto", "color output: auto, always, or never")
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")
f.Bool("ai-execute", false, "run confirmed AI-suggested commands (experimental)")
}
// NewFlagSet returns a FlagSet with every gitflow flag registered.
func NewFlagSet() *pflag.FlagSet {
fs := pflag.NewFlagSet("gitflow", pflag.ContinueOnError)
RegisterFlags(fs)
return fs
}
// Load resolves the effective configuration from flags, environment, and
// config file, then validates it.
func Load(flags *pflag.FlagSet) (*Config, error) {
v := viper.New()
applyDefaults(v)
v.SetEnvPrefix("GITFLOW")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
if err := readConfigFile(v); err != nil {
return nil, err
}
bindFlags(v, flags)
cfg := &Config{
Dir: v.GetString("dir"),
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"),
ConfigFile: v.ConfigFileUsed(),
AI: AIConfig{
Enabled: v.GetBool("ai.enabled"),
Provider: v.GetString("ai.provider"),
Model: v.GetString("ai.model"),
APIKeyEnv: v.GetString("ai.api_key_env"),
BaseURL: v.GetString("ai.base_url"),
Execute: v.GetBool("ai.execute"),
},
}
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
}
func applyDefaults(v *viper.Viper) {
v.SetDefault("dir", ".")
v.SetDefault("interval", 0)
v.SetDefault("format", "table")
v.SetDefault("color", "auto")
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")
v.SetDefault("ai.api_key_env", "OPENAI_API_KEY")
v.SetDefault("ai.base_url", "")
v.SetDefault("ai.execute", false)
}
// readConfigFile loads ~/.gitflow.yaml (or $GITFLOW_CONFIG when set). A
// missing config file is not an error; a malformed one is.
func readConfigFile(v *viper.Viper) error {
if path := os.Getenv("GITFLOW_CONFIG"); path != "" {
v.SetConfigFile(path)
} else {
v.SetConfigName(".gitflow")
v.SetConfigType("yaml")
v.AddConfigPath("$HOME")
v.AddConfigPath(".")
}
if err := v.ReadInConfig(); err != nil {
var notFound viper.ConfigFileNotFoundError
if errors.As(err, &notFound) {
return nil
}
return fmt.Errorf("config: %w", err)
}
return nil
}
func bindFlags(v *viper.Viper, flags *pflag.FlagSet) {
for _, fk := range flagKeys {
if fl := flags.Lookup(fk.flag); fl != nil {
_ = v.BindPFlag(fk.key, fl)
}
}
}
// Validate rejects configuration that cannot be used.
func (c *Config) Validate() error {
if c.Dir == "" {
return errors.New("config: dir must not be empty")
}
switch c.Format {
case "table", "json", "compact":
default:
return fmt.Errorf("config: unsupported format %q (want table, json, or compact)", c.Format)
}
switch c.Color {
case "auto", "always", "never":
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")
}
if c.MaxDepth < 0 {
return errors.New("config: max-depth must not be negative")
}
if c.Workers < 1 {
return errors.New("config: workers must be at least 1")
}
if c.AI.Enabled {
switch c.AI.Provider {
case "openai", "ollama", "anthropic":
default:
return fmt.Errorf("config: unsupported ai provider %q (want openai, ollama, or anthropic)", c.AI.Provider)
}
}
return nil
}
// Dump renders the effective configuration as human-readable YAML, with the
// interval shown as a duration string.
func (c *Config) Dump() (string, error) {
v := map[string]any{
"config_file": c.ConfigFile,
"dir": c.Dir,
"interval": c.Interval.String(),
"format": c.Format,
"color": c.Color,
"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,
"model": c.AI.Model,
"api_key_env": c.AI.APIKeyEnv,
"base_url": c.AI.BaseURL,
"execute": c.AI.Execute,
},
}
out, err := yaml.Marshal(v)
if err != nil {
return "", err
}
return string(out), nil
}

View File

@ -0,0 +1,274 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/pflag"
)
// clearEnv removes every GITFLOW_ variable so tests start from a known
// state regardless of the developer's shell.
func clearEnv(t *testing.T) {
t.Helper()
for _, kv := range os.Environ() {
if strings.HasPrefix(kv, "GITFLOW_") {
key := strings.SplitN(kv, "=", 2)[0]
os.Unsetenv(key)
}
}
}
func loadWithFlags(t *testing.T, set func(f *pflag.FlagSet)) (*Config, error) {
t.Helper()
fs := NewFlagSet()
if set != nil {
set(fs)
}
return Load(fs)
}
func TestDefaults(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home) // no config file present
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Dir != "." {
t.Errorf("Dir = %q, want %q", cfg.Dir, ".")
}
if cfg.Interval != 0 || cfg.MaxDepth != 0 {
t.Errorf("Interval/MaxDepth = %v/%d, want 0/0", cfg.Interval, cfg.MaxDepth)
}
if cfg.Format != "table" || cfg.Workers != 8 {
t.Errorf("Format/Workers = %q/%d, want table/8", cfg.Format, cfg.Workers)
}
if cfg.AI.Provider != "openai" || cfg.AI.Model != "gpt-4o" || cfg.AI.APIKeyEnv != "OPENAI_API_KEY" {
t.Errorf("AI defaults wrong: %+v", cfg.AI)
}
}
func TestFlagOverrides(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
cfg, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("format", "compact"); err != nil {
t.Fatal(err)
}
if err := f.Set("dir", "/tmp/x"); err != nil {
t.Fatal(err)
}
if err := f.Set("workers", "4"); err != nil {
t.Fatal(err)
}
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "compact" || cfg.Dir != "/tmp/x" || cfg.Workers != 4 {
t.Errorf("flag overrides not applied: %+v", cfg)
}
}
func TestEnvOverrides(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("GITFLOW_FORMAT", "json")
t.Setenv("GITFLOW_MAX_DEPTH", "3")
t.Setenv("GITFLOW_AI_PROVIDER", "ollama")
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "json" || cfg.MaxDepth != 3 || cfg.AI.Provider != "ollama" {
t.Errorf("env overrides not applied: %+v", cfg)
}
}
func TestFlagBeatsEnv(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("GITFLOW_FORMAT", "json")
cfg, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("format", "compact"); err != nil {
t.Fatal(err)
}
})
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "compact" {
t.Errorf("Format = %q, want compact (flag must beat env)", cfg.Format)
}
}
func TestConfigFile(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
content := "dir: /home/user/projects\nformat: json\nmax_depth: 2\ninterval: 5m\nexclude:\n - node_modules\n - vendor\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 cfg.Dir != "/home/user/projects" || cfg.Format != "json" || cfg.MaxDepth != 2 {
t.Errorf("file values not applied: %+v", cfg)
}
if cfg.Interval != 5*time.Minute {
t.Errorf("Interval = %v, want 5m", cfg.Interval)
}
if len(cfg.Exclude) != 2 || cfg.Exclude[0] != "node_modules" || cfg.Exclude[1] != "vendor" {
t.Errorf("Exclude = %v, want [node_modules vendor]", cfg.Exclude)
}
if cfg.ConfigFile != filepath.Join(home, ".gitflow.yaml") {
t.Errorf("ConfigFile = %q, want %q", cfg.ConfigFile, filepath.Join(home, ".gitflow.yaml"))
}
}
func TestConfigPathEnvOverride(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
alt := filepath.Join(home, "custom.yaml")
if err := os.WriteFile(alt, []byte("format: compact\n"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("GITFLOW_CONFIG", alt)
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Format != "compact" {
t.Errorf("Format = %q, want compact from GITFLOW_CONFIG file", cfg.Format)
}
}
func TestValidateRejectsBadFormat(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("format", "xml"); err != nil {
t.Fatal(err)
}
}); err == nil {
t.Error("Load(bad format) succeeded, want error")
}
}
func TestValidateRejectsBadWorkers(t *testing.T) {
clearEnv(t)
home := t.TempDir()
t.Setenv("HOME", home)
if _, err := loadWithFlags(t, func(f *pflag.FlagSet) {
if err := f.Set("workers", "0"); err != nil {
t.Fatal(err)
}
}); err == nil {
t.Error("Load(workers=0) succeeded, want error")
}
}
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()
t.Setenv("HOME", home)
cfg, err := loadWithFlags(t, nil)
if err != nil {
t.Fatalf("Load: %v", err)
}
out, err := cfg.Dump()
if err != nil {
t.Fatalf("Dump: %v", err)
}
for _, want := range []string{"dir:", "interval:", "format:", "workers:", "ai:", "provider:"} {
if !strings.Contains(out, want) {
t.Errorf("Dump() missing %q:\n%s", want, out)
}
}
}

80
internal/git/exec.go Normal file
View File

@ -0,0 +1,80 @@
// Package git is a thin wrapper around the git binary. It runs porcelain
// commands via os/exec with a per-command timeout so a scan can never hang
// on a broken or unresponsive repository.
package git
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
// DefaultTimeout bounds every git invocation. A single hung git call would
// otherwise stall the whole scan.
const DefaultTimeout = 10 * time.Second
// Executor runs git commands against a repository working directory.
type Executor struct {
bin string
timeout time.Duration
env []string
}
// Option configures an Executor (functional options pattern).
type Option func(*Executor)
// WithBinary overrides the git executable path (default "git").
func WithBinary(bin string) Option {
return func(e *Executor) { e.bin = bin }
}
// WithTimeout overrides the per-command timeout (default 10s).
func WithTimeout(d time.Duration) Option {
return func(e *Executor) { e.timeout = d }
}
// WithEnv appends extra environment variables to every command.
func WithEnv(env ...string) Option {
return func(e *Executor) { e.env = append(e.env, env...) }
}
// NewExecutor builds an Executor with sensible defaults.
func NewExecutor(opts ...Option) *Executor {
e := &Executor{bin: "git", timeout: DefaultTimeout}
for _, opt := range opts {
opt(e)
}
return e
}
// Run executes git <args...> in dir with a context timeout and returns the
// trimmed stdout. Context cancellation is honoured; the configured timeout
// guards against a hanging repository.
func (e *Executor) Run(ctx context.Context, dir string, args ...string) (string, error) {
ctx, cancel := context.WithTimeout(ctx, e.timeout)
defer cancel()
cmd := exec.CommandContext(ctx, e.bin, args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), e.env...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if ctx.Err() != nil {
return "", fmt.Errorf("git %s: timed out after %s", strings.Join(args, " "), e.timeout)
}
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg)
}
return strings.TrimSpace(stdout.String()), nil
}

161
internal/git/status.go Normal file
View File

@ -0,0 +1,161 @@
package git
import (
"context"
"path/filepath"
"strconv"
"strings"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// Status snapshots one repository at path. Bare repositories are reported
// with StatusBare and no further detail, since they have no working tree.
func (e *Executor) Status(ctx context.Context, path string) (status.RepoInfo, error) {
info := status.RepoInfo{Path: path, Name: filepath.Base(path)}
bare, err := e.Run(ctx, path, "rev-parse", "--is-bare-repository")
if err != nil {
return info, err
}
if bare == "true" {
info.Status = status.StatusBare
return info, nil
}
out, err := e.Run(ctx, path, "status", "--porcelain=v2", "--branch", "-z")
if err != nil {
return info, err
}
parsePorcelainV2(out, &info)
info.RemoteURL = e.remoteURL(ctx, path)
info.StashCount = e.stashCount(ctx, path)
info.Status = classify(&info)
return info, nil
}
// classify derives the coarse-grained status from the parsed snapshot.
func classify(info *status.RepoInfo) status.RepoStatus {
switch {
case info.AheadBy > 0 && info.BehindBy > 0:
return status.StatusDiverged
case info.BehindBy > 0:
return status.StatusBehind
case info.AheadBy > 0:
return status.StatusAhead
case info.FileCount() > 0:
return status.StatusModified
case info.Detached:
return status.StatusDetached
default:
return status.StatusClean
}
}
// parsePorcelainV2 parses the NUL-separated output of
// `git status --porcelain=v2 --branch -z` into info. Unknown tokens are
// ignored so future git versions remain compatible.
func parsePorcelainV2(out string, info *status.RepoInfo) {
for _, token := range strings.Split(out, "\x00") {
token = strings.TrimSpace(token)
if token == "" {
continue
}
switch {
case strings.HasPrefix(token, "# branch.head "):
head := strings.TrimPrefix(token, "# branch.head ")
if head == "(detached)" {
info.Detached = true
info.Branch = "(detached)"
} else {
info.Branch = head
}
case strings.HasPrefix(token, "# branch.ab "):
parseAheadBehind(token, info)
case strings.HasPrefix(token, "1 "), strings.HasPrefix(token, "2 "):
parseChangeRecord(token, info)
case strings.HasPrefix(token, "?"):
info.UntrackedFiles = append(info.UntrackedFiles, strings.TrimSpace(strings.TrimPrefix(token, "?")))
}
}
}
// parseAheadBehind parses "# branch.ab +3 -5" into AheadBy/BehindBy.
func parseAheadBehind(token string, info *status.RepoInfo) {
fields := strings.Fields(token) // [#, branch.ab, +3, -5]
if len(fields) < 4 {
return
}
if n, err := strconv.Atoi(strings.TrimPrefix(fields[2], "+")); err == nil {
info.AheadBy = n
}
if n, err := strconv.Atoi(strings.TrimPrefix(fields[3], "-")); err == nil {
info.BehindBy = n
}
}
// parseChangeRecord handles porcelain v2 change records:
//
// 1 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <path>
// 2 <XY> <sub> <mH> <mI> <mW> <hH> <hI> <X><score> <path>
//
// X is the staged status and Y the unstaged one; '.' means unmodified.
// With -z the path is raw, so only the fixed leading fields are split and
// the remainder (which may contain spaces) is taken as the path.
func parseChangeRecord(token string, info *status.RepoInfo) {
fields := strings.Fields(token)
if len(fields[1]) < 2 {
return
}
var pathIdx int
switch fields[0] {
case "1":
if len(fields) < 9 {
return
}
pathIdx = 8
case "2":
if len(fields) < 10 {
return
}
pathIdx = 9
default:
return
}
path := strings.Join(fields[pathIdx:], " ")
if xy := fields[1]; xy[0] != '.' {
info.StagedFiles = append(info.StagedFiles, path)
}
if xy := fields[1]; xy[1] != '.' {
info.ModifiedFiles = append(info.ModifiedFiles, path)
}
}
// remoteURL returns the fetch URL of the first configured remote, if any.
func (e *Executor) remoteURL(ctx context.Context, path string) string {
out, err := e.Run(ctx, path, "config", "--get-regexp", `^remote\..*\.url$`)
if err != nil {
return ""
}
for _, line := range strings.Split(out, "\n") {
if f := strings.Fields(line); len(f) >= 2 {
return f[1]
}
}
return ""
}
// stashCount returns the number of stashes in the repository.
func (e *Executor) stashCount(ctx context.Context, path string) int {
out, err := e.Run(ctx, path, "stash", "list")
if err != nil {
return 0
}
if strings.TrimSpace(out) == "" {
return 0
}
return strings.Count(strings.TrimSpace(out), "\n") + 1
}

241
internal/git/status_test.go Normal file
View File

@ -0,0 +1,241 @@
package git
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// nulp joins porcelain v2 tokens the way git does with -z: NUL separated.
func nulp(parts ...string) string { return strings.Join(parts, "\x00") }
// parseFixture parses canned porcelain v2 output and classifies it.
func parseFixture(t *testing.T, out string) status.RepoInfo {
t.Helper()
var info status.RepoInfo
parsePorcelainV2(out, &info)
info.Status = classify(&info)
return info
}
func TestParsePorcelainV2Clean(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.oid 85fe419ad0cb548d3288a2e73d9bb18f5ddc8863",
"# branch.head main",
"# branch.upstream origin/main",
"# branch.ab +0 -0",
))
if info.Branch != "main" || info.Detached {
t.Errorf("branch = %q detached=%v, want main/false", info.Branch, info.Detached)
}
if info.Status != status.StatusClean {
t.Errorf("Status = %v, want clean", info.Status)
}
if info.FileCount() != 0 {
t.Errorf("FileCount = %d, want 0", info.FileCount())
}
}
func TestParsePorcelainV2Modified(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"1 .M N... 100644 100644 100644 aaa bbb a.txt",
"? new dir/",
))
if info.Status != status.StatusModified {
t.Errorf("Status = %v, want modified", info.Status)
}
if want := []string{"a.txt"}; !reflect.DeepEqual(info.ModifiedFiles, want) {
t.Errorf("ModifiedFiles = %v, want %v", info.ModifiedFiles, want)
}
if want := []string{"new dir/"}; !reflect.DeepEqual(info.UntrackedFiles, want) {
t.Errorf("UntrackedFiles = %v, want %v", info.UntrackedFiles, want)
}
}
func TestParsePorcelainV2StagedAndRenamed(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"1 M. N... 100644 100644 100644 aaa bbb staged.txt",
"1 MM N... 100644 100644 100644 aaa bbb both.txt",
"2 R. N... 100644 100644 100644 aaa bbb R100 new file.txt",
))
if want := []string{"staged.txt", "both.txt", "new file.txt"}; !reflect.DeepEqual(info.StagedFiles, want) {
t.Errorf("StagedFiles = %v, want %v", info.StagedFiles, want)
}
if want := []string{"both.txt"}; !reflect.DeepEqual(info.ModifiedFiles, want) {
t.Errorf("ModifiedFiles = %v, want %v", info.ModifiedFiles, want)
}
}
func TestParsePorcelainV2Diverged(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"# branch.ab +3 -5",
))
if info.AheadBy != 3 || info.BehindBy != 5 {
t.Errorf("ahead/behind = %d/%d, want 3/5", info.AheadBy, info.BehindBy)
}
if info.Status != status.StatusDiverged {
t.Errorf("Status = %v, want diverged", info.Status)
}
}
func TestParsePorcelainV2Behind(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.head main",
"# branch.ab +0 -2",
))
if info.Status != status.StatusBehind {
t.Errorf("Status = %v, want behind", info.Status)
}
}
func TestParsePorcelainV2Detached(t *testing.T) {
info := parseFixture(t, nulp(
"# branch.oid 85fe419ad0cb548d3288a2e73d9bb18f5ddc8863",
"# branch.head (detached)",
))
if !info.Detached || info.Branch != "(detached)" {
t.Errorf("Detached = %v, Branch = %q; want true/(detached)", info.Detached, info.Branch)
}
if info.Status != status.StatusDetached {
t.Errorf("Status = %v, want detached", info.Status)
}
}
func TestParsePorcelainV2RawPaths(t *testing.T) {
// With -z, paths are raw: spaces, quotes, and tabs are never escaped.
info := parseFixture(t, nulp(
"? tab\tname.txt",
`? quote"name.txt`,
))
if want := []string{"tab\tname.txt", `quote"name.txt`}; !reflect.DeepEqual(info.UntrackedFiles, want) {
t.Errorf("UntrackedFiles = %v, want %v", info.UntrackedFiles, want)
}
}
func TestParsePorcelainV2ToleratesUnknownLines(t *testing.T) {
info := parseFixture(t, nulp(
"# future.header something",
"# branch.head main",
"9 custom record",
))
if info.Status != status.StatusClean || info.Branch != "main" {
t.Errorf("unexpected parse: %+v", info)
}
}
// --- Integration tests against a real git binary ---------------------------
func runGit(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func TestExecutorStatusIntegration(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
dir := t.TempDir()
repo := filepath.Join(dir, "repo")
runGit(t, dir, "init", "-q", "-b", "main", filepath.Base(repo))
runGit(t, repo, "config", "user.email", "t@t")
runGit(t, repo, "config", "user.name", "t")
writeFile(t, repo, "a.txt", "hello")
runGit(t, repo, "add", "a.txt")
runGit(t, repo, "commit", "-qm", "init")
e := NewExecutor()
info, err := e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if info.Status != status.StatusClean {
t.Errorf("Status = %v, want clean", info.Status)
}
if info.Branch != "main" {
t.Errorf("Branch = %q, want main", info.Branch)
}
if info.Name != "repo" || info.Path != repo {
t.Errorf("Name/Path = %q/%q, want repo/%s", info.Name, info.Path, repo)
}
// Untracked + modified
writeFile(t, repo, "b.txt", "x")
writeFile(t, repo, "a.txt", "changed")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if info.Status != status.StatusModified {
t.Errorf("Status = %v, want modified", info.Status)
}
if len(info.UntrackedFiles) != 1 || len(info.ModifiedFiles) != 1 {
t.Errorf("untracked/modified = %d/%d, want 1/1", len(info.UntrackedFiles), len(info.ModifiedFiles))
}
// Staged
runGit(t, repo, "add", "b.txt")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if len(info.StagedFiles) != 1 {
t.Errorf("staged = %d, want 1", len(info.StagedFiles))
}
// Stash
runGit(t, repo, "stash", "push", "-q", "-m", "wip")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if info.StashCount != 1 {
t.Errorf("StashCount = %d, want 1", info.StashCount)
}
// Detached HEAD
runGit(t, repo, "checkout", "-q", "--detach")
info, err = e.Status(context.Background(), repo)
if err != nil {
t.Fatalf("Status: %v", err)
}
if !info.Detached {
t.Errorf("Detached = false, want true")
}
// Bare repository
bare := filepath.Join(dir, "bare.git")
runGit(t, dir, "init", "-q", "--bare", filepath.Base(bare))
info, err = e.Status(context.Background(), bare)
if err != nil {
t.Fatalf("Status(bare): %v", err)
}
if info.Status != status.StatusBare {
t.Errorf("bare Status = %v, want bare", info.Status)
}
// Not a repository
if _, err := e.Status(context.Background(), dir); err == nil {
t.Fatal("Status(non-repo) succeeded, want error")
}
}

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

@ -0,0 +1,63 @@
package presenter
import (
"fmt"
"io"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// CompactFormatter renders one line per repository with color-coded status
// symbols, aimed at dense terminals and dashboards.
type CompactFormatter struct {
opts Options
}
// Format implements Formatter.
func (c *CompactFormatter) Format(w io.Writer, result status.ScanResult) error {
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)
if repo.Branch != "" && !repo.Detached {
line += " (" + repo.Branch + ")"
}
if repo.AheadBy > 0 || repo.BehindBy > 0 {
line += fmt.Sprintf(" +%d/-%d", repo.AheadBy, repo.BehindBy)
}
if n := repo.FileCount(); n > 0 {
line += fmt.Sprintf(" %d change(s)", n)
}
if repo.Status == status.StatusError {
line += ": " + repo.Error
}
if _, err := fmt.Fprintln(w, line); err != nil {
return err
}
}
return nil
}
// statusSymbol returns a single-character status marker.
func statusSymbol(s status.RepoStatus) string {
switch s {
case status.StatusClean:
return "✓"
case status.StatusModified:
return "✗"
case status.StatusAhead:
return "↑"
case status.StatusBehind:
return "↓"
case status.StatusDiverged:
return "⇄"
case status.StatusDetached:
return "◉"
case status.StatusBare:
return "▢"
case status.StatusError:
return "!"
default:
return "?"
}
}

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

@ -0,0 +1,30 @@
package presenter
import (
"encoding/json"
"io"
"time"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// JSONFormatter renders the scan result as indented JSON for scripting.
type JSONFormatter struct{}
// Format implements Formatter.
func (j *JSONFormatter) Format(w io.Writer, result status.ScanResult) error {
doc := struct {
ScannedAt time.Time `json:"scanned_at"`
ParentDir string `json:"parent_dir"`
Repos []status.RepoInfo `json:"repos"`
Summary status.Summary `json:"summary"`
}{
ScannedAt: result.ScannedAt,
ParentDir: result.ParentDir,
Repos: result.Repos,
Summary: result.Summary(),
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(doc)
}

View File

@ -0,0 +1,184 @@
// Package presenter renders scan results as human- or machine-readable
// output: a colorized terminal table, indented JSON, or a compact
// one-liner format.
package presenter
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// ColorMode controls ANSI color rendering.
type ColorMode int
const (
ColorAuto ColorMode = iota // color only when writing to a terminal
ColorAlways // always emit ANSI color
ColorNever // never emit ANSI color
)
// ParseColorMode converts a CLI string to a ColorMode; anything unknown
// falls back to auto.
func ParseColorMode(s string) ColorMode {
switch s {
case "always":
return ColorAlways
case "never":
return ColorNever
default:
return ColorAuto
}
}
// 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.
type Formatter interface {
Format(w io.Writer, result status.ScanResult) error
}
// For returns the formatter matching a format name ("table", "json", or
// "compact").
func For(format string, opts Options) (Formatter, error) {
switch format {
case "table":
return &TableFormatter{opts: opts}, nil
case "json":
return &JSONFormatter{}, nil
case "compact":
return &CompactFormatter{opts: opts}, nil
default:
return nil, fmt.Errorf("presenter: unsupported format %q (want table, json, or compact)", format)
}
}
// Present renders result to w in the given format.
func Present(w io.Writer, format string, result status.ScanResult, opts Options) error {
formatter, err := For(format, opts)
if err != nil {
return err
}
return formatter.Format(w, result)
}
// renderer resolves ANSI escape codes according to the color mode, the
// destination, and the NO_COLOR convention (https://no-color.org).
type renderer struct {
green string
yellow string
red string
cyan string
blue string
reset string
}
// 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:
useColor = true
case ColorNever:
useColor = false
default: // ColorAuto
useColor = isTTY(w) && os.Getenv("NO_COLOR") == ""
}
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",
red: "\x1b[31m",
cyan: "\x1b[36m",
blue: "\x1b[34m",
reset: "\x1b[0m",
}
}
// paint wraps text in the color for the given status.
func (r renderer) paint(s status.RepoStatus, text string) string {
var code string
switch s {
case status.StatusClean:
code = r.green
case status.StatusModified:
code = r.yellow
case status.StatusDiverged, status.StatusError:
code = r.red
case status.StatusAhead:
code = r.blue
case status.StatusBehind:
code = r.yellow
case status.StatusDetached, status.StatusBare:
code = r.cyan
}
if code == "" {
return text
}
return code + text + r.reset
}
func isTTY(w io.Writer) bool {
f, ok := w.(*os.File)
if !ok {
return false
}
info, err := f.Stat()
if err != nil {
return false
}
return info.Mode()&os.ModeCharDevice != 0
}
// shortPath renders a path with the home directory collapsed to "~".
func shortPath(p string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return p
}
rel, err := filepath.Rel(home, p)
if err != nil || rel == "." || strings.HasPrefix(rel, "..") {
return p
}
return filepath.Join("~", rel)
}

View File

@ -0,0 +1,236 @@
package presenter
import (
"bytes"
"encoding/json"
"strings"
"testing"
"time"
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
func sampleResult() status.ScanResult {
return status.ScanResult{
ScannedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
ParentDir: "/home/user/projects",
Repos: []status.RepoInfo{
{Path: "/home/user/projects/api", Name: "api", Branch: "main", Status: status.StatusClean},
{
Path: "/home/user/projects/web",
Name: "web",
Branch: "feat/login",
Status: status.StatusModified,
ModifiedFiles: []string{"a.go"},
UntrackedFiles: []string{"b"},
AheadBy: 3,
},
{Path: "/home/user/projects/lib", Name: "lib", Branch: "main", Status: status.StatusBehind, BehindBy: 5},
{Path: "/home/user/projects/legacy", Name: "legacy", Branch: "(detached)", Detached: true, Status: status.StatusDetached},
{Path: "/home/user/projects/broken", Name: "broken", Status: status.StatusError, Error: "git status: fatal: not a git repository"},
},
}
}
func TestParseColorMode(t *testing.T) {
cases := []struct {
in string
want ColorMode
}{
{"auto", ColorAuto},
{"always", ColorAlways},
{"never", ColorNever},
{"bogus", ColorAuto},
}
for _, tc := range cases {
if got := ParseColorMode(tc.in); got != tc.want {
t.Errorf("ParseColorMode(%q) = %v, want %v", tc.in, got, tc.want)
}
}
}
func TestTableFormat(t *testing.T) {
var buf bytes.Buffer
opts := Options{Color: ColorNever}
if err := Present(&buf, "table", sampleResult(), opts); err != nil {
t.Fatalf("Present: %v", err)
}
out := buf.String()
for _, want := range []string{
"REPOSITORY", "BRANCH", "STATUS", "AHEAD/BEHIND", "CHANGES",
"api", "feat/login", "1M 1U", "0/5", "3/0",
"5 repos | 1 clean | 3 need attention | 1 errors",
"not a git repository",
} {
if !strings.Contains(out, want) {
t.Errorf("table output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "\x1b[") {
t.Errorf("table output contains escape codes with ColorNever:\n%q", out)
}
}
func TestTableColorAlways(t *testing.T) {
t.Setenv("NO_COLOR", "1") // explicit always must win over NO_COLOR
var buf bytes.Buffer
if err := Present(&buf, "table", sampleResult(), Options{Color: ColorAlways}); err != nil {
t.Fatalf("Present: %v", err)
}
if !strings.Contains(buf.String(), "\x1b[") {
t.Error("ColorAlways produced no escape codes")
}
}
func TestTableRespectsNoColor(t *testing.T) {
t.Setenv("NO_COLOR", "1")
var buf bytes.Buffer
// ColorAuto with a non-terminal writer: no color regardless of NO_COLOR,
// but this also verifies NO_COLOR is read without panicking.
if err := Present(&buf, "table", sampleResult(), Options{Color: ColorAuto}); err != nil {
t.Fatalf("Present: %v", err)
}
if strings.Contains(buf.String(), "\x1b[") {
t.Error("auto color on non-TTY writer produced escape codes")
}
}
func TestJSONFormat(t *testing.T) {
var buf bytes.Buffer
if err := Present(&buf, "json", sampleResult(), Options{}); err != nil {
t.Fatalf("Present: %v", err)
}
var doc struct {
ScannedAt time.Time `json:"scanned_at"`
ParentDir string `json:"parent_dir"`
Repos []status.RepoInfo `json:"repos"`
Summary status.Summary `json:"summary"`
}
if err := json.Unmarshal(buf.Bytes(), &doc); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, buf.String())
}
if doc.ParentDir != "/home/user/projects" {
t.Errorf("parent_dir = %q", doc.ParentDir)
}
if len(doc.Repos) != 5 {
t.Errorf("len(repos) = %d, want 5", len(doc.Repos))
}
if doc.Repos[1].Status != status.StatusModified {
t.Errorf("repos[1].status = %v, want modified", doc.Repos[1].Status)
}
if doc.Summary.Total != 5 || doc.Summary.Clean != 1 || doc.Summary.Attention != 3 || doc.Summary.Errored != 1 {
t.Errorf("summary = %+v", doc.Summary)
}
// Status must marshal as a string label, not an int.
if !strings.Contains(buf.String(), `"status": "modified"`) {
t.Errorf("status not marshaled as string label:\n%s", buf.String())
}
}
func TestCompactFormat(t *testing.T) {
var buf bytes.Buffer
if err := Present(&buf, "compact", sampleResult(), Options{Color: ColorNever}); err != nil {
t.Fatalf("Present: %v", err)
}
out := buf.String()
for _, want := range []string{"✓", "✗", "↓", "◉", "!", "+3/-0", "2 change(s)"} {
if !strings.Contains(out, want) {
t.Errorf("compact output missing %q:\n%s", want, out)
}
}
}
func TestForRejectsUnknownFormat(t *testing.T) {
if _, err := For("xml", Options{}); err == nil {
t.Error("For(xml) succeeded, want error")
}
}
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},
{RepoPath: "/home/user/projects/lib", Action: "pull", Message: "pull latest", Command: "git pull", Priority: 1},
}
var buf bytes.Buffer
if err := Suggestions(&buf, Options{Color: ColorNever}, sugs); err != nil {
t.Fatalf("Suggestions: %v", err)
}
out := buf.String()
for _, want := range []string{"AI SUGGESTIONS", "commit", "pull", "high", "medium", "git pull"} {
if !strings.Contains(out, want) {
t.Errorf("suggestions output missing %q:\n%s", want, out)
}
}
if strings.Contains(out, "\x1b[") {
t.Errorf("suggestions contain escape codes with ColorNever")
}
}
func TestSuggestionsEmpty(t *testing.T) {
var buf bytes.Buffer
if err := Suggestions(&buf, Options{}, nil); err != nil {
t.Fatalf("Suggestions: %v", err)
}
if !strings.Contains(buf.String(), "no suggestions") {
t.Errorf("empty suggestions message missing: %q", buf.String())
}
}

View File

@ -0,0 +1,48 @@
package presenter
import (
"fmt"
"io"
"text/tabwriter"
"gitea.oblak.solutions/dimitar/gitFlow/internal/ai"
)
// Suggestions renders AI suggestions below a scan result, color-coded by
// priority.
func Suggestions(w io.Writer, opts Options, suggestions []ai.Suggestion) error {
if len(suggestions) == 0 {
fmt.Fprintln(w, "\nAI: no suggestions — all repositories are healthy.")
return nil
}
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")
for _, s := range suggestions {
prio := priorityLabel(s.Priority)
switch s.Priority {
case 2:
prio = r.red + prio + r.reset
case 1:
prio = r.yellow + prio + r.reset
case 0:
prio = r.green + prio + r.reset
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
shortPath(s.RepoPath), s.Action, prio, s.Message, s.Command)
}
return tw.Flush()
}
func priorityLabel(p int) string {
switch {
case p >= 2:
return "high"
case p == 1:
return "medium"
default:
return "low"
}
}

View File

@ -0,0 +1,75 @@
package presenter
import (
"fmt"
"io"
"strconv"
"strings"
"text/tabwriter"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// TableFormatter renders repositories as an aligned terminal table followed
// by a summary line.
type TableFormatter struct {
opts Options
}
// Format implements Formatter.
func (t *TableFormatter) Format(w io.Writer, result status.ScanResult) error {
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")
for _, repo := range result.Repos {
branch := repo.Branch
if branch == "" {
branch = "-"
}
ab := "-"
if repo.Status != status.StatusError && (repo.RemoteURL != "" || repo.AheadBy > 0 || repo.BehindBy > 0) {
ab = fmt.Sprintf("%d/%d", repo.AheadBy, repo.BehindBy)
}
changes := "-"
switch {
case repo.Status == status.StatusError:
changes = repo.Error
case repo.FileCount() > 0:
changes = summarizeChanges(repo)
}
stash := "-"
if repo.StashCount > 0 {
stash = strconv.Itoa(repo.StashCount)
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n",
shortPath(repo.Path), branch, r.paint(repo.Status, repo.Status.String()), ab, changes, stash)
}
if err := tw.Flush(); err != nil {
return err
}
s := result.Summary()
label := "repos"
if s.Total == 1 {
label = "repo"
}
fmt.Fprintf(w, "\n%d %s | %s clean | %d need attention | %d errors\n",
s.Total, label, r.paint(status.StatusClean, strconv.Itoa(s.Clean)), s.Attention, s.Errored)
return nil
}
// summarizeChanges renders staged/modified/untracked counts, e.g. "2M 1U".
func summarizeChanges(repo status.RepoInfo) string {
parts := make([]string, 0, 3)
if n := len(repo.StagedFiles); n > 0 {
parts = append(parts, fmt.Sprintf("%dS", n))
}
if n := len(repo.ModifiedFiles); n > 0 {
parts = append(parts, fmt.Sprintf("%dM", n))
}
if n := len(repo.UntrackedFiles); n > 0 {
parts = append(parts, fmt.Sprintf("%dU", n))
}
return strings.Join(parts, " ")
}

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

View File

@ -0,0 +1,165 @@
// Package scanner discovers Git repositories on disk and produces a status
// snapshot for each of them.
package scanner
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
)
// Discoverer walks a directory tree and reports the repository roots it finds.
type Discoverer struct {
excludePatterns []string
maxDepth int // 0 means unlimited
}
// DiscoverOption configures a Discoverer (functional options pattern).
type DiscoverOption func(*Discoverer)
// WithExclude adds glob patterns that are skipped during traversal. Patterns
// are matched against both the full path and the base name of each entry.
func WithExclude(patterns ...string) DiscoverOption {
return func(d *Discoverer) { d.excludePatterns = append(d.excludePatterns, patterns...) }
}
// WithMaxDepth limits how many directory levels below the root are scanned.
// A value of 0 means unlimited. Repositories nested deeper than the limit
// are ignored.
func WithMaxDepth(depth int) DiscoverOption {
return func(d *Discoverer) { d.maxDepth = depth }
}
// NewDiscoverer builds a Discoverer with the given options.
func NewDiscoverer(opts ...DiscoverOption) *Discoverer {
d := &Discoverer{}
for _, opt := range opts {
opt(d)
}
return d
}
// Discover returns the absolute paths of all repository roots under root.
//
// Working trees are detected via their .git directory or .git pointer file
// (linked worktrees and submodule checkouts), bare repositories via a
// directory named *.git that carries its own HEAD/objects/refs. .git
// internals are never descended into.
//
// Walk errors (e.g. permission denied) are aggregated and returned alongside
// whatever repositories were found, so a partially-scanned result is still
// useful to the caller.
func (d *Discoverer) Discover(ctx context.Context, root string) ([]string, error) {
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("discover %s: %w", root, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("discover %s: not a directory", root)
}
root, err = filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("discover %s: %w", root, err)
}
var (
repos []string
walkErrs []error
)
walkFn := func(path string, entry fs.DirEntry, err error) error {
if err != nil {
walkErrs = append(walkErrs, fmt.Errorf("walk %s: %w", path, err))
return nil // keep walking siblings
}
if err := ctx.Err(); err != nil {
return err
}
if !entry.IsDir() {
// A .git file marks a linked worktree or submodule checkout;
// its parent directory is the working tree root.
if entry.Name() == ".git" {
repos = append(repos, filepath.Dir(path))
}
return nil
}
name := entry.Name()
path = filepath.Clean(path)
// The .git directory inside a working tree: report the parent and
// never descend into git internals.
if name == ".git" {
repos = append(repos, filepath.Dir(path))
return filepath.SkipDir
}
// A bare repository: a directory named *.git (other than the .git
// handled above) that carries its own git metadata.
if strings.HasSuffix(name, ".git") && isBareRepoDir(path) {
repos = append(repos, path)
return filepath.SkipDir
}
if d.excluded(path, name) {
return filepath.SkipDir
}
if d.maxDepth > 0 && depthBelow(root, path) > d.maxDepth {
return filepath.SkipDir
}
return nil
}
if err := filepath.WalkDir(root, walkFn); err != nil {
// Distinguish cancellation from a walk-level failure.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return repos, err
}
walkErrs = append(walkErrs, err)
}
return repos, errors.Join(walkErrs...)
}
// isBareRepoDir reports whether path looks like a bare repository: it holds
// its own HEAD, objects, and refs entries.
func isBareRepoDir(path string) bool {
if _, err := os.Stat(filepath.Join(path, "HEAD")); err != nil {
return false
}
if info, err := os.Stat(filepath.Join(path, "objects")); err != nil || !info.IsDir() {
return false
}
if info, err := os.Stat(filepath.Join(path, "refs")); err != nil || !info.IsDir() {
return false
}
return true
}
// excluded reports whether path or its base name matches any pattern.
func (d *Discoverer) excluded(path, name string) bool {
for _, pat := range d.excludePatterns {
if ok, _ := filepath.Match(pat, name); ok {
return true
}
if ok, _ := filepath.Match(pat, path); ok {
return true
}
}
return false
}
// depthBelow returns how many directory levels path sits below root.
// The root itself is depth 0.
func depthBelow(root, path string) int {
rel, err := filepath.Rel(root, path)
if err != nil || rel == "." {
return 0
}
return strings.Count(rel, string(filepath.Separator)) + 1
}

View File

@ -0,0 +1,154 @@
package scanner
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
)
// buildTree creates a fixture directory tree with a mix of repository types
// and noise directories, and returns its root.
//
// root/
// ├── bare.git/ (bare repository)
// ├── deep/nested/repoC/ (working tree, depth 3)
// ├── nested/repoB/ (working tree)
// ├── node_modules/vendored/ (working tree, exclusion target)
// ├── plaindir/ (noise)
// ├── repoA/ (working tree)
// └── worktrees/wt1/ (linked worktree via .git file)
func buildTree(t *testing.T) string {
t.Helper()
root := t.TempDir()
mkdir := func(p string) string {
t.Helper()
full := filepath.Join(root, p)
if err := os.MkdirAll(full, 0o755); err != nil {
t.Fatal(err)
}
return full
}
runGit := func(dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
}
}
for _, p := range []string{"repoA", "nested/repoB", "deep/nested/repoC", "node_modules/vendored"} {
runGit(mkdir(p), "init", "-q", "-b", "main")
}
runGit(mkdir("bare.git"), "init", "-q", "--bare")
wt := mkdir("worktrees/wt1")
if err := os.WriteFile(filepath.Join(wt, ".git"), []byte("gitdir: /nonexistent/real\n"), 0o644); err != nil {
t.Fatal(err)
}
mkdir("plaindir")
return root
}
func TestDiscoverFindsAllRepos(t *testing.T) {
root := buildTree(t)
d := NewDiscoverer()
repos, err := d.Discover(context.Background(), root)
if err != nil {
t.Fatalf("Discover: %v", err)
}
want := []string{
filepath.Join(root, "bare.git"),
filepath.Join(root, "deep/nested/repoC"),
filepath.Join(root, "nested/repoB"),
filepath.Join(root, "node_modules/vendored"),
filepath.Join(root, "repoA"),
filepath.Join(root, "worktrees/wt1"),
}
if !reflect.DeepEqual(repos, want) {
t.Errorf("Discover() =\n %v\nwant\n %v", repos, want)
}
}
func TestDiscoverExcludesPatterns(t *testing.T) {
root := buildTree(t)
d := NewDiscoverer(WithExclude("node_modules"))
repos, err := d.Discover(context.Background(), root)
if err != nil {
t.Fatalf("Discover: %v", err)
}
for _, r := range repos {
if strings.Contains(r, "node_modules") {
t.Errorf("Discover() returned excluded repo %s", r)
}
}
if len(repos) != 5 {
t.Errorf("Discover() returned %d repos, want 5", len(repos))
}
}
func TestDiscoverMaxDepth(t *testing.T) {
root := buildTree(t)
d := NewDiscoverer(WithMaxDepth(2))
repos, err := d.Discover(context.Background(), root)
if err != nil {
t.Fatalf("Discover: %v", err)
}
for _, r := range repos {
if strings.Contains(r, "deep/nested/repoC") {
t.Errorf("Discover() returned repo beyond max depth: %s", r)
}
}
if len(repos) != 5 {
t.Errorf("Discover() returned %d repos, want 5 (deep/nested/repoC excluded)", len(repos))
}
}
func TestDiscoverBadRoot(t *testing.T) {
d := NewDiscoverer()
if _, err := d.Discover(context.Background(), filepath.Join(t.TempDir(), "missing")); err == nil {
t.Error("Discover(missing) succeeded, want error")
}
file := filepath.Join(t.TempDir(), "file.txt")
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := d.Discover(context.Background(), file); err == nil {
t.Error("Discover(file) succeeded, want error")
}
}
func TestDiscoverCancellation(t *testing.T) {
root := buildTree(t)
ctx, cancel := context.WithCancel(context.Background())
cancel() // cancelled up front
if _, err := NewDiscoverer().Discover(ctx, root); err == nil {
t.Error("Discover(cancelled ctx) succeeded, want error")
}
}
func TestDepthBelow(t *testing.T) {
root := "/a/b"
cases := []struct {
path string
want int
}{
{"/a/b", 0},
{"/a/b/c", 1},
{"/a/b/c/d", 2},
}
for _, tc := range cases {
if got := depthBelow(root, tc.path); got != tc.want {
t.Errorf("depthBelow(%s, %s) = %d, want %d", root, tc.path, got, tc.want)
}
}
}

View File

@ -0,0 +1,74 @@
package scanner
import (
"context"
"path/filepath"
"golang.org/x/sync/errgroup"
"gitea.oblak.solutions/dimitar/gitFlow/internal/git"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// Scanner produces status snapshots for a set of repositories, scanning them
// concurrently with a bounded worker pool.
type Scanner struct {
git *git.Executor
workers int
}
// ScannerOption configures a Scanner (functional options pattern).
type ScannerOption func(*Scanner)
// WithGitExecutor overrides the git executor used for scanning.
func WithGitExecutor(e *git.Executor) ScannerOption {
return func(s *Scanner) { s.git = e }
}
// WithWorkers sets the maximum number of concurrent git scans.
func WithWorkers(n int) ScannerOption {
return func(s *Scanner) {
if n > 0 {
s.workers = n
}
}
}
// NewScanner builds a Scanner with sane defaults.
func NewScanner(opts ...ScannerOption) *Scanner {
s := &Scanner{git: git.NewExecutor(), workers: 8}
for _, opt := range opts {
opt(s)
}
return s
}
// Scan snapshots every repository path. A repository that fails to scan is
// reported with StatusError rather than aborting the pass. The returned
// error is non-nil only when the context is cancelled mid-scan.
func (s *Scanner) Scan(ctx context.Context, paths []string) ([]status.RepoInfo, error) {
infos := make([]status.RepoInfo, len(paths))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(s.workers)
for i, path := range paths {
i, path := i, path
g.Go(func() error {
info, err := s.git.Status(ctx, path)
if err != nil {
if ctx.Err() != nil {
return ctx.Err() // cancelled mid-scan, not a repo failure
}
info = status.RepoInfo{
Path: path,
Name: filepath.Base(path),
Status: status.StatusError,
Error: err.Error(),
}
}
infos[i] = info
return nil
})
}
return infos, g.Wait()
}

View File

@ -0,0 +1,100 @@
package scanner
import (
"context"
"os/exec"
"path/filepath"
"testing"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
func newGitRepo(t *testing.T, dir string) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
cmd := exec.Command("git", "init", "-q", "-b", "main", filepath.Base(dir))
cmd.Dir = filepath.Dir(dir)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
cmd = exec.Command("git", "config", "user.email", "t@t")
cmd.Dir = dir
_ = cmd.Run()
cmd = exec.Command("git", "config", "user.name", "t")
cmd.Dir = dir
_ = cmd.Run()
return dir
}
func TestScannerScanMixed(t *testing.T) {
root := t.TempDir()
repo1 := newGitRepo(t, filepath.Join(root, "one"))
repo2 := newGitRepo(t, filepath.Join(root, "two"))
bogus := filepath.Join(root, "not-a-repo")
s := NewScanner()
infos, err := s.Scan(context.Background(), []string{repo1, repo2, bogus})
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(infos) != 3 {
t.Fatalf("Scan returned %d infos, want 3", len(infos))
}
for _, info := range infos {
switch info.Path {
case repo1, repo2:
if info.Status != status.StatusClean {
t.Errorf("%s: Status = %v, want clean", info.Path, info.Status)
}
case bogus:
if info.Status != status.StatusError {
t.Errorf("bogus: Status = %v, want error", info.Status)
}
if info.Error == "" {
t.Errorf("bogus: Error is empty")
}
}
}
}
func TestScannerConcurrentErrors(t *testing.T) {
// Many failing paths at low worker count: everything must come back as
// StatusError and no goroutine may leak or race.
root := t.TempDir()
paths := make([]string, 20)
for i := range paths {
paths[i] = filepath.Join(root, "nope", "repo", string(rune('a'+i)))
}
s := NewScanner(WithWorkers(4))
infos, err := s.Scan(context.Background(), paths)
if err != nil {
t.Fatalf("Scan: %v", err)
}
if len(infos) != len(paths) {
t.Fatalf("Scan returned %d infos, want %d", len(infos), len(paths))
}
for i, info := range infos {
if info.Status != status.StatusError {
t.Errorf("path %d: Status = %v, want error", i, info.Status)
}
}
}
func TestScannerCancellation(t *testing.T) {
root := t.TempDir()
repo := newGitRepo(t, filepath.Join(root, "one"))
paths := make([]string, 0, 10)
for i := 0; i < 10; i++ {
paths = append(paths, repo)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
if _, err := NewScanner().Scan(ctx, paths); err == nil {
t.Error("Scan(cancelled ctx) succeeded, want error")
}
}

View File

@ -0,0 +1,55 @@
// Package scheduler runs a function on a fixed interval with an immediate
// first execution, until the context is cancelled.
package scheduler
import (
"context"
"time"
)
// Scheduler triggers a run function periodically.
type Scheduler struct {
interval time.Duration
run func(ctx context.Context) error
}
// New creates a Scheduler that calls run immediately and then every
// interval. An interval <= 0 means run once and stop.
func New(interval time.Duration, run func(ctx context.Context) error) *Scheduler {
return &Scheduler{interval: interval, run: run}
}
// Run executes run immediately and then every interval until ctx is
// cancelled. A run error stops the loop and is returned, except when the
// context was cancelled (graceful shutdown is not an error).
func (s *Scheduler) Run(ctx context.Context) error {
if err := ctx.Err(); err != nil {
return nil
}
if err := s.run(ctx); err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
if s.interval <= 0 {
return nil
}
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := s.run(ctx); err != nil {
if ctx.Err() != nil {
return nil
}
return err
}
}
}
}

View File

@ -0,0 +1,95 @@
package scheduler
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
)
func TestRunOnce(t *testing.T) {
var calls atomic.Int32
s := New(0, func(ctx context.Context) error {
calls.Add(1)
return nil
})
if err := s.Run(context.Background()); err != nil {
t.Fatalf("Run: %v", err)
}
if got := calls.Load(); got != 1 {
t.Errorf("run called %d times, want 1", got)
}
}
func TestRunPeriodic(t *testing.T) {
var calls atomic.Int32
s := New(10*time.Millisecond, func(ctx context.Context) error {
calls.Add(1)
return nil
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- s.Run(ctx) }()
time.Sleep(55 * time.Millisecond)
cancel()
if err := <-done; err != nil {
t.Fatalf("Run: %v", err)
}
if got := calls.Load(); got < 3 {
t.Errorf("run called %d times, want >= 3", got)
}
}
func TestRunStopsOnError(t *testing.T) {
var calls atomic.Int32
wantErr := errors.New("boom")
s := New(5*time.Millisecond, func(ctx context.Context) error {
n := calls.Add(1)
if n == 2 {
return wantErr
}
return nil
})
if err := s.Run(context.Background()); !errors.Is(err, wantErr) {
t.Fatalf("Run() error = %v, want %v", err, wantErr)
}
if got := calls.Load(); got != 2 {
t.Errorf("run called %d times, want 2", got)
}
}
func TestRunCancelledBeforeStart(t *testing.T) {
var calls atomic.Int32
s := New(time.Second, func(ctx context.Context) error {
calls.Add(1)
return nil
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := s.Run(ctx); err != nil {
t.Fatalf("Run: %v", err)
}
if got := calls.Load(); got != 0 {
t.Errorf("run called %d times, want 0", got)
}
}
func TestRunCancellationIsNotAnError(t *testing.T) {
var calls atomic.Int32
s := New(5*time.Millisecond, func(ctx context.Context) error {
calls.Add(1)
time.Sleep(50 * time.Millisecond) // outlive cancellation
return ctx.Err()
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- s.Run(ctx) }()
time.Sleep(12 * time.Millisecond)
cancel()
if err := <-done; err != nil {
t.Fatalf("Run returned %v on cancellation, want nil", err)
}
}

View File

@ -0,0 +1,8 @@
// Package version exposes build metadata for the gitflow binary.
package version
// Version is the semantic version of the binary. It can be overridden at
// build time with:
//
// go build -ldflags "-X gitea.oblak.solutions/dimitar/gitFlow/internal/version.Version=v1.2.3" ./cmd/gitflow
var Version = "dev"

199
pkg/status/status.go Normal file
View File

@ -0,0 +1,199 @@
// Package status defines the domain model shared across gitflow: how a Git
// repository's state is captured, classified, and summarised after a scan.
package status
import (
"encoding/json"
"fmt"
"time"
)
// RepoStatus classifies the overall health of a repository at scan time.
//
// The zero value is StatusUnknown, an invalid/unset state, so a zero-valued
// RepoInfo can never be mistaken for a real scan result.
type RepoStatus int
const (
StatusUnknown RepoStatus = iota // 0 — invalid/unset
StatusClean // 1 — working tree clean, in sync
StatusModified // 2 — staged, modified, or untracked files
StatusAhead // 3 — local commits not yet pushed
StatusBehind // 4 — remote commits not yet pulled
StatusDiverged // 5 — both ahead of and behind upstream
StatusDetached // 6 — HEAD points at a commit, not a branch
StatusBare // 7 — bare repository, no working tree
StatusError // 8 — could not be scanned
)
// String returns a lowercase, human-readable label for the status.
func (s RepoStatus) String() string {
switch s {
case StatusClean:
return "clean"
case StatusModified:
return "modified"
case StatusAhead:
return "ahead"
case StatusBehind:
return "behind"
case StatusDiverged:
return "diverged"
case StatusDetached:
return "detached"
case StatusBare:
return "bare"
case StatusError:
return "error"
default:
return "unknown"
}
}
// NeedsAttention reports whether the repository asks for human action.
func (s RepoStatus) NeedsAttention() bool {
return s != StatusClean && s != StatusUnknown
}
// MarshalJSON renders the status as its string label, e.g. "modified", so
// machine-readable output stays human-readable.
func (s RepoStatus) MarshalJSON() ([]byte, error) {
return json.Marshal(s.String())
}
// UnmarshalJSON accepts either a string label or a numeric value, so JSON
// output can be fed back into the type.
func (s *RepoStatus) UnmarshalJSON(b []byte) error {
var label string
if err := json.Unmarshal(b, &label); err == nil {
switch label {
case "clean":
*s = StatusClean
case "modified":
*s = StatusModified
case "ahead":
*s = StatusAhead
case "behind":
*s = StatusBehind
case "diverged":
*s = StatusDiverged
case "detached":
*s = StatusDetached
case "bare":
*s = StatusBare
case "error":
*s = StatusError
default:
*s = StatusUnknown
}
return nil
}
var n int
if err := json.Unmarshal(b, &n); err == nil {
*s = RepoStatus(n)
return nil
}
return fmt.Errorf("status: cannot unmarshal %s as RepoStatus", b)
}
// RepoInfo is a full snapshot of a single repository at scan time.
type RepoInfo struct {
Path string `json:"path"`
Name string `json:"name"`
RemoteURL string `json:"remote_url,omitempty"`
Branch string `json:"branch,omitempty"`
Detached bool `json:"detached,omitempty"`
Status RepoStatus `json:"status"`
StagedFiles []string `json:"staged_files,omitempty"`
ModifiedFiles []string `json:"modified_files,omitempty"`
UntrackedFiles []string `json:"untracked_files,omitempty"`
AheadBy int `json:"ahead_by,omitempty"`
BehindBy int `json:"behind_by,omitempty"`
StashCount int `json:"stash_count,omitempty"`
Error string `json:"error,omitempty"`
}
// FileCount returns the total number of files with any local change.
func (r RepoInfo) FileCount() int {
return len(r.StagedFiles) + len(r.ModifiedFiles) + len(r.UntrackedFiles)
}
// ScanResult is the outcome of one scan pass over a set of repositories.
type ScanResult struct {
ScannedAt time.Time `json:"scanned_at"`
ParentDir string `json:"parent_dir"`
Repos []RepoInfo `json:"repos"`
}
// Summary aggregates per-repository counters for the whole result.
type Summary struct {
Total int `json:"total"`
Clean int `json:"clean"`
Attention int `json:"attention"`
Errored int `json:"errored"`
Staged int `json:"staged"`
Modified int `json:"modified"`
Untracked int `json:"untracked"`
}
// Summary computes the aggregate counters for the result.
func (r ScanResult) Summary() Summary {
s := Summary{Total: len(r.Repos)}
for _, repo := range r.Repos {
switch repo.Status {
case StatusClean:
s.Clean++
case StatusError:
s.Errored++
case StatusUnknown:
// Neither clean nor actionable; not counted.
default:
s.Attention++
}
s.Staged += len(repo.StagedFiles)
s.Modified += len(repo.ModifiedFiles)
s.Untracked += len(repo.UntrackedFiles)
}
return s
}
// NeedsAttention lists repositories whose status is anything but clean.
func (r ScanResult) NeedsAttention() []RepoInfo {
out := make([]RepoInfo, 0, len(r.Repos))
for _, repo := range r.Repos {
if repo.Status.NeedsAttention() {
out = append(out, repo)
}
}
return out
}
// Changed reports the repositories whose observable state differs between
// prev and next, in next's order. Repositories that appear only in next
// (or vanished from prev) count as changed.
func Changed(prev, next ScanResult) []RepoInfo {
byPath := make(map[string]RepoInfo, len(prev.Repos))
for _, r := range prev.Repos {
byPath[r.Path] = r
}
out := make([]RepoInfo, 0, len(next.Repos))
for _, r := range next.Repos {
p, ok := byPath[r.Path]
if !ok || !sameState(p, r) {
out = append(out, r)
}
}
return out
}
// sameState reports whether two snapshots of the same repository are
// observably identical.
func sameState(a, b RepoInfo) bool {
return a.Status == b.Status &&
a.Branch == b.Branch &&
a.Detached == b.Detached &&
a.AheadBy == b.AheadBy &&
a.BehindBy == b.BehindBy &&
a.StashCount == b.StashCount &&
a.FileCount() == b.FileCount()
}

151
pkg/status/status_test.go Normal file
View File

@ -0,0 +1,151 @@
package status
import (
"encoding/json"
"testing"
"time"
)
func TestRepoStatusString(t *testing.T) {
cases := []struct {
s RepoStatus
want string
}{
{StatusUnknown, "unknown"},
{StatusClean, "clean"},
{StatusModified, "modified"},
{StatusAhead, "ahead"},
{StatusBehind, "behind"},
{StatusDiverged, "diverged"},
{StatusDetached, "detached"},
{StatusBare, "bare"},
{StatusError, "error"},
}
for _, tc := range cases {
if got := tc.s.String(); got != tc.want {
t.Errorf("RepoStatus(%d).String() = %q, want %q", tc.s, got, tc.want)
}
}
}
func TestRepoStatusNeedsAttention(t *testing.T) {
cases := []struct {
s RepoStatus
want bool
}{
{StatusUnknown, false},
{StatusClean, false},
{StatusModified, true},
{StatusAhead, true},
{StatusBehind, true},
{StatusDiverged, true},
{StatusDetached, true},
{StatusBare, true},
{StatusError, true},
}
for _, tc := range cases {
if got := tc.s.NeedsAttention(); got != tc.want {
t.Errorf("RepoStatus(%d).NeedsAttention() = %v, want %v", tc.s, got, tc.want)
}
}
}
func TestRepoInfoFileCount(t *testing.T) {
info := RepoInfo{
StagedFiles: []string{"a", "b"},
ModifiedFiles: []string{"c"},
UntrackedFiles: []string{"d", "e", "f"},
}
if got := info.FileCount(); got != 6 {
t.Errorf("FileCount() = %d, want 6", got)
}
}
func TestRepoStatusJSONRoundTrip(t *testing.T) {
all := []RepoStatus{
StatusUnknown, StatusClean, StatusModified, StatusAhead, StatusBehind,
StatusDiverged, StatusDetached, StatusBare, StatusError,
}
for _, s := range all {
b, err := json.Marshal(s)
if err != nil {
t.Fatalf("Marshal(%d): %v", s, err)
}
var got RepoStatus
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("Unmarshal(%s): %v", b, err)
}
if got != s {
t.Errorf("round trip of %s = %v", s, got)
}
}
}
func TestScanResultSummary(t *testing.T) {
result := ScanResult{
ScannedAt: time.Now(),
ParentDir: "/tmp",
Repos: []RepoInfo{
{Status: StatusClean},
{Status: StatusModified, StagedFiles: []string{"a"}, ModifiedFiles: []string{"b"}},
{Status: StatusDiverged, UntrackedFiles: []string{"c"}},
{Status: StatusError, Error: "boom"},
{Status: StatusUnknown},
},
}
got := result.Summary()
want := Summary{Total: 5, Clean: 1, Attention: 2, Errored: 1, Staged: 1, Modified: 1, Untracked: 1}
if got != want {
t.Errorf("Summary() = %+v, want %+v", got, want)
}
}
func TestScanResultNeedsAttention(t *testing.T) {
result := ScanResult{
Repos: []RepoInfo{
{Path: "/a", Status: StatusClean},
{Path: "/b", Status: StatusBehind},
{Path: "/c", Status: StatusError},
},
}
got := result.NeedsAttention()
if len(got) != 2 {
t.Fatalf("NeedsAttention() returned %d repos, want 2", len(got))
}
for _, r := range got {
if r.Path == "/a" {
t.Errorf("clean repo /a listed as needing attention")
}
}
}
func TestChanged(t *testing.T) {
prev := ScanResult{Repos: []RepoInfo{
{Path: "/a", Status: StatusClean},
{Path: "/b", Status: StatusModified, ModifiedFiles: []string{"x"}},
{Path: "/c", Status: StatusAhead, AheadBy: 2},
}}
next := ScanResult{Repos: []RepoInfo{
{Path: "/a", Status: StatusClean}, // unchanged
{Path: "/b", Status: StatusModified, ModifiedFiles: []string{"x"}, UntrackedFiles: []string{"y"}}, // file count changed
{Path: "/c", Status: StatusClean}, // status changed
{Path: "/d", Status: StatusBehind, BehindBy: 3}, // newly appeared
}}
got := Changed(prev, next)
byPath := make(map[string]bool, len(got))
for _, r := range got {
byPath[r.Path] = true
}
if byPath["/a"] {
t.Error("/a reported as changed but state is identical")
}
for _, want := range []string{"/b", "/c", "/d"} {
if !byPath[want] {
t.Errorf("%s not reported as changed", want)
}
}
if len(got) != 3 {
t.Errorf("Changed() returned %d repos, want 3", len(got))
}
}

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