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.
170 lines
4.9 KiB
Go
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
|
|
}
|