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>
183 lines
5.0 KiB
Go
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
|
|
}
|