Close out the plan with the remaining polish items and a full README.
Shell completions (cmd/gitflow):
- New `completion [bash|zsh|fish|powershell]` command backed by cobra's
generators, wired into the root command
Desktop notifications (internal/notify):
- Send() dispatches to notify-send (Linux) with an osascript fallback
(macOS); Windows is a documented no-op for now; missing notifiers are
silent, never errors
- watch --notify sends a notification listing repositories whose state
changed since the previous frame
Color themes (internal/presenter):
- ThemeMode (dark/light) with ParseTheme; light uses bright ANSI variants
(90-97) that stay legible on light backgrounds; threaded through the
table, compact, and suggestions renderers; new --theme flag validated
and dumped by config
Custom rules (internal/rules):
- Rule{name, field (ahead|behind|stash|changes), op (==,!=,<,<=,>,>=),
value, label} with upfront validation in both Validate and Eval
- Config gains a `rules:` section (yaml/env only, no flag), validated at
load; matches render as a "FLAGS (custom rules)" section via a new
presenter.Flags renderer, shown in scan and watch frames
Docs:
- readme.md fully rewritten: features, install, usage, examples, flag
table, status classes, configuration reference, AI agent behavior and
--ai-execute guardrails, development layout, CI, and future work
- implementation.md gains an Implementation Progress section recording
every phase branch and the deviations from the original plan
Multi-platform:
- Verified cross-compilation for windows/amd64 and darwin/arm64; the
notify package is split behind build tags
Testing:
- rules: validation, operator semantics, Eval ordering, invalid-rule
errors
- presenter: ParseTheme, light-theme bright codes (and absence of
dark-theme codes), Flags rendering (empty = silent, matches render)
- config: rules loading from file, invalid-rule rejection, bad theme and
bad provider rejection
- notify: no-op behaviour when no notifier is installed (skipped when one
is, to avoid firing real notifications)
Verified: go build, go vet, go test -race (10 packages), gofmt clean,
windows/darwin cross-compile, completion generation, rules + light theme
smoke test, watch --notify graceful shutdown (exit 0, no orphans).
185 lines
4.2 KiB
Go
185 lines
4.2 KiB
Go
// 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)
|
|
}
|