Files
mgsh/overview_test.go
T
mikeandClaude Opus 5 cb1ff98c3d Lay overview out as a table
The status field was not a column: "*", "↑2", "✓" and "✓ (no upstream)"
are four different widths, so everything after them started somewhere
else on every line and the eye had to hunt along each row instead of
going down one.

Each field now has its own measured column: name (with the branch
appended when it is not master/main), a one-character dirty marker, the
sync state, host and age, then the mirrors. "(no upstream)" was fifteen
columns wide for something that is not even a problem, and is now "–".
Colour weights the row rather than decorating it -- a project that is
clean and in sync goes grey, the arrows and the dirty marker keep their
colour -- and the rows needing action sort to the top, alphabetically
within each group so positions stay predictable.

padRight counted bytes, which was fine while everything it padded was
ASCII; the arrows and check marks are three bytes and one column, so it
counts runes now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 17:42:37 +02:00

153 lines
4.2 KiB
Go

package main
// overview_test.go — the inventory view: what mgsh knows that plain git cannot.
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestReadStatusParsesPorcelainV2 covers the single `git status` call that
// replaced three separate ones: branch, upstream, ahead/behind and dirty all
// come out of its header.
func TestReadStatusParsesPorcelainV2(t *testing.T) {
dir := t.TempDir()
bare := filepath.Join(t.TempDir(), "o.git")
mustGit(t, "", "init", "--bare", "-q", bare)
mustGit(t, dir, "init", "-q")
mustGit(t, dir, "config", "user.name", "t")
mustGit(t, dir, "config", "user.email", "t@e")
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "[mike@laptop] work")
mustGit(t, dir, "remote", "add", "origin", bare)
mustGit(t, dir, "push", "-q", "-u", "origin", "HEAD")
var s projStatus
readStatus(&s, dir)
if !s.hasUpstream || s.ahead != 0 || s.behind != 0 || s.dirty {
t.Errorf("clean synced repo = %+v", s)
}
if s.branch == "" || s.branch == "-" {
t.Errorf("branch not read: %q", s.branch)
}
// one unstaged file and one unpushed commit
if err := os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0644); err != nil {
t.Fatal(err)
}
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "[mike@desktop] more")
s = projStatus{}
readStatus(&s, dir)
if !s.dirty {
t.Error("untracked file did not register as dirty")
}
if s.ahead != 1 {
t.Errorf("ahead = %d, want 1", s.ahead)
}
// and the host stamp `push` writes is picked up
var l projStatus
readLastCommit(&l, dir)
if l.lastHost != "desktop" {
t.Errorf("lastHost = %q, want desktop", l.lastHost)
}
if l.lastWhen.IsZero() {
t.Error("lastWhen not read")
}
}
// TestCommitHostRe: only mgsh's own "[user@host]" stamp counts.
func TestCommitHostRe(t *testing.T) {
cases := map[string]string{
"[mike@laptop] fixed a thing": "laptop",
"[mike@build-01] ": "build-01",
"[@host] no user": "host",
"fixed a thing": "",
"[not a stamp] text": "",
"see [a@b] mid-line": "",
}
for subj, want := range cases {
got := ""
if m := commitHostRe.FindStringSubmatch(subj); m != nil {
got = m[1]
}
if got != want {
t.Errorf("host of %q = %q, want %q", subj, got, want)
}
}
}
// TestReportInventoryUnreachableServer: when the server cannot be listed, the
// overview must say so rather than claim every project is missing there.
func TestReportInventoryUnreachableServer(t *testing.T) {
out := captureStdout(t, func() {
reportInventory([]string{"a", "b"}, nil, errors.New("network is unreachable"))
})
if strings.Contains(out, "not on the server") {
t.Errorf("an unreachable server was reported as missing repositories: %q", out)
}
if !strings.Contains(out, "not reachable") {
t.Errorf("no hint that the server was unreachable: %q", out)
}
}
// TestReportInventorySplitsSides is the join that plain git cannot do.
func TestReportInventorySplitsSides(t *testing.T) {
out := captureStdout(t, func() {
reportInventory([]string{"both", "onlyhere"}, []string{"both", "onlythere"}, nil)
})
if !strings.Contains(out, "not on the server (init)") || !strings.Contains(out, "onlyhere") {
t.Errorf("local-only project not reported: %q", out)
}
if !strings.Contains(out, "not cloned here (clone)") || !strings.Contains(out, "onlythere") {
t.Errorf("server-only project not reported: %q", out)
}
if strings.Contains(out, "both") {
t.Errorf("a project present on both sides should not be listed: %q", out)
}
}
func TestShortAge(t *testing.T) {
cases := []struct {
d time.Duration
want string
}{
{30 * time.Second, "now"},
{90 * time.Second, "1m"},
{2 * time.Hour, "2h"},
{36 * time.Hour, "1d"},
{20 * 24 * time.Hour, "20d"},
}
for _, c := range cases {
if got := shortAge(c.d); got != c.want {
t.Errorf("shortAge(%v) = %q, want %q", c.d, got, c.want)
}
}
}
// captureStdout collects everything a function prints.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
old := os.Stdout
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
os.Stdout = w
done := make(chan string)
go func() {
var b strings.Builder
io.Copy(&b, r)
done <- b.String()
}()
fn()
w.Close()
os.Stdout = old
return <-done
}