merge: phase 3 presentation into main

This commit is contained in:
dimitar 2026-08-02 08:41:21 +02:00
commit 5fe5eb5af8
10 changed files with 573 additions and 58 deletions

View File

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

View File

@ -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)

View File

@ -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,

View File

@ -0,0 +1,63 @@
package presenter
import (
"fmt"
"io"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// CompactFormatter renders one line per repository with color-coded status
// symbols, aimed at dense terminals and dashboards.
type CompactFormatter struct {
opts Options
}
// Format implements Formatter.
func (c *CompactFormatter) Format(w io.Writer, result status.ScanResult) error {
r := 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 "?"
}
}

View File

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

View File

@ -0,0 +1,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)
}

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

View File

@ -0,0 +1,75 @@
package presenter
import (
"fmt"
"io"
"strconv"
"strings"
"text/tabwriter"
"gitea.oblak.solutions/dimitar/gitFlow/pkg/status"
)
// TableFormatter renders repositories as an aligned terminal table followed
// by a summary line.
type TableFormatter struct {
opts Options
}
// Format implements Formatter.
func (t *TableFormatter) Format(w io.Writer, result status.ScanResult) error {
r := 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, " ")
}

View File

@ -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.

View File

@ -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(),