Files
mgsh/overview_test.go
T
mikeandClaude Opus 5 61a7059f61 Fold the unpublished projects into the overview table
They were a list underneath the table, which meant reading the same
project names in two different shapes. They are rows now, with the action
in an "init" column that only appears when some row needs it, and they
sort to the bottom as their own group: an un-inited directory is a
different kind of task and should not push the daily ones down.

Every directory under the base gets a row, not just the repositories --
`init` is exactly what turns a plain directory into a project, so leaving
those out would have hidden the ones the column is for. Such a row has no
git state to show and costs no subprocesses either, since projectStatus
now checks for .git before running any.

The count line gained "N to init"; the projects count still counts
repositories, so the two numbers stay meaningful side by side. An
unreachable server marks nothing at all, as before.

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

183 lines
5.0 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)
}
}
}
// TestMarkUnpublishedUnreachableServer: a listing that failed must leave every
// row unmarked. Not knowing is not the same as knowing they are missing —
// marking all of them would tell the user to re-init their whole base.
func TestMarkUnpublishedUnreachableServer(t *testing.T) {
rows := []projStatus{{name: "a"}, {name: "b"}}
markUnpublished(rows, nil, errors.New("network is unreachable"))
for _, r := range rows {
if r.notOnServer {
t.Errorf("%s marked as missing although the server could not be listed", r.name)
}
}
}
// TestMarkUnpublished flags only what the server really does not have.
func TestMarkUnpublished(t *testing.T) {
rows := []projStatus{{name: "both"}, {name: "onlyhere"}}
markUnpublished(rows, []string{"both", "onlythere"}, nil)
if rows[0].notOnServer {
t.Error("a project present on both sides was marked")
}
if !rows[1].notOnServer {
t.Error("a local-only project was not marked")
}
}
// TestUnpublishedRowsCarryTheHint: the entries live in the table now, with the
// action in their own column, rather than in a list underneath it.
func TestUnpublishedRowsCarryTheHint(t *testing.T) {
useColor = false
rows := []projStatus{
{name: "published", isRepo: true, hasUpstream: true},
{name: "fresh", isRepo: true, notOnServer: true},
{name: "notarepo", notOnServer: true},
}
w := measureOverview(rows)
if !w.hint {
t.Fatal("hint column not reserved although rows need it")
}
got := []string{}
for _, r := range rows {
got = append(got, formatProjStatus(r, w))
}
if strings.Contains(got[0], "init") {
t.Errorf("a published project was hinted: %q", got[0])
}
for _, i := range []int{1, 2} {
if !strings.Contains(got[i], "init") {
t.Errorf("row %d missing the init hint: %q", i, got[i])
}
}
// a directory that is not a repository has no sync state to report
if strings.ContainsAny(got[2], "✓–↑↓") {
t.Errorf("non-repository row claims a git state: %q", got[2])
}
// with nothing to hint the column disappears entirely
if measureOverview(rows[:1]).hint {
t.Error("hint column reserved although no row needs it")
}
}
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
}