diff --git a/README.md b/README.md index 5d54910..3cf29b3 100644 --- a/README.md +++ b/README.md @@ -156,21 +156,33 @@ thing that sees the local base directory *and* the git server at once. ``` < src > overview - mgsh * ↑2 · desktop 3h · → hub,gitea - notes ✓ · laptop 2d - website ✓ (no upstream) · laptop 20d -3 projects · 1 dirty · 1 in sync - not on the git server: scratch, experiments (init) - not cloned here: oldproject (clone) + notes * ↑2 laptop 3h + website * ✓ desktop 2d → hub + Betaflight3.0.0 ✓ workstation 20d → gitea hub + scratch (wip) – laptop 1h +4 projects · 2 dirty · 2 in sync + 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 -is not `master`/`main`, and the mirror targets the repository has a remote for. +Every field sits in its own column, so the eye can go down one instead of +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]` -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. +| column | meaning | +|---|---| +| name | the project, with its branch appended when it is not `master`/`main` | +| `*` | 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 server has never seen (`init` them) and server repositories missing on this diff --git a/colors.go b/colors.go index efbe01b..5ae4a80 100644 --- a/colors.go +++ b/colors.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" "strings" + "unicode/utf8" ) // 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)) } -// 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 { - if len(s) < n { - return s + strings.Repeat(" ", n-len(s)) + if l := utf8.RuneCountInString(s); l < n { + return s + strings.Repeat(" ", n-l) } return s } diff --git a/mgsh_test.go b/mgsh_test.go index 4dd89f8..d896a89 100644 --- a/mgsh_test.go +++ b/mgsh_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" "time" + "unicode/utf8" ) func TestSanitizeComment(t *testing.T) { @@ -806,6 +807,7 @@ func TestTruthy(t *testing.T) { func TestFormatProjStatus(t *testing.T) { useColor = false defer func() { useColor = false }() + w := overviewWidths{label: 12, sync: 5, host: 7} cases := []struct { s projStatus contains []string @@ -820,10 +822,15 @@ func TestFormatProjStatus(t *testing.T) { {projStatus{name: "d", branch: "feature", dirty: true}, []string{"d", "*", "(feature)"}, nil}, {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 { - got := formatProjStatus(c.s, 8) + got := formatProjStatus(c.s, w) for _, sub := range c.contains { if !strings.Contains(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) { cases := []struct { url, override string diff --git a/overview.go b/overview.go index 7e429fb..e75ccbd 100644 --- a/overview.go +++ b/overview.go @@ -18,6 +18,7 @@ import ( "strings" "sync" "time" + "unicode/utf8" ) // projStatus is the collected state of one project for the overview. @@ -74,19 +75,18 @@ func overviewAll() { rows := scanProjects(repos) srv := <-srvCh - width := 0 - for _, n := range repos { - if len(n) > width { - width = len(n) - } - } + // what needs doing first, alphabetical within each group + sort.SliceStable(rows, func(i, j int) bool { + return attentionRank(rows[i]) < attentionRank(rows[j]) + }) if len(rows) == 0 { fmt.Println(col(cGray, "no git projects under "+BASE)) } + w := measureOverview(rows) dirtyN, syncN := 0, 0 for _, r := range rows { - fmt.Println(formatProjStatus(r, width)) + fmt.Println(formatProjStatus(r, w)) if r.dirty { dirtyN++ } @@ -152,13 +152,15 @@ func reportInventory(local, server []string, err error) { sort.Strings(missingRemote) sort.Strings(missingLocal) + // the two labels share a column so their contents line up as well + const labelW = 24 if len(missingRemote) > 0 { - fmt.Printf("%s %s\n", col(cYellow, " not on the git server:"), - strings.Join(missingRemote, ", ")+col(cGray, " (init)")) + fmt.Printf(" %s %s\n", col(cYellow, padRight("not on the server (init)", labelW)), + col(cGray, strings.Join(missingRemote, ", "))) } if len(missingLocal) > 0 { - fmt.Printf("%s %s\n", col(cCyan, " not cloned here: "), - strings.Join(missingLocal, ", ")+col(cGray, " (clone)")) + fmt.Printf(" %s %s\n", col(cCyan, padRight("not cloned here (clone)", labelW)), + col(cGray, strings.Join(missingLocal, ", "))) } } @@ -244,50 +246,98 @@ func configuredMirrors(dir string) []string { return found } -// formatProjStatus renders one aligned overview row. -func formatProjStatus(s projStatus, width int) string { - var marks []string +// overviewWidths are the column widths of the overview table, measured from the +// rows so every field starts at the same place. Ragged columns were what made +// 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 { - marks = append(marks, col(cYellow, "*")) - } - 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)") - } + dirty = "*" } - line := " " + col(cGreen, padRight(s.name, width+2)) + state - if s.branch != "master" && s.branch != "main" && s.branch != "-" { - line += col(cGray, " ("+s.branch+")") - } + var b strings.Builder + b.WriteString(" ") + 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 s.lastHost != "" || !s.lastWhen.IsZero() { - t := s.lastHost + if w.host > 0 { + age := "" if !s.lastWhen.IsZero() { - if t != "" { - t += " " - } - t += shortAge(time.Since(s.lastWhen)) + age = shortAge(time.Since(s.lastWhen)) } - tail = append(tail, t) + b.WriteString(" " + col(cGray, padRight(s.lastHost, w.host))) + b.WriteString(" " + col(cGray, fmt.Sprintf("%4s", age))) } if len(s.mirrors) > 0 { - tail = append(tail, "→ "+strings.Join(s.mirrors, ",")) + b.WriteString(col(cGray, " → "+strings.Join(s.mirrors, " "))) } - if len(tail) > 0 { - line += col(cGray, " · "+strings.Join(tail, " · ")) - } - return line + return b.String() } // shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d". diff --git a/overview_test.go b/overview_test.go index 37dd93f..91d4de2 100644 --- a/overview_test.go +++ b/overview_test.go @@ -88,7 +88,7 @@ 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 git server") { + 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") { @@ -101,10 +101,10 @@ func TestReportInventorySplitsSides(t *testing.T) { out := captureStdout(t, func() { 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) } - 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) } if strings.Contains(out, "both") { diff --git a/version.txt b/version.txt index a6e8576..2ce4910 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.0.33 +4.0.36