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.
200 lines
5.8 KiB
Go
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()
|
|
}
|