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.
152 lines
3.8 KiB
Go
152 lines
3.8 KiB
Go
package status
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRepoStatusString(t *testing.T) {
|
|
cases := []struct {
|
|
s RepoStatus
|
|
want string
|
|
}{
|
|
{StatusUnknown, "unknown"},
|
|
{StatusClean, "clean"},
|
|
{StatusModified, "modified"},
|
|
{StatusAhead, "ahead"},
|
|
{StatusBehind, "behind"},
|
|
{StatusDiverged, "diverged"},
|
|
{StatusDetached, "detached"},
|
|
{StatusBare, "bare"},
|
|
{StatusError, "error"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := tc.s.String(); got != tc.want {
|
|
t.Errorf("RepoStatus(%d).String() = %q, want %q", tc.s, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRepoStatusNeedsAttention(t *testing.T) {
|
|
cases := []struct {
|
|
s RepoStatus
|
|
want bool
|
|
}{
|
|
{StatusUnknown, false},
|
|
{StatusClean, false},
|
|
{StatusModified, true},
|
|
{StatusAhead, true},
|
|
{StatusBehind, true},
|
|
{StatusDiverged, true},
|
|
{StatusDetached, true},
|
|
{StatusBare, true},
|
|
{StatusError, true},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := tc.s.NeedsAttention(); got != tc.want {
|
|
t.Errorf("RepoStatus(%d).NeedsAttention() = %v, want %v", tc.s, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRepoInfoFileCount(t *testing.T) {
|
|
info := RepoInfo{
|
|
StagedFiles: []string{"a", "b"},
|
|
ModifiedFiles: []string{"c"},
|
|
UntrackedFiles: []string{"d", "e", "f"},
|
|
}
|
|
if got := info.FileCount(); got != 6 {
|
|
t.Errorf("FileCount() = %d, want 6", got)
|
|
}
|
|
}
|
|
|
|
func TestRepoStatusJSONRoundTrip(t *testing.T) {
|
|
all := []RepoStatus{
|
|
StatusUnknown, StatusClean, StatusModified, StatusAhead, StatusBehind,
|
|
StatusDiverged, StatusDetached, StatusBare, StatusError,
|
|
}
|
|
for _, s := range all {
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
t.Fatalf("Marshal(%d): %v", s, err)
|
|
}
|
|
var got RepoStatus
|
|
if err := json.Unmarshal(b, &got); err != nil {
|
|
t.Fatalf("Unmarshal(%s): %v", b, err)
|
|
}
|
|
if got != s {
|
|
t.Errorf("round trip of %s = %v", s, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestScanResultSummary(t *testing.T) {
|
|
result := ScanResult{
|
|
ScannedAt: time.Now(),
|
|
ParentDir: "/tmp",
|
|
Repos: []RepoInfo{
|
|
{Status: StatusClean},
|
|
{Status: StatusModified, StagedFiles: []string{"a"}, ModifiedFiles: []string{"b"}},
|
|
{Status: StatusDiverged, UntrackedFiles: []string{"c"}},
|
|
{Status: StatusError, Error: "boom"},
|
|
{Status: StatusUnknown},
|
|
},
|
|
}
|
|
got := result.Summary()
|
|
want := Summary{Total: 5, Clean: 1, Attention: 2, Errored: 1, Staged: 1, Modified: 1, Untracked: 1}
|
|
if got != want {
|
|
t.Errorf("Summary() = %+v, want %+v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestScanResultNeedsAttention(t *testing.T) {
|
|
result := ScanResult{
|
|
Repos: []RepoInfo{
|
|
{Path: "/a", Status: StatusClean},
|
|
{Path: "/b", Status: StatusBehind},
|
|
{Path: "/c", Status: StatusError},
|
|
},
|
|
}
|
|
got := result.NeedsAttention()
|
|
if len(got) != 2 {
|
|
t.Fatalf("NeedsAttention() returned %d repos, want 2", len(got))
|
|
}
|
|
for _, r := range got {
|
|
if r.Path == "/a" {
|
|
t.Errorf("clean repo /a listed as needing attention")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChanged(t *testing.T) {
|
|
prev := ScanResult{Repos: []RepoInfo{
|
|
{Path: "/a", Status: StatusClean},
|
|
{Path: "/b", Status: StatusModified, ModifiedFiles: []string{"x"}},
|
|
{Path: "/c", Status: StatusAhead, AheadBy: 2},
|
|
}}
|
|
next := ScanResult{Repos: []RepoInfo{
|
|
{Path: "/a", Status: StatusClean}, // unchanged
|
|
{Path: "/b", Status: StatusModified, ModifiedFiles: []string{"x"}, UntrackedFiles: []string{"y"}}, // file count changed
|
|
{Path: "/c", Status: StatusClean}, // status changed
|
|
{Path: "/d", Status: StatusBehind, BehindBy: 3}, // newly appeared
|
|
}}
|
|
|
|
got := Changed(prev, next)
|
|
byPath := make(map[string]bool, len(got))
|
|
for _, r := range got {
|
|
byPath[r.Path] = true
|
|
}
|
|
if byPath["/a"] {
|
|
t.Error("/a reported as changed but state is identical")
|
|
}
|
|
for _, want := range []string{"/b", "/c", "/d"} {
|
|
if !byPath[want] {
|
|
t.Errorf("%s not reported as changed", want)
|
|
}
|
|
}
|
|
if len(got) != 3 {
|
|
t.Errorf("Changed() returned %d repos, want 3", len(got))
|
|
}
|
|
}
|