Compare commits
14 Commits
3ef7c601bd
...
982012c867
| Author | SHA1 | Date | |
|---|---|---|---|
| 982012c867 | |||
| 012fbf8ac5 | |||
| fe310188ac | |||
| aee63bf321 | |||
| 6e945fc13b | |||
| 0c9c07e8de | |||
| 5fe5eb5af8 | |||
| c9ff44298c | |||
| baa2b8d910 | |||
| d52e0715f2 | |||
| 263b02b846 | |||
| ae23019277 | |||
| 48c6438ae7 | |||
| 9761adf3f9 |
37
.github/workflows/ci.yml
vendored
Normal file
37
.github/workflows/ci.yml
vendored
Normal 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
17
.gitignore
vendored
Normal 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
24
.golangci.yml
Normal 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
35
Makefile
Normal 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
16
cmd/gitflow/main.go
Normal 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
56
cmd/gitflow/prompt.go
Normal 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
77
cmd/gitflow/root.go
Normal 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
135
cmd/gitflow/scan.go
Normal 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
125
cmd/gitflow/watch.go
Normal 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
32
go.mod
Normal 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
79
go.sum
Normal 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=
|
||||
@ -291,3 +291,37 @@ Construct a prompt that includes:
|
||||
| **M7 — Polish** | Phase 6: TUI, themes, completions, docs | 2–3 days |
|
||||
|
||||
**Total estimated effort**: ~7–10 days for a solid v1.0
|
||||
|
||||
---
|
||||
|
||||
## Implementation Progress
|
||||
|
||||
All phases from this plan are implemented and merged into `main` (branch
|
||||
per phase, merged with `--no-ff`, no remote pushes).
|
||||
|
||||
| Phase | Branch | Status |
|
||||
|---|---|---|
|
||||
| 0 — Scaffolding | `feat/phase-0-scaffolding` | ✅ done |
|
||||
| 1 — Discovery & scanning | `feat/phase-1-discovery-scanning` | ✅ done |
|
||||
| 2 — CLI & configuration | `feat/phase-2-cli-config` | ✅ done |
|
||||
| 3 — Presentation | `feat/phase-3-presentation` | ✅ done |
|
||||
| 4 — Scheduler / watch | `feat/phase-4-scheduler` | ✅ done |
|
||||
| 5 — AI agent | `feat/phase-5-ai` | ✅ done |
|
||||
| 6 — Polish | `feat/phase-6-polish` | ✅ done |
|
||||
|
||||
### Deviations from the plan
|
||||
|
||||
- **`LastFetch` field dropped** (phase 1): the only reliable source is a
|
||||
reflog of the remote-tracking ref, which does not exist on fresh clones;
|
||||
noted as future work instead.
|
||||
- **Full bubbletea TUI deferred** (phase 6): the plan listed it as
|
||||
optional; watch mode + completions + themes + notifications were
|
||||
implemented instead.
|
||||
- **Webhooks deferred** (phase 6): desktop notifications cover the
|
||||
alerting case; Slack/Discord noted as future work.
|
||||
- **AI defaults**: `ai-model` defaults to `gpt-4o` (plan) with
|
||||
provider-specific fallbacks (ollama → `llama3.2`, anthropic →
|
||||
`claude-3-5-haiku-latest`); `--ai-execute` implemented with an
|
||||
allowlist + per-command confirmation instead of a bare auto-run.
|
||||
- **testify** was not added: stdlib `testing` covers all suites, keeping
|
||||
the dependency tree minimal.
|
||||
|
||||
54
internal/ai/ai.go
Normal file
54
internal/ai/ai.go
Normal 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
266
internal/ai/ai_test.go
Normal 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
82
internal/ai/anthropic.go
Normal 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
56
internal/ai/client.go
Normal 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
58
internal/ai/execute.go
Normal 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
|
||||
}
|
||||
73
internal/ai/execute_test.go
Normal file
73
internal/ai/execute_test.go
Normal 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
69
internal/ai/ollama.go
Normal 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
87
internal/ai/openai.go
Normal 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
66
internal/ai/parse.go
Normal 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
69
internal/ai/prompt.go
Normal 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
64
internal/app/app.go
Normal 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
74
internal/app/app_test.go
Normal 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
256
internal/config/config.go
Normal 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, ¬Found) {
|
||||
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
|
||||
}
|
||||
274
internal/config/config_test.go
Normal file
274
internal/config/config_test.go
Normal 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
80
internal/git/exec.go
Normal 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
161
internal/git/status.go
Normal 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
241
internal/git/status_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
9
internal/notify/notify.go
Normal file
9
internal/notify/notify.go
Normal file
@ -0,0 +1,9 @@
|
||||
// Package notify sends desktop notifications when repositories change while
|
||||
// watching. Platforms without a notifier are silent no-ops, never errors.
|
||||
package notify
|
||||
|
||||
// Send posts a desktop notification. It returns nil when no notifier is
|
||||
// available; a non-nil error means a notifier was found but failed.
|
||||
func Send(title, body string) error {
|
||||
return send(title, body)
|
||||
}
|
||||
27
internal/notify/notify_test.go
Normal file
27
internal/notify/notify_test.go
Normal file
@ -0,0 +1,27 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// hasNotifier reports whether a desktop notifier is installed, so tests can
|
||||
// avoid firing real notifications on developer machines.
|
||||
func hasNotifier() bool {
|
||||
for _, bin := range []string{"notify-send", "osascript"} {
|
||||
if _, err := exec.LookPath(bin); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestSendWithoutNotifier(t *testing.T) {
|
||||
if hasNotifier() {
|
||||
t.Skip("a desktop notifier is installed; skipping to avoid firing notifications")
|
||||
}
|
||||
// Without a notifier, Send must be a silent no-op.
|
||||
if err := Send("gitflow test", "no notifier available"); err != nil {
|
||||
t.Errorf("Send: %v", err)
|
||||
}
|
||||
}
|
||||
21
internal/notify/notify_unix.go
Normal file
21
internal/notify/notify_unix.go
Normal file
@ -0,0 +1,21 @@
|
||||
//go:build linux || darwin
|
||||
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
// send prefers notify-send (Linux) and falls back to osascript (macOS).
|
||||
// If neither is installed the notification is dropped silently.
|
||||
func send(title, body string) error {
|
||||
if _, err := exec.LookPath("notify-send"); err == nil {
|
||||
return exec.Command("notify-send", "-a", "gitflow", "--", title, body).Run()
|
||||
}
|
||||
if _, err := exec.LookPath("osascript"); err == nil {
|
||||
script := fmt.Sprintf("display notification %q with title %q", body, title)
|
||||
return exec.Command("osascript", "-e", script).Run()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
9
internal/notify/notify_windows.go
Normal file
9
internal/notify/notify_windows.go
Normal file
@ -0,0 +1,9 @@
|
||||
//go:build windows
|
||||
|
||||
package notify
|
||||
|
||||
// send is a no-op on Windows for now; a PowerShell toast bridge can be
|
||||
// added later.
|
||||
func send(title, body string) error {
|
||||
return nil
|
||||
}
|
||||
63
internal/presenter/compact.go
Normal file
63
internal/presenter/compact.go
Normal 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 "?"
|
||||
}
|
||||
}
|
||||
25
internal/presenter/flags.go
Normal file
25
internal/presenter/flags.go
Normal file
@ -0,0 +1,25 @@
|
||||
package presenter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"text/tabwriter"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/rules"
|
||||
)
|
||||
|
||||
// Flags renders user-rule matches below a scan result.
|
||||
func Flags(w io.Writer, opts Options, flags []rules.Flag) error {
|
||||
if len(flags) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
r := newRendererTheme(opts.Color, opts.Theme, w)
|
||||
fmt.Fprintln(w, "\nFLAGS (custom rules)")
|
||||
tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "REPOSITORY\tFLAG")
|
||||
for _, f := range flags {
|
||||
fmt.Fprintf(tw, "%s\t%s\n", shortPath(f.RepoPath), r.yellow+f.Label+r.reset)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
30
internal/presenter/json.go
Normal file
30
internal/presenter/json.go
Normal 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)
|
||||
}
|
||||
184
internal/presenter/presenter.go
Normal file
184
internal/presenter/presenter.go
Normal 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)
|
||||
}
|
||||
236
internal/presenter/presenter_test.go
Normal file
236
internal/presenter/presenter_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
48
internal/presenter/suggestions.go
Normal file
48
internal/presenter/suggestions.go
Normal 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"
|
||||
}
|
||||
}
|
||||
75
internal/presenter/table.go
Normal file
75
internal/presenter/table.go
Normal 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
104
internal/rules/rules.go
Normal file
@ -0,0 +1,104 @@
|
||||
// Package rules evaluates user-defined threshold rules against a scan
|
||||
// result, producing labels for repositories that match (e.g. flag a branch
|
||||
// as "stale" when it is 10+ commits behind).
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
// Rule flags a repository when a numeric field crosses a threshold.
|
||||
type Rule struct {
|
||||
Name string `yaml:"name"` // descriptive name, for config output
|
||||
Field string `yaml:"field"` // ahead, behind, stash, or changes
|
||||
Op string `yaml:"op"` // ==, !=, <, <=, >, >=
|
||||
Value int `yaml:"value"` // threshold to compare against
|
||||
Label string `yaml:"label"` // flag label, e.g. "critical"
|
||||
}
|
||||
|
||||
// Validate checks that the rule can be evaluated.
|
||||
func (r Rule) Validate() error {
|
||||
switch r.Field {
|
||||
case "ahead", "behind", "stash", "changes":
|
||||
default:
|
||||
return fmt.Errorf("rules: %s: unknown field %q (want ahead, behind, stash, or changes)", r.Name, r.Field)
|
||||
}
|
||||
switch r.Op {
|
||||
case "==", "!=", "<", "<=", ">", ">=":
|
||||
default:
|
||||
return fmt.Errorf("rules: %s: unknown operator %q (want ==, !=, <, <=, >, >=)", r.Name, r.Op)
|
||||
}
|
||||
if r.Label == "" {
|
||||
return fmt.Errorf("rules: %s: label must not be empty", r.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Flag is a rule match on a repository.
|
||||
type Flag struct {
|
||||
RepoPath string
|
||||
Label string
|
||||
}
|
||||
|
||||
// Matches reports whether the rule matches the repository.
|
||||
func (r Rule) Matches(repo status.RepoInfo) (bool, error) {
|
||||
v, err := fieldValue(r.Field, repo)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch r.Op {
|
||||
case "==":
|
||||
return v == r.Value, nil
|
||||
case "!=":
|
||||
return v != r.Value, nil
|
||||
case "<":
|
||||
return v < r.Value, nil
|
||||
case "<=":
|
||||
return v <= r.Value, nil
|
||||
case ">":
|
||||
return v > r.Value, nil
|
||||
case ">=":
|
||||
return v >= r.Value, nil
|
||||
}
|
||||
return false, fmt.Errorf("rules: %s: unknown operator %q", r.Name, r.Op)
|
||||
}
|
||||
|
||||
// Eval applies the rules to the result and returns every match, in scan
|
||||
// order. Rules are validated up front; an invalid rule is an error.
|
||||
func Eval(rules []Rule, result status.ScanResult) ([]Flag, error) {
|
||||
for _, rule := range rules {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var out []Flag
|
||||
for _, rule := range rules {
|
||||
for _, repo := range result.Repos {
|
||||
ok, err := rule.Matches(repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok {
|
||||
out = append(out, Flag{RepoPath: repo.Path, Label: rule.Label})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// fieldValue extracts the numeric field a rule compares against.
|
||||
func fieldValue(field string, repo status.RepoInfo) (int, error) {
|
||||
switch field {
|
||||
case "ahead":
|
||||
return repo.AheadBy, nil
|
||||
case "behind":
|
||||
return repo.BehindBy, nil
|
||||
case "stash":
|
||||
return repo.StashCount, nil
|
||||
case "changes":
|
||||
return repo.FileCount(), nil
|
||||
}
|
||||
return 0, fmt.Errorf("rules: unknown field %q", field)
|
||||
}
|
||||
84
internal/rules/rules_test.go
Normal file
84
internal/rules/rules_test.go
Normal file
@ -0,0 +1,84 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
||||
)
|
||||
|
||||
func TestRuleValidate(t *testing.T) {
|
||||
good := Rule{Name: "stale", Field: "behind", Op: ">=", Value: 10, Label: "stale"}
|
||||
if err := good.Validate(); err != nil {
|
||||
t.Errorf("Validate(good): %v", err)
|
||||
}
|
||||
bad := []Rule{
|
||||
{Name: "a", Field: "nope", Op: ">=", Label: "x"},
|
||||
{Name: "b", Field: "behind", Op: "~", Label: "x"},
|
||||
{Name: "c", Field: "behind", Op: ">=", Label: ""},
|
||||
}
|
||||
for _, r := range bad {
|
||||
if err := r.Validate(); err == nil {
|
||||
t.Errorf("Validate(%+v) succeeded, want error", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleMatches(t *testing.T) {
|
||||
repo := status.RepoInfo{BehindBy: 12, StashCount: 1, ModifiedFiles: []string{"a"}}
|
||||
|
||||
cases := []struct {
|
||||
rule Rule
|
||||
want bool
|
||||
}{
|
||||
{Rule{Field: "behind", Op: ">=", Value: 10}, true},
|
||||
{Rule{Field: "behind", Op: ">=", Value: 13}, false},
|
||||
{Rule{Field: "behind", Op: "==", Value: 12}, true},
|
||||
{Rule{Field: "stash", Op: ">", Value: 0}, true},
|
||||
{Rule{Field: "stash", Op: "==", Value: 0}, false},
|
||||
{Rule{Field: "changes", Op: ">=", Value: 1}, true},
|
||||
{Rule{Field: "ahead", Op: "==", Value: 0}, true},
|
||||
{Rule{Field: "changes", Op: "!=", Value: 3}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := tc.rule.Matches(repo)
|
||||
if err != nil {
|
||||
t.Errorf("Matches(%+v): %v", tc.rule, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("Matches(%+v) = %v, want %v", tc.rule, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEval(t *testing.T) {
|
||||
rules := []Rule{
|
||||
{Name: "stale", Field: "behind", Op: ">=", Value: 10, Label: "stale"},
|
||||
{Name: "dirty", Field: "changes", Op: ">=", Value: 1, Label: "dirty"},
|
||||
}
|
||||
result := status.ScanResult{Repos: []status.RepoInfo{
|
||||
{Path: "/a", BehindBy: 15},
|
||||
{Path: "/b", ModifiedFiles: []string{"x"}},
|
||||
{Path: "/c"},
|
||||
}}
|
||||
flags, err := Eval(rules, result)
|
||||
if err != nil {
|
||||
t.Fatalf("Eval: %v", err)
|
||||
}
|
||||
want := []Flag{{RepoPath: "/a", Label: "stale"}, {RepoPath: "/b", Label: "dirty"}}
|
||||
if len(flags) != len(want) {
|
||||
t.Fatalf("Eval returned %d flags, want %d: %+v", len(flags), len(want), flags)
|
||||
}
|
||||
for i, f := range flags {
|
||||
if f != want[i] {
|
||||
t.Errorf("flag[%d] = %+v, want %+v", i, f, want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalInvalidRule(t *testing.T) {
|
||||
rules := []Rule{{Name: "bad", Field: "nope", Op: ">=", Label: "x"}}
|
||||
if _, err := Eval(rules, status.ScanResult{}); err == nil {
|
||||
t.Error("Eval(invalid rule) succeeded, want error")
|
||||
}
|
||||
}
|
||||
165
internal/scanner/discover.go
Normal file
165
internal/scanner/discover.go
Normal 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
|
||||
}
|
||||
154
internal/scanner/discover_test.go
Normal file
154
internal/scanner/discover_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
74
internal/scanner/scanner.go
Normal file
74
internal/scanner/scanner.go
Normal 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()
|
||||
}
|
||||
100
internal/scanner/scanner_test.go
Normal file
100
internal/scanner/scanner_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
55
internal/scheduler/scheduler.go
Normal file
55
internal/scheduler/scheduler.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
95
internal/scheduler/scheduler_test.go
Normal file
95
internal/scheduler/scheduler_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
8
internal/version/version.go
Normal file
8
internal/version/version.go
Normal 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
199
pkg/status/status.go
Normal 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
151
pkg/status/status_test.go
Normal 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
204
readme.md
@ -1,8 +1,198 @@
|
||||
# git flow is CLI app writen go.
|
||||
# gitflow
|
||||
|
||||
App will explore [] all git repos leaving on our machine.
|
||||
it will ask for parent directory.
|
||||
will scan status of our repos
|
||||
and present it to the user.
|
||||
app will scan repos in set interval [and present findings, integrated ai agent
|
||||
will suggest next steps/actions]
|
||||
**gitflow** is a CLI tool written in Go that explores every Git repository
|
||||
under a parent directory, scans each one's status, and presents the
|
||||
findings. It can rescan on a schedule — flagging repositories as they
|
||||
change — and, when enabled, uses an integrated AI agent to suggest the next
|
||||
actions for repositories that need attention.
|
||||
|
||||
## Features
|
||||
|
||||
- **Repo discovery** — walks a directory tree and finds working trees,
|
||||
linked worktrees/submodule checkouts, and bare repositories, without
|
||||
descending into `.git` internals
|
||||
- **Status scanning** — parses `git status --porcelain=v2` for each repo:
|
||||
branch, detached HEAD, staged/modified/untracked files, ahead/behind
|
||||
counts, stash count, and remote URL; scans run concurrently with a
|
||||
bounded worker pool
|
||||
- **Three output formats** — aligned colorized table (default), indented
|
||||
JSON for scripting, and a compact one-line-per-repo view
|
||||
- **Watch mode** — rescan on an interval with change detection, desktop
|
||||
notifications, and graceful Ctrl-C shutdown
|
||||
- **AI agent** — OpenAI, Ollama (local), or Anthropic providers suggest
|
||||
concrete next steps (`commit`, `push`, `pull`, …) for repositories that
|
||||
need attention
|
||||
- **Custom rules** — user-defined threshold rules flag repositories (e.g.
|
||||
"behind ≥ 10 commits" ⇒ `stale`)
|
||||
- **Light/dark themes**, `NO_COLOR` support, and shell completions
|
||||
|
||||
## Installation
|
||||
|
||||
Requires Go 1.24+ and `git` on the `PATH`.
|
||||
|
||||
```sh
|
||||
go install gitea.oblak.solutions/dimitar/gitFlow/cmd/gitflow@latest
|
||||
# or build from a checkout:
|
||||
make build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
gitflow scan [flags] Scan repositories under a directory once
|
||||
gitflow watch [flags] Repeatedly scan on an interval
|
||||
gitflow config Show the effective configuration
|
||||
gitflow completion [shell] Generate shell completions (bash/zsh/fish/powershell)
|
||||
gitflow version Print version information
|
||||
```
|
||||
|
||||
When no `--dir` is given and stdin is a terminal, gitflow asks for the
|
||||
parent directory to scan, per the README's original design.
|
||||
|
||||
### Examples
|
||||
|
||||
```sh
|
||||
# One-shot scan of ~/projects (prompts for the directory if omitted)
|
||||
gitflow scan
|
||||
|
||||
# JSON output for scripting
|
||||
gitflow scan -d ~/projects -f json
|
||||
|
||||
# Skip dependency directories and cap traversal depth
|
||||
gitflow scan -d ~ --exclude node_modules --exclude vendor --max-depth 3
|
||||
|
||||
# Watch every 30 seconds, notifying when repositories change
|
||||
gitflow watch -d ~/projects -i 30s --notify
|
||||
|
||||
# AI suggestions via OpenAI (exports OPENAI_API_KEY or ai.api_key_env)
|
||||
gitflow scan -d ~/projects --ai
|
||||
|
||||
# AI suggestions via a local Ollama server
|
||||
gitflow scan -d ~/projects --ai --ai-provider ollama --ai-model llama3.2
|
||||
|
||||
# Custom rules
|
||||
gitflow scan -d ~/projects # with rules: in ~/.gitflow.yaml
|
||||
|
||||
# Shell completion
|
||||
eval "$(gitflow completion bash)"
|
||||
```
|
||||
|
||||
### Flags
|
||||
|
||||
| Flag | Default | Description |
|
||||
|---|---|---|
|
||||
| `-d, --dir` | `.` | Parent directory to scan |
|
||||
| `-i, --interval` | `0` | Rescan interval (e.g. `30s`, `5m`); `0` runs once |
|
||||
| `-f, --format` | `table` | Output format: `table`, `json`, or `compact` |
|
||||
| `--color` | `auto` | Color output: `auto`, `always`, or `never` |
|
||||
| `--theme` | `dark` | Color theme: `dark` or `light` |
|
||||
| `--exclude` | – | Glob patterns of directories to skip (repeatable) |
|
||||
| `--max-depth` | `0` | Maximum directory depth to scan (`0` = unlimited) |
|
||||
| `--workers` | `8` | Number of concurrent git scans |
|
||||
| `--notify` | `false` | Desktop notifications on watch changes |
|
||||
| `--ai` | `false` | Enable AI suggestions |
|
||||
| `--ai-provider` | `openai` | `openai`, `ollama`, or `anthropic` |
|
||||
| `--ai-model` | `gpt-4o` | Model name (provider default when empty) |
|
||||
| `--ai-execute` | `false` | Run confirmed AI-suggested commands (experimental) |
|
||||
|
||||
### Status classes
|
||||
|
||||
`clean` · `modified` · `ahead` · `behind` · `diverged` · `detached` ·
|
||||
`bare` · `error`
|
||||
|
||||
## Configuration
|
||||
|
||||
Settings are resolved with the precedence **flags > environment >
|
||||
`~/.gitflow.yaml` > defaults**. Environment variables use a `GITFLOW_`
|
||||
prefix with dots as underscores (e.g. `GITFLOW_FORMAT=json`,
|
||||
`GITFLOW_AI_PROVIDER=ollama`). Use `GITFLOW_CONFIG=/path/to/file.yaml` to
|
||||
point at a specific config file.
|
||||
|
||||
```yaml
|
||||
# ~/.gitflow.yaml
|
||||
dir: ~/projects
|
||||
interval: 5m
|
||||
format: table
|
||||
color: auto
|
||||
theme: dark
|
||||
notify: true
|
||||
max_depth: 3
|
||||
workers: 8
|
||||
exclude:
|
||||
- node_modules
|
||||
- vendor
|
||||
ai:
|
||||
enabled: true
|
||||
provider: openai # openai | ollama | anthropic
|
||||
model: gpt-4o
|
||||
api_key_env: OPENAI_API_KEY
|
||||
base_url: "" # provider endpoint override
|
||||
execute: false # run confirmed AI-suggested commands
|
||||
rules:
|
||||
- name: stale
|
||||
field: behind # ahead | behind | stash | changes
|
||||
op: ">=" # == | != | < | <= | > | >=
|
||||
value: 10
|
||||
label: stale
|
||||
```
|
||||
|
||||
## AI agent
|
||||
|
||||
With `--ai` (or `ai.enabled`), gitflow renders a summary of the scan to the
|
||||
configured provider and displays a `AI SUGGESTIONS` section below the
|
||||
table, color-coded by priority:
|
||||
|
||||
```
|
||||
AI SUGGESTIONS
|
||||
REPOSITORY ACTION PRIORITY MESSAGE COMMAND
|
||||
~/projects/web commit high commit the untracked file git add -A && git commit -m wip
|
||||
```
|
||||
|
||||
Suggestions include `repo_path`, `action`, `message`, a concrete `command`
|
||||
when one is safe, and a `priority` (low/medium/high). AI failures degrade
|
||||
to a warning — a scan result is always shown. In watch mode the agent is
|
||||
consulted only on the first frame and when something changed, so the
|
||||
provider is not called on every interval.
|
||||
|
||||
### `--ai-execute` (experimental)
|
||||
|
||||
Runs the commands of AI suggestions **only** after explicit per-command
|
||||
confirmation (`y/N`) and **only** for actions on an allowlist
|
||||
(`commit`, `push`, `pull`, `stash`, `checkout`) — LLM output can never run
|
||||
arbitrary shell commands. Treat this feature as experimental.
|
||||
|
||||
## Development
|
||||
|
||||
```
|
||||
make build # build the binary (VERSION=... to stamp the version)
|
||||
make test # run the full test suite
|
||||
make lint # golangci-lint
|
||||
```
|
||||
|
||||
Layout:
|
||||
|
||||
```
|
||||
cmd/gitflow/ CLI commands (scan, watch, config, version, completion)
|
||||
internal/ai/ AI providers (OpenAI, Ollama, Anthropic) + prompt + execution
|
||||
internal/app/ orchestration: discovery → scan → result
|
||||
internal/config/ flags, env, and config-file resolution
|
||||
internal/git/ porcelain v2 git wrapper
|
||||
internal/notify/ desktop notifications (Linux/macOS; Windows no-op)
|
||||
internal/presenter/ table / json / compact output + colors + themes
|
||||
internal/rules/ user-defined threshold rules
|
||||
internal/scanner/ repository discovery + concurrent status scanning
|
||||
internal/scheduler/ interval loop with graceful shutdown
|
||||
internal/version/ ldflags-injectable version
|
||||
pkg/status/ shared domain model (RepoInfo, RepoStatus, ScanResult)
|
||||
```
|
||||
|
||||
CI runs build, vet, and tests (with `-race`) on Go 1.24/1.26 plus
|
||||
golangci-lint.
|
||||
|
||||
## Future work
|
||||
|
||||
- Interactive TUI (bubbletea) for navigating repositories and triggering
|
||||
AI suggestions
|
||||
- Webhook alerts (Slack/Discord) instead of desktop notifications
|
||||
- `ai_execute` hardening and a wider command allowlist
|
||||
- Fetch-history tracking to populate per-repo `last_fetch` metadata
|
||||
|
||||
Loading…
Reference in New Issue
Block a user