feat: phase 3 — presentation layer (table, JSON, compact)
Replace the interim renderers with a dedicated presenter package that
formats scan results in three ways.
Presenter (internal/presenter):
- Formatter interface with Present()/For() dispatch on format name:
table, json, compact
- TableFormatter: aligned tabwriter table with REPOSITORY / BRANCH /
STATUS / AHEAD-BEHIND / CHANGES / STASH columns, per-repo change
summaries like "2M 1U", error details inline, and a colored summary
line ("N repos | N clean | N need attention | N errors")
- JSONFormatter: indented document with scanned_at, parent_dir, repos,
and an aggregate summary for scripting; statuses render as string
labels ("modified") instead of raw integers
- CompactFormatter: one line per repo with color-coded status symbols
(✓ ✗ ↑ ↓ ⇄ ◉ ▢ !) plus branch, ahead/behind, and change counts
- Color handling: auto/always/never modes, TTY detection, and the
NO_COLOR convention (explicit --color=always still wins); paths have
$HOME collapsed to "~" in table and compact views
Domain model (pkg/status):
- JSON tags on RepoInfo/ScanResult/Summary for clean field names
- RepoStatus now marshals to its string label and unmarshals from both
string labels and numeric values, so JSON output round-trips
Configuration (internal/config):
- New --color flag (auto/always/never) validated in Load and included in
the config dump; keyed as "color" in viper
CLI (cmd/gitflow):
- scan now routes through presenter.Present with the resolved color mode;
the interim renderers are removed
Testing:
- Table content (columns, change summaries, error text, summary line) and
absence of escape codes with ColorNever
- ColorAlways emits ANSI codes even under NO_COLOR; auto stays clean on
non-terminal writers
- JSON decodes back into the domain types (string statuses round-trip)
- Compact symbols and counts; unknown formats rejected
- RepoStatus JSON round trip covers every status
Verified: go build, go vet, go test -race, gofmt clean; manual smoke of
table / compact / forced-color / JSON output against a scratch directory.
This commit is contained in:
parent
baa2b8d910
commit
c9ff44298c
@ -1,7 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
@ -9,7 +8,7 @@ import (
|
|||||||
|
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/config"
|
||||||
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
|
"gitea.oblak.solutions/dimitar/gitFlow/internal/presenter"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newScanCmd runs a single scan pass and renders the result.
|
// newScanCmd runs a single scan pass and renders the result.
|
||||||
@ -43,15 +42,8 @@ func newScanCmd() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Interim rendering; phase 3 routes this through the
|
opts := presenter.Options{Color: presenter.ParseColorMode(cfg.Color)}
|
||||||
// presenter package (table/json/compact).
|
return presenter.Present(os.Stdout, cfg.Format, result, opts)
|
||||||
switch cfg.Format {
|
|
||||||
case "json":
|
|
||||||
return renderJSON(result)
|
|
||||||
default:
|
|
||||||
renderTableLines(result)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
config.RegisterFlags(cmd.Flags())
|
config.RegisterFlags(cmd.Flags())
|
||||||
@ -80,24 +72,3 @@ func newConfigCmd() *cobra.Command {
|
|||||||
config.RegisterFlags(cmd.Flags())
|
config.RegisterFlags(cmd.Flags())
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// renderTableLines is the interim text renderer (replaced in phase 3).
|
|
||||||
func renderTableLines(result status.ScanResult) {
|
|
||||||
for _, repo := range result.Repos {
|
|
||||||
mark := "ok"
|
|
||||||
if repo.Status.NeedsAttention() {
|
|
||||||
mark = "!!"
|
|
||||||
}
|
|
||||||
fmt.Fprintf(os.Stdout, "%2s %-10s %s (branch %s)\n", mark, repo.Status, repo.Path, repo.Branch)
|
|
||||||
}
|
|
||||||
s := result.Summary()
|
|
||||||
fmt.Fprintf(os.Stdout, "\n%d repos | %d clean | %d need attention | %d errors\n",
|
|
||||||
s.Total, s.Clean, s.Attention, s.Errored)
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderJSON serializes the scan result as indented JSON.
|
|
||||||
func renderJSON(result status.ScanResult) error {
|
|
||||||
enc := json.NewEncoder(os.Stdout)
|
|
||||||
enc.SetIndent("", " ")
|
|
||||||
return enc.Encode(result)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@ func TestScanOnce(t *testing.T) {
|
|||||||
repo := filepath.Join(root, "repo")
|
repo := filepath.Join(root, "repo")
|
||||||
initGitRepo(t, repo)
|
initGitRepo(t, repo)
|
||||||
|
|
||||||
cfg := &config.Config{Dir: root, Format: "table", Workers: 4}
|
cfg := &config.Config{Dir: root, Format: "table", Color: "auto", Workers: 4}
|
||||||
a, err := New(cfg)
|
a, err := New(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
@ -63,7 +63,7 @@ func TestScanOnce(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestScanOnceMissingDir(t *testing.T) {
|
func TestScanOnceMissingDir(t *testing.T) {
|
||||||
cfg := &config.Config{Dir: filepath.Join(t.TempDir(), "missing"), Format: "table", Workers: 4}
|
cfg := &config.Config{Dir: filepath.Join(t.TempDir(), "missing"), Format: "table", Color: "auto", Workers: 4}
|
||||||
a, err := New(cfg)
|
a, err := New(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
|
|||||||
@ -31,6 +31,7 @@ type Config struct {
|
|||||||
Dir string `yaml:"dir"`
|
Dir string `yaml:"dir"`
|
||||||
Interval time.Duration `yaml:"interval"`
|
Interval time.Duration `yaml:"interval"`
|
||||||
Format string `yaml:"format"`
|
Format string `yaml:"format"`
|
||||||
|
Color string `yaml:"color"`
|
||||||
Exclude []string `yaml:"exclude,omitempty"`
|
Exclude []string `yaml:"exclude,omitempty"`
|
||||||
MaxDepth int `yaml:"max_depth"`
|
MaxDepth int `yaml:"max_depth"`
|
||||||
Workers int `yaml:"workers"`
|
Workers int `yaml:"workers"`
|
||||||
@ -43,6 +44,7 @@ var flagKeys = []struct{ flag, key string }{
|
|||||||
{"dir", "dir"},
|
{"dir", "dir"},
|
||||||
{"interval", "interval"},
|
{"interval", "interval"},
|
||||||
{"format", "format"},
|
{"format", "format"},
|
||||||
|
{"color", "color"},
|
||||||
{"exclude", "exclude"},
|
{"exclude", "exclude"},
|
||||||
{"max-depth", "max_depth"},
|
{"max-depth", "max_depth"},
|
||||||
{"workers", "workers"},
|
{"workers", "workers"},
|
||||||
@ -57,6 +59,7 @@ func RegisterFlags(f *pflag.FlagSet) {
|
|||||||
f.StringP("dir", "d", ".", "parent directory to scan")
|
f.StringP("dir", "d", ".", "parent directory to scan")
|
||||||
f.DurationP("interval", "i", 0, "rescan interval (e.g. 30s, 5m); 0 runs once")
|
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.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.StringSlice("exclude", nil, "glob patterns of directories to skip (repeatable)")
|
||||||
f.Int("max-depth", 0, "maximum directory depth to scan (0 = unlimited)")
|
f.Int("max-depth", 0, "maximum directory depth to scan (0 = unlimited)")
|
||||||
f.Int("workers", 8, "number of concurrent git scans")
|
f.Int("workers", 8, "number of concurrent git scans")
|
||||||
@ -90,6 +93,7 @@ func Load(flags *pflag.FlagSet) (*Config, error) {
|
|||||||
Dir: v.GetString("dir"),
|
Dir: v.GetString("dir"),
|
||||||
Interval: v.GetDuration("interval"),
|
Interval: v.GetDuration("interval"),
|
||||||
Format: v.GetString("format"),
|
Format: v.GetString("format"),
|
||||||
|
Color: v.GetString("color"),
|
||||||
Exclude: v.GetStringSlice("exclude"),
|
Exclude: v.GetStringSlice("exclude"),
|
||||||
MaxDepth: v.GetInt("max_depth"),
|
MaxDepth: v.GetInt("max_depth"),
|
||||||
Workers: v.GetInt("workers"),
|
Workers: v.GetInt("workers"),
|
||||||
@ -112,6 +116,7 @@ func applyDefaults(v *viper.Viper) {
|
|||||||
v.SetDefault("dir", ".")
|
v.SetDefault("dir", ".")
|
||||||
v.SetDefault("interval", 0)
|
v.SetDefault("interval", 0)
|
||||||
v.SetDefault("format", "table")
|
v.SetDefault("format", "table")
|
||||||
|
v.SetDefault("color", "auto")
|
||||||
v.SetDefault("exclude", []string{})
|
v.SetDefault("exclude", []string{})
|
||||||
v.SetDefault("max_depth", 0)
|
v.SetDefault("max_depth", 0)
|
||||||
v.SetDefault("workers", 8)
|
v.SetDefault("workers", 8)
|
||||||
@ -161,6 +166,11 @@ func (c *Config) Validate() error {
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("config: unsupported format %q (want table, json, or compact)", c.Format)
|
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)
|
||||||
|
}
|
||||||
if c.Interval < 0 {
|
if c.Interval < 0 {
|
||||||
return errors.New("config: interval must not be negative")
|
return errors.New("config: interval must not be negative")
|
||||||
}
|
}
|
||||||
@ -184,6 +194,7 @@ func (c *Config) Dump() (string, error) {
|
|||||||
"dir": c.Dir,
|
"dir": c.Dir,
|
||||||
"interval": c.Interval.String(),
|
"interval": c.Interval.String(),
|
||||||
"format": c.Format,
|
"format": c.Format,
|
||||||
|
"color": c.Color,
|
||||||
"exclude": c.Exclude,
|
"exclude": c.Exclude,
|
||||||
"max_depth": c.MaxDepth,
|
"max_depth": c.MaxDepth,
|
||||||
"workers": c.Workers,
|
"workers": c.Workers,
|
||||||
|
|||||||
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 := newRenderer(c.opts.Color, 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 "?"
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
152
internal/presenter/presenter.go
Normal file
152
internal/presenter/presenter.go
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Options control rendering behaviour.
|
||||||
|
type Options struct {
|
||||||
|
Color ColorMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRenderer(mode ColorMode, 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{}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
147
internal/presenter/presenter_test.go
Normal file
147
internal/presenter/presenter_test.go
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
package presenter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"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")
|
||||||
|
}
|
||||||
|
}
|
||||||
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 := newRenderer(t.opts.Color, 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, " ")
|
||||||
|
}
|
||||||
@ -2,7 +2,11 @@
|
|||||||
// repository's state is captured, classified, and summarised after a scan.
|
// repository's state is captured, classified, and summarised after a scan.
|
||||||
package status
|
package status
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// RepoStatus classifies the overall health of a repository at scan time.
|
// RepoStatus classifies the overall health of a repository at scan time.
|
||||||
//
|
//
|
||||||
@ -51,21 +55,62 @@ func (s RepoStatus) NeedsAttention() bool {
|
|||||||
return s != StatusClean && s != StatusUnknown
|
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.
|
// RepoInfo is a full snapshot of a single repository at scan time.
|
||||||
type RepoInfo struct {
|
type RepoInfo struct {
|
||||||
Path string // absolute path to the repository root
|
Path string `json:"path"`
|
||||||
Name string // base directory name, for display
|
Name string `json:"name"`
|
||||||
RemoteURL string // fetch URL of the first remote, if any
|
RemoteURL string `json:"remote_url,omitempty"`
|
||||||
Branch string // current branch name; "(detached)" when detached
|
Branch string `json:"branch,omitempty"`
|
||||||
Detached bool // HEAD is detached from any branch
|
Detached bool `json:"detached,omitempty"`
|
||||||
Status RepoStatus
|
Status RepoStatus `json:"status"`
|
||||||
StagedFiles []string // files with staged changes
|
StagedFiles []string `json:"staged_files,omitempty"`
|
||||||
ModifiedFiles []string // files with unstaged changes
|
ModifiedFiles []string `json:"modified_files,omitempty"`
|
||||||
UntrackedFiles []string // untracked files or directories
|
UntrackedFiles []string `json:"untracked_files,omitempty"`
|
||||||
AheadBy int // commits ahead of upstream
|
AheadBy int `json:"ahead_by,omitempty"`
|
||||||
BehindBy int // commits behind upstream
|
BehindBy int `json:"behind_by,omitempty"`
|
||||||
StashCount int // number of stashes
|
StashCount int `json:"stash_count,omitempty"`
|
||||||
Error string // scan error detail; non-empty when Status is StatusError
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileCount returns the total number of files with any local change.
|
// FileCount returns the total number of files with any local change.
|
||||||
@ -75,20 +120,20 @@ func (r RepoInfo) FileCount() int {
|
|||||||
|
|
||||||
// ScanResult is the outcome of one scan pass over a set of repositories.
|
// ScanResult is the outcome of one scan pass over a set of repositories.
|
||||||
type ScanResult struct {
|
type ScanResult struct {
|
||||||
ScannedAt time.Time // when the scan ran
|
ScannedAt time.Time `json:"scanned_at"`
|
||||||
ParentDir string // the directory that was scanned
|
ParentDir string `json:"parent_dir"`
|
||||||
Repos []RepoInfo
|
Repos []RepoInfo `json:"repos"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary aggregates per-repository counters for the whole result.
|
// Summary aggregates per-repository counters for the whole result.
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
Total int // repositories scanned
|
Total int `json:"total"`
|
||||||
Clean int // status clean, no action needed
|
Clean int `json:"clean"`
|
||||||
Attention int // status not clean (modified, ahead, behind, diverged, detached, bare)
|
Attention int `json:"attention"`
|
||||||
Errored int // status error (scan failed)
|
Errored int `json:"errored"`
|
||||||
Staged int // files staged across all repos
|
Staged int `json:"staged"`
|
||||||
Modified int // files modified across all repos
|
Modified int `json:"modified"`
|
||||||
Untracked int // untracked files across all repos
|
Untracked int `json:"untracked"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Summary computes the aggregate counters for the result.
|
// Summary computes the aggregate counters for the result.
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package status
|
package status
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -60,6 +61,26 @@ func TestRepoInfoFileCount(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestScanResultSummary(t *testing.T) {
|
||||||
result := ScanResult{
|
result := ScanResult{
|
||||||
ScannedAt: time.Now(),
|
ScannedAt: time.Now(),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user