gitFlow/pkg/status/status.go
dimitar c9ff44298c 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.
2026-08-02 08:41:18 +02:00

170 lines
4.9 KiB
Go

// Package status defines the domain model shared across gitflow: how a Git
// repository's state is captured, classified, and summarised after a scan.
package status
import (
"encoding/json"
"fmt"
"time"
)
// RepoStatus classifies the overall health of a repository at scan time.
//
// The zero value is StatusUnknown, an invalid/unset state, so a zero-valued
// RepoInfo can never be mistaken for a real scan result.
type RepoStatus int
const (
StatusUnknown RepoStatus = iota // 0 — invalid/unset
StatusClean // 1 — working tree clean, in sync
StatusModified // 2 — staged, modified, or untracked files
StatusAhead // 3 — local commits not yet pushed
StatusBehind // 4 — remote commits not yet pulled
StatusDiverged // 5 — both ahead of and behind upstream
StatusDetached // 6 — HEAD points at a commit, not a branch
StatusBare // 7 — bare repository, no working tree
StatusError // 8 — could not be scanned
)
// String returns a lowercase, human-readable label for the status.
func (s RepoStatus) String() string {
switch s {
case StatusClean:
return "clean"
case StatusModified:
return "modified"
case StatusAhead:
return "ahead"
case StatusBehind:
return "behind"
case StatusDiverged:
return "diverged"
case StatusDetached:
return "detached"
case StatusBare:
return "bare"
case StatusError:
return "error"
default:
return "unknown"
}
}
// NeedsAttention reports whether the repository asks for human action.
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 `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.
func (r RepoInfo) FileCount() int {
return len(r.StagedFiles) + len(r.ModifiedFiles) + len(r.UntrackedFiles)
}
// ScanResult is the outcome of one scan pass over a set of repositories.
type ScanResult struct {
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 `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.
func (r ScanResult) Summary() Summary {
s := Summary{Total: len(r.Repos)}
for _, repo := range r.Repos {
switch repo.Status {
case StatusClean:
s.Clean++
case StatusError:
s.Errored++
case StatusUnknown:
// Neither clean nor actionable; not counted.
default:
s.Attention++
}
s.Staged += len(repo.StagedFiles)
s.Modified += len(repo.ModifiedFiles)
s.Untracked += len(repo.UntrackedFiles)
}
return s
}
// NeedsAttention lists repositories whose status is anything but clean.
func (r ScanResult) NeedsAttention() []RepoInfo {
out := make([]RepoInfo, 0, len(r.Repos))
for _, repo := range r.Repos {
if repo.Status.NeedsAttention() {
out = append(out, repo)
}
}
return out
}