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
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
@ -9,7 +8,7 @@ import (
|
||||
|
||||
"gitea.oblak.solutions/dimitar/gitFlow/internal/app"
|
||||
"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.
|
||||
@ -43,15 +42,8 @@ func newScanCmd() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
// Interim rendering; phase 3 routes this through the
|
||||
// presenter package (table/json/compact).
|
||||
switch cfg.Format {
|
||||
case "json":
|
||||
return renderJSON(result)
|
||||
default:
|
||||
renderTableLines(result)
|
||||
return nil
|
||||
}
|
||||
opts := presenter.Options{Color: presenter.ParseColorMode(cfg.Color)}
|
||||
return presenter.Present(os.Stdout, cfg.Format, result, opts)
|
||||
},
|
||||
}
|
||||
config.RegisterFlags(cmd.Flags())
|
||||
@ -80,24 +72,3 @@ func newConfigCmd() *cobra.Command {
|
||||
config.RegisterFlags(cmd.Flags())
|
||||
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")
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
@ -63,7 +63,7 @@ func TestScanOnce(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestScanOnceMissingDir(t *testing.T) {
|
||||
cfg := &config.Config{Dir: filepath.Join(t.TempDir(), "missing"), Format: "table", Workers: 4}
|
||||
cfg := &config.Config{Dir: filepath.Join(t.TempDir(), "missing"), Format: "table", Color: "auto", Workers: 4}
|
||||
a, err := New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
|
||||
@ -31,6 +31,7 @@ type Config struct {
|
||||
Dir string `yaml:"dir"`
|
||||
Interval time.Duration `yaml:"interval"`
|
||||
Format string `yaml:"format"`
|
||||
Color string `yaml:"color"`
|
||||
Exclude []string `yaml:"exclude,omitempty"`
|
||||
MaxDepth int `yaml:"max_depth"`
|
||||
Workers int `yaml:"workers"`
|
||||
@ -43,6 +44,7 @@ var flagKeys = []struct{ flag, key string }{
|
||||
{"dir", "dir"},
|
||||
{"interval", "interval"},
|
||||
{"format", "format"},
|
||||
{"color", "color"},
|
||||
{"exclude", "exclude"},
|
||||
{"max-depth", "max_depth"},
|
||||
{"workers", "workers"},
|
||||
@ -57,6 +59,7 @@ 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")
|
||||
@ -90,6 +93,7 @@ func Load(flags *pflag.FlagSet) (*Config, error) {
|
||||
Dir: v.GetString("dir"),
|
||||
Interval: v.GetDuration("interval"),
|
||||
Format: v.GetString("format"),
|
||||
Color: v.GetString("color"),
|
||||
Exclude: v.GetStringSlice("exclude"),
|
||||
MaxDepth: v.GetInt("max_depth"),
|
||||
Workers: v.GetInt("workers"),
|
||||
@ -112,6 +116,7 @@ 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)
|
||||
@ -161,6 +166,11 @@ func (c *Config) Validate() error {
|
||||
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)
|
||||
}
|
||||
if c.Interval < 0 {
|
||||
return errors.New("config: interval must not be negative")
|
||||
}
|
||||
@ -184,6 +194,7 @@ func (c *Config) Dump() (string, error) {
|
||||
"dir": c.Dir,
|
||||
"interval": c.Interval.String(),
|
||||
"format": c.Format,
|
||||
"color": c.Color,
|
||||
"exclude": c.Exclude,
|
||||
"max_depth": c.MaxDepth,
|
||||
"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.
|
||||
package status
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"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
|
||||
}
|
||||
|
||||
// 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 // absolute path to the repository root
|
||||
Name string // base directory name, for display
|
||||
RemoteURL string // fetch URL of the first remote, if any
|
||||
Branch string // current branch name; "(detached)" when detached
|
||||
Detached bool // HEAD is detached from any branch
|
||||
Status RepoStatus
|
||||
StagedFiles []string // files with staged changes
|
||||
ModifiedFiles []string // files with unstaged changes
|
||||
UntrackedFiles []string // untracked files or directories
|
||||
AheadBy int // commits ahead of upstream
|
||||
BehindBy int // commits behind upstream
|
||||
StashCount int // number of stashes
|
||||
Error string // scan error detail; non-empty when Status is StatusError
|
||||
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.
|
||||
@ -75,20 +120,20 @@ func (r RepoInfo) FileCount() int {
|
||||
|
||||
// ScanResult is the outcome of one scan pass over a set of repositories.
|
||||
type ScanResult struct {
|
||||
ScannedAt time.Time // when the scan ran
|
||||
ParentDir string // the directory that was scanned
|
||||
Repos []RepoInfo
|
||||
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 // repositories scanned
|
||||
Clean int // status clean, no action needed
|
||||
Attention int // status not clean (modified, ahead, behind, diverged, detached, bare)
|
||||
Errored int // status error (scan failed)
|
||||
Staged int // files staged across all repos
|
||||
Modified int // files modified across all repos
|
||||
Untracked int // untracked files across all repos
|
||||
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.
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package status
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"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) {
|
||||
result := ScanResult{
|
||||
ScannedAt: time.Now(),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user