// 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 } // Changed reports the repositories whose observable state differs between // prev and next, in next's order. Repositories that appear only in next // (or vanished from prev) count as changed. func Changed(prev, next ScanResult) []RepoInfo { byPath := make(map[string]RepoInfo, len(prev.Repos)) for _, r := range prev.Repos { byPath[r.Path] = r } out := make([]RepoInfo, 0, len(next.Repos)) for _, r := range next.Repos { p, ok := byPath[r.Path] if !ok || !sameState(p, r) { out = append(out, r) } } return out } // sameState reports whether two snapshots of the same repository are // observably identical. func sameState(a, b RepoInfo) bool { return a.Status == b.Status && a.Branch == b.Branch && a.Detached == b.Detached && a.AheadBy == b.AheadBy && a.BehindBy == b.BehindBy && a.StashCount == b.StashCount && a.FileCount() == b.FileCount() }