gitFlow/pkg/status/status.go
dimitar 0c9c07e8de feat: phase 4 — periodic scanning (watch mode) with change detection
Add the scheduling layer and the watch command so gitflow can rescan
repositories on an interval and surface state changes.

Scheduler (internal/scheduler):
- Scheduler runs a callback immediately and then on a time.Ticker until
  the context is cancelled; interval <= 0 means a single run
- Graceful shutdown: cancellation is never reported as an error (checked
  on the first run and after every run), so Ctrl-C / SIGTERM exit cleanly
- A non-cancellation run error stops the loop and is propagated

Change detection (pkg/status):
- Changed(prev, next) compares snapshots per repository path — status,
  branch, detached flag, ahead/behind, stash count, and file counts —
  and returns the repositories whose observable state differs, including
  repositories that newly appear

Watch command (cmd/gitflow):
- newWatchCmd validates that --interval is positive, prompts for the
  parent directory on a TTY when --dir is absent, and drives the
  scheduler with ScanOnce
- Each frame clears the screen on a terminal (or prints a RFC3339 header
  when output is piped, so watch doubles as a lightweight logger) and
  renders through the presenter
- Footer shows changed repositories since the previous frame ("▲ name:
  status") and the next scan time; JSON format emits one document per
  frame for scripting
- SIGINT/SIGTERM handled via signalContext for a clean stop

Testing:
- Scheduler: single run, periodic repetition, stop-on-error, pre-cancelled
  context, and cancellation-during-run (no error reported)
- status.Changed: unchanged repos ignored; status, file-count, and
  newly-appeared repos detected

Verified: go build, go vet, go test -race, gofmt clean; manual watch
smoke test over a scratch directory with 1s interval — frames render,
SIGINT and SIGTERM both exit 0 with no orphaned processes.
2026-08-02 08:44:29 +02:00

200 lines
5.8 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
}
// 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()
}