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>
153 lines
4.2 KiB
Go
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
|
|
}
|