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:
dimitar 2026-08-02 08:41:18 +02:00
parent baa2b8d910
commit c9ff44298c
10 changed files with 573 additions and 58 deletions

View File

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

View File

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

View File

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

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

View File

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