From c9ff44298c141079f254ab3b89b948e2cd6ebd57 Mon Sep 17 00:00:00 2001 From: dimitar Date: Sun, 2 Aug 2026 08:41:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20phase=203=20=E2=80=94=20presentation=20?= =?UTF-8?q?layer=20(table,=20JSON,=20compact)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/gitflow/scan.go | 35 +----- internal/app/app_test.go | 4 +- internal/config/config.go | 11 ++ internal/presenter/compact.go | 63 +++++++++++ internal/presenter/json.go | 30 ++++++ internal/presenter/presenter.go | 152 +++++++++++++++++++++++++++ internal/presenter/presenter_test.go | 147 ++++++++++++++++++++++++++ internal/presenter/table.go | 75 +++++++++++++ pkg/status/status.go | 93 +++++++++++----- pkg/status/status_test.go | 21 ++++ 10 files changed, 573 insertions(+), 58 deletions(-) create mode 100644 internal/presenter/compact.go create mode 100644 internal/presenter/json.go create mode 100644 internal/presenter/presenter.go create mode 100644 internal/presenter/presenter_test.go create mode 100644 internal/presenter/table.go diff --git a/cmd/gitflow/scan.go b/cmd/gitflow/scan.go index 967f646..867c5bf 100644 --- a/cmd/gitflow/scan.go +++ b/cmd/gitflow/scan.go @@ -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) -} diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 4447518..e5306fa 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 3cc7975..75b997e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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, diff --git a/internal/presenter/compact.go b/internal/presenter/compact.go new file mode 100644 index 0000000..f786f67 --- /dev/null +++ b/internal/presenter/compact.go @@ -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 "?" + } +} diff --git a/internal/presenter/json.go b/internal/presenter/json.go new file mode 100644 index 0000000..3737fb7 --- /dev/null +++ b/internal/presenter/json.go @@ -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) +} diff --git a/internal/presenter/presenter.go b/internal/presenter/presenter.go new file mode 100644 index 0000000..71eb069 --- /dev/null +++ b/internal/presenter/presenter.go @@ -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) +} diff --git a/internal/presenter/presenter_test.go b/internal/presenter/presenter_test.go new file mode 100644 index 0000000..38f0158 --- /dev/null +++ b/internal/presenter/presenter_test.go @@ -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") + } +} diff --git a/internal/presenter/table.go b/internal/presenter/table.go new file mode 100644 index 0000000..106c829 --- /dev/null +++ b/internal/presenter/table.go @@ -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, " ") +} diff --git a/pkg/status/status.go b/pkg/status/status.go index 689a63b..4d1bd44 100644 --- a/pkg/status/status.go +++ b/pkg/status/status.go @@ -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. diff --git a/pkg/status/status_test.go b/pkg/status/status_test.go index 26b9e88..2fa0a1b 100644 --- a/pkg/status/status_test.go +++ b/pkg/status/status_test.go @@ -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(),