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>
This commit is contained in:
2026-07-26 17:42:37 +02:00
co-authored by Claude Opus 5
parent 86fb898df4
commit cb1ff98c3d
6 changed files with 194 additions and 67 deletions
+24 -12
View File
@@ -156,21 +156,33 @@ thing that sees the local base directory *and* the git server at once.
``` ```
< src > overview < src > overview
mgsh * ↑2 · desktop 3h · → hub,gitea notes * ↑2 laptop 3h
notes · laptop 2d website * ✓ desktop 2d → hub
website ✓ (no upstream) · laptop 20d Betaflight3.0.0 ✓ workstation 20d → gitea hub
3 projects · 1 dirty · 1 in sync scratch (wip) laptop 1h
not on the git server: scratch, experiments (init) 4 projects · 2 dirty · 2 in sync
not cloned here: oldproject (clone) not on the server (init) experiments, sandbox
not cloned here (clone) oldproject
``` ```
Per project: dirty marker, commits ahead/behind the upstream, the branch when it Every field sits in its own column, so the eye can go down one instead of
is not `master`/`main`, and the mirror targets the repository has a remote for. hunting along each line. The projects that need something done come first;
within a group the order stays alphabetical, so positions do not jump around.
The machine and age come from the commit itself — `push` writes `[user@host]` | column | meaning |
into every message, so `overview` can say where a project was last worked on |---|---|
without storing anything. On a setup spanning a laptop and a workstation that is | name | the project, with its branch appended when it is not `master`/`main` |
usually the piece of information you actually wanted. | `*` | uncommitted changes |
| `↑n` `↓n` | commits ahead of / behind the upstream (`↑2↓1` when both) |
| `✓` | in sync with the upstream |
| `` | the branch tracks nothing — never pushed |
| host, age | who last committed and when, from the `[user@host]` stamp |
| `→` | mirror targets this repository has a remote for |
The host and age come from the commit itself — `push` writes `[user@host]` into
every message, so `overview` can say where a project was last worked on without
storing anything. On a setup spanning a laptop and a workstation that is usually
the piece of information you actually wanted.
The two lists at the end are the join no git command can do: local projects the The two lists at the end are the join no git command can do: local projects the
server has never seen (`init` them) and server repositories missing on this server has never seen (`init` them) and server repositories missing on this
+6 -3
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"unicode/utf8"
) )
// Colors for the prompt, banner and output, using the Catppuccin Mocha palette // Colors for the prompt, banner and output, using the Catppuccin Mocha palette
@@ -36,10 +37,12 @@ func errorln(msg string) {
fmt.Println(col(cRed, msg)) fmt.Println(col(cRed, msg))
} }
// padRight pads an ASCII string with trailing spaces to width n. // padRight pads s with trailing spaces to a width of n columns. It counts
// runes, not bytes: the overview pads fields holding ↑ ↓ ✓, each of which is
// one column wide but three bytes long.
func padRight(s string, n int) string { func padRight(s string, n int) string {
if len(s) < n { if l := utf8.RuneCountInString(s); l < n {
return s + strings.Repeat(" ", n-len(s)) return s + strings.Repeat(" ", n-l)
} }
return s return s
} }
+64 -2
View File
@@ -9,6 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"time" "time"
"unicode/utf8"
) )
func TestSanitizeComment(t *testing.T) { func TestSanitizeComment(t *testing.T) {
@@ -806,6 +807,7 @@ func TestTruthy(t *testing.T) {
func TestFormatProjStatus(t *testing.T) { func TestFormatProjStatus(t *testing.T) {
useColor = false useColor = false
defer func() { useColor = false }() defer func() { useColor = false }()
w := overviewWidths{label: 12, sync: 5, host: 7}
cases := []struct { cases := []struct {
s projStatus s projStatus
contains []string contains []string
@@ -820,10 +822,15 @@ func TestFormatProjStatus(t *testing.T) {
{projStatus{name: "d", branch: "feature", dirty: true}, {projStatus{name: "d", branch: "feature", dirty: true},
[]string{"d", "*", "(feature)"}, nil}, []string{"d", "*", "(feature)"}, nil},
{projStatus{name: "e", branch: "master"}, // clean, no upstream {projStatus{name: "e", branch: "master"}, // clean, no upstream
[]string{"e", "no upstream"}, []string{"*"}}, []string{"e", ""}, []string{"*", "✓"}},
{projStatus{name: "f", branch: "master", hasUpstream: true, ahead: 1, behind: 2},
[]string{"f", "↑1↓2"}, []string{"✓"}}, // diverged shows both
{projStatus{name: "g", branch: "master", hasUpstream: true, lastHost: "laptop",
mirrors: []string{"hub", "gitea"}},
[]string{"g", "laptop", "→ hub gitea"}, nil},
} }
for _, c := range cases { for _, c := range cases {
got := formatProjStatus(c.s, 8) got := formatProjStatus(c.s, w)
for _, sub := range c.contains { for _, sub := range c.contains {
if !strings.Contains(got, sub) { if !strings.Contains(got, sub) {
t.Errorf("formatProjStatus(%+v) = %q, missing %q", c.s, got, sub) t.Errorf("formatProjStatus(%+v) = %q, missing %q", c.s, got, sub)
@@ -837,6 +844,61 @@ func TestFormatProjStatus(t *testing.T) {
} }
} }
// TestOverviewColumnsAlign is the point of the table: every field has to start
// at the same column on every row, whatever the name lengths or the multi-byte
// status glyphs do.
func TestOverviewColumnsAlign(t *testing.T) {
useColor = false
// host names must not occur anywhere else in a row, or the index search
// below would find them inside a project or branch name instead
rows := []projStatus{
{name: "a", branch: "master", hasUpstream: true, ahead: 12, behind: 3, lastHost: "workstation"},
{name: "a-very-long-project-name", branch: "wip", dirty: true, lastHost: "buildbox"},
{name: "mid", branch: "main", hasUpstream: true, lastHost: "laptop"},
}
w := measureOverview(rows)
var widths []int
for _, r := range rows {
line := formatProjStatus(r, w)
// the host column starts right after the padded sync field
idx := strings.Index(line, r.lastHost)
if idx < 0 {
t.Fatalf("host %q missing from %q", r.lastHost, line)
}
widths = append(widths, utf8.RuneCountInString(line[:idx]))
}
for i := 1; i < len(widths); i++ {
if widths[i] != widths[0] {
t.Errorf("host column starts at %d on row %d, %d on row 0:\n%s",
widths[i], i, widths[0], strings.Join([]string{
formatProjStatus(rows[0], w), formatProjStatus(rows[i], w)}, "\n"))
}
}
}
func TestAttentionRank(t *testing.T) {
needs := []projStatus{
{dirty: true},
{ahead: 1},
{behind: 1},
}
quiet := []projStatus{
{hasUpstream: true},
{}, // clean, no upstream: nothing to do about it here
}
for _, s := range needs {
if attentionRank(s) != 0 {
t.Errorf("%+v should sort to the top", s)
}
}
for _, s := range quiet {
if attentionRank(s) != 1 {
t.Errorf("%+v should sort below the ones needing action", s)
}
}
}
func TestDetectRemoteKind(t *testing.T) { func TestDetectRemoteKind(t *testing.T) {
cases := []struct { cases := []struct {
url, override string url, override string
+96 -46
View File
@@ -18,6 +18,7 @@ import (
"strings" "strings"
"sync" "sync"
"time" "time"
"unicode/utf8"
) )
// projStatus is the collected state of one project for the overview. // projStatus is the collected state of one project for the overview.
@@ -74,19 +75,18 @@ func overviewAll() {
rows := scanProjects(repos) rows := scanProjects(repos)
srv := <-srvCh srv := <-srvCh
width := 0 // what needs doing first, alphabetical within each group
for _, n := range repos { sort.SliceStable(rows, func(i, j int) bool {
if len(n) > width { return attentionRank(rows[i]) < attentionRank(rows[j])
width = len(n) })
}
}
if len(rows) == 0 { if len(rows) == 0 {
fmt.Println(col(cGray, "no git projects under "+BASE)) fmt.Println(col(cGray, "no git projects under "+BASE))
} }
w := measureOverview(rows)
dirtyN, syncN := 0, 0 dirtyN, syncN := 0, 0
for _, r := range rows { for _, r := range rows {
fmt.Println(formatProjStatus(r, width)) fmt.Println(formatProjStatus(r, w))
if r.dirty { if r.dirty {
dirtyN++ dirtyN++
} }
@@ -152,13 +152,15 @@ func reportInventory(local, server []string, err error) {
sort.Strings(missingRemote) sort.Strings(missingRemote)
sort.Strings(missingLocal) sort.Strings(missingLocal)
// the two labels share a column so their contents line up as well
const labelW = 24
if len(missingRemote) > 0 { if len(missingRemote) > 0 {
fmt.Printf("%s %s\n", col(cYellow, " not on the git server:"), fmt.Printf(" %s %s\n", col(cYellow, padRight("not on the server (init)", labelW)),
strings.Join(missingRemote, ", ")+col(cGray, " (init)")) col(cGray, strings.Join(missingRemote, ", ")))
} }
if len(missingLocal) > 0 { if len(missingLocal) > 0 {
fmt.Printf("%s %s\n", col(cCyan, " not cloned here: "), fmt.Printf(" %s %s\n", col(cCyan, padRight("not cloned here (clone)", labelW)),
strings.Join(missingLocal, ", ")+col(cGray, " (clone)")) col(cGray, strings.Join(missingLocal, ", ")))
} }
} }
@@ -244,50 +246,98 @@ func configuredMirrors(dir string) []string {
return found return found
} }
// formatProjStatus renders one aligned overview row. // overviewWidths are the column widths of the overview table, measured from the
func formatProjStatus(s projStatus, width int) string { // rows so every field starts at the same place. Ragged columns were what made
var marks []string // the old one-line-per-project output hard to read.
type overviewWidths struct{ label, sync, host int }
// measureOverview sizes the columns for a set of rows.
func measureOverview(rows []projStatus) overviewWidths {
var w overviewWidths
for _, r := range rows {
w.label = max(w.label, utf8.RuneCountInString(projLabel(r)))
w.sync = max(w.sync, utf8.RuneCountInString(syncState(r)))
w.host = max(w.host, utf8.RuneCountInString(r.lastHost))
}
return w
}
// projLabel is the first column: the project, with its branch appended when it
// is not the usual one. Keeping the branch attached to the name costs no extra
// column and keeps the table narrow.
func projLabel(s projStatus) string {
if s.branch != "" && s.branch != "-" && s.branch != "master" && s.branch != "main" {
return s.name + " (" + s.branch + ")"
}
return s.name
}
// syncState renders the relation to the upstream as one short field: ahead,
// behind, both, in sync, or "" for a branch that tracks nothing. The old
// spelled-out "(no upstream)" was fifteen columns wide and pushed every
// following field out of line.
func syncState(s projStatus) string {
switch {
case s.ahead > 0 && s.behind > 0:
return fmt.Sprintf("↑%d↓%d", s.ahead, s.behind)
case s.ahead > 0:
return fmt.Sprintf("↑%d", s.ahead)
case s.behind > 0:
return fmt.Sprintf("↓%d", s.behind)
case s.hasUpstream:
return "✓"
default:
return ""
}
}
// syncColor weights a row visually: anything needing action is coloured, a
// project that is clean and in sync recedes into grey.
func syncColor(s projStatus) string {
switch {
case s.behind > 0:
return cRed
case s.ahead > 0:
return cGreen
default:
return cGray
}
}
// attentionRank sorts the rows worth acting on to the top. With many projects,
// scanning the whole list for the two dirty ones is the actual work.
func attentionRank(s projStatus) int {
if s.dirty || s.ahead > 0 || s.behind > 0 {
return 0
}
return 1
}
// formatProjStatus renders one row of the overview table.
func formatProjStatus(s projStatus, w overviewWidths) string {
dirty := " "
if s.dirty { if s.dirty {
marks = append(marks, col(cYellow, "*")) dirty = "*"
}
if s.ahead > 0 {
marks = append(marks, col(cGreen, fmt.Sprintf("↑%d", s.ahead)))
}
if s.behind > 0 {
marks = append(marks, col(cRed, fmt.Sprintf("↓%d", s.behind)))
}
state := strings.Join(marks, " ")
if state == "" {
if s.hasUpstream {
state = col(cGreen, "✓")
} else {
state = col(cGray, "✓ (no upstream)")
}
} }
line := " " + col(cGreen, padRight(s.name, width+2)) + state var b strings.Builder
if s.branch != "master" && s.branch != "main" && s.branch != "-" { b.WriteString(" ")
line += col(cGray, " ("+s.branch+")") b.WriteString(col(cGreen, padRight(projLabel(s), w.label)))
} b.WriteString(" " + col(cYellow, dirty) + " ")
b.WriteString(col(syncColor(s), padRight(syncState(s), w.sync)))
var tail []string if w.host > 0 {
if s.lastHost != "" || !s.lastWhen.IsZero() { age := ""
t := s.lastHost
if !s.lastWhen.IsZero() { if !s.lastWhen.IsZero() {
if t != "" { age = shortAge(time.Since(s.lastWhen))
t += " "
} }
t += shortAge(time.Since(s.lastWhen)) b.WriteString(" " + col(cGray, padRight(s.lastHost, w.host)))
} b.WriteString(" " + col(cGray, fmt.Sprintf("%4s", age)))
tail = append(tail, t)
} }
if len(s.mirrors) > 0 { if len(s.mirrors) > 0 {
tail = append(tail, "→ "+strings.Join(s.mirrors, ",")) b.WriteString(col(cGray, " → "+strings.Join(s.mirrors, " ")))
} }
if len(tail) > 0 { return b.String()
line += col(cGray, " · "+strings.Join(tail, " · "))
}
return line
} }
// shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d". // shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d".
+3 -3
View File
@@ -88,7 +88,7 @@ func TestReportInventoryUnreachableServer(t *testing.T) {
out := captureStdout(t, func() { out := captureStdout(t, func() {
reportInventory([]string{"a", "b"}, nil, errors.New("network is unreachable")) reportInventory([]string{"a", "b"}, nil, errors.New("network is unreachable"))
}) })
if strings.Contains(out, "not on the git server") { if strings.Contains(out, "not on the server") {
t.Errorf("an unreachable server was reported as missing repositories: %q", out) t.Errorf("an unreachable server was reported as missing repositories: %q", out)
} }
if !strings.Contains(out, "not reachable") { if !strings.Contains(out, "not reachable") {
@@ -101,10 +101,10 @@ func TestReportInventorySplitsSides(t *testing.T) {
out := captureStdout(t, func() { out := captureStdout(t, func() {
reportInventory([]string{"both", "onlyhere"}, []string{"both", "onlythere"}, nil) reportInventory([]string{"both", "onlyhere"}, []string{"both", "onlythere"}, nil)
}) })
if !strings.Contains(out, "not on the git server:") || !strings.Contains(out, "onlyhere") { if !strings.Contains(out, "not on the server (init)") || !strings.Contains(out, "onlyhere") {
t.Errorf("local-only project not reported: %q", out) t.Errorf("local-only project not reported: %q", out)
} }
if !strings.Contains(out, "not cloned here:") || !strings.Contains(out, "onlythere") { if !strings.Contains(out, "not cloned here (clone)") || !strings.Contains(out, "onlythere") {
t.Errorf("server-only project not reported: %q", out) t.Errorf("server-only project not reported: %q", out)
} }
if strings.Contains(out, "both") { if strings.Contains(out, "both") {
+1 -1
View File
@@ -1 +1 @@
4.0.33 4.0.36