Files
mgsh/overview.go
T
mikeandClaude Opus 5 cb1ff98c3d 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>
2026-07-26 17:42:37 +02:00

356 lines
9.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
// overview.go — the `overview` command (also reachable as `status -a`).
//
// mgsh is the only thing that sees all three places a project can live: the
// local base directory, the internal ssh server, and the public mirrors. Joining
// those answers the questions plain git cannot — which projects were never
// pushed to the server, which exist there but not on this machine, and which
// machine last touched each one (every `push` stamps "[user@host]" into the
// commit message, so that comes for free).
import (
"fmt"
"os"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
)
// projStatus is the collected state of one project for the overview.
type projStatus struct {
name string
branch string
dirty bool
ahead, behind int
hasUpstream bool
lastHost string // machine that made the last commit, from "[user@host]"
lastWhen time.Time // when that was
mirrors []string // configured mirror remotes present in this repo
}
// commitHostRe pulls the host out of the "[user@host] subject" line that `push`
// writes, so the overview can say where a project was last worked on.
var commitHostRe = regexp.MustCompile(`^\[[^@\]]*@([^\]]+)\]`)
// overviewScanLimit bounds how many projects are inspected at once. The work is
// all subprocess latency, so some concurrency helps a lot and more does not.
const overviewScanLimit = 8
// overviewAll prints a status summary for all git projects under BASE, plus the
// projects that exist on only one side of the local/server divide.
func overviewAll() {
entries, err := os.ReadDir(BASE)
if err != nil {
errorln("cannot read " + BASE + ": " + err.Error())
return
}
// ask the server while the local tree is being walked
type serverList struct {
names []string
err error
}
srvCh := make(chan serverList, 1)
go func() {
names, err := serverRepoNames()
srvCh <- serverList{names, err}
}()
var local, repos []string
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
local = append(local, e.Name())
if isDir(BASE + "/" + e.Name() + "/.git") {
repos = append(repos, e.Name())
}
}
rows := scanProjects(repos)
srv := <-srvCh
// 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, w))
if r.dirty {
dirtyN++
}
if r.hasUpstream && r.ahead == 0 && r.behind == 0 && !r.dirty {
syncN++
}
}
if len(rows) > 0 {
fmt.Println(col(cGray, fmt.Sprintf("%d projects · %d dirty · %d in sync",
len(rows), dirtyN, syncN)))
}
reportInventory(local, srv.names, srv.err)
}
// scanProjects collects the state of every project concurrently. Each project
// costs two git subprocesses, and serially that is the slowest thing mgsh does.
func scanProjects(names []string) []projStatus {
rows := make([]projStatus, len(names))
sem := make(chan struct{}, overviewScanLimit)
var wg sync.WaitGroup
for i, n := range names {
wg.Add(1)
go func(i int, n string) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
rows[i] = projectStatus(n, BASE+"/"+n)
}(i, n)
}
wg.Wait()
return rows
}
// reportInventory names the projects that live on only one side: local ones the
// server has never seen (candidates for `init`) and server repositories missing
// here (candidates for `clone`).
func reportInventory(local, server []string, err error) {
if err != nil {
fmt.Println(col(cGray, " git server not reachable — local view only"))
return
}
have := map[string]bool{}
for _, n := range local {
have[n] = true
}
onServer := map[string]bool{}
for _, n := range server {
onServer[n] = true
}
var missingRemote, missingLocal []string
for _, n := range local {
if !onServer[n] {
missingRemote = append(missingRemote, n)
}
}
for _, n := range server {
if !have[n] {
missingLocal = append(missingLocal, n)
}
}
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, padRight("not on the server (init)", labelW)),
col(cGray, strings.Join(missingRemote, ", ")))
}
if len(missingLocal) > 0 {
fmt.Printf(" %s %s\n", col(cCyan, padRight("not cloned here (clone)", labelW)),
col(cGray, strings.Join(missingLocal, ", ")))
}
}
// projectStatus gathers the git state of a single project directory.
func projectStatus(name, dir string) projStatus {
s := projStatus{name: name, branch: "-"}
readStatus(&s, dir)
readLastCommit(&s, dir)
s.mirrors = configuredMirrors(dir)
return s
}
// readStatus fills in branch, upstream, ahead/behind and dirty from a single
// `git status` — the porcelain v2 header carries all four.
func readStatus(s *projStatus, dir string) {
out, err := gitCapture(dir, "status", "--porcelain=v2", "--branch")
if err != nil {
return
}
for _, ln := range splitLines(out) {
if !strings.HasPrefix(ln, "# ") {
s.dirty = true // any entry line means the tree is not clean
continue
}
f := strings.Fields(ln)
if len(f) < 3 {
continue
}
switch f[1] {
case "branch.head":
s.branch = f[2]
case "branch.upstream":
s.hasUpstream = true
case "branch.ab":
if len(f) >= 4 {
s.ahead, _ = strconv.Atoi(strings.TrimPrefix(f[2], "+"))
s.behind, _ = strconv.Atoi(strings.TrimPrefix(f[3], "-"))
}
}
}
}
// readLastCommit records when the project was last committed to and from which
// machine, taken from the "[user@host]" prefix `push` writes.
func readLastCommit(s *projStatus, dir string) {
out, err := gitCapture(dir, "log", "-1", "--format=%ct%x00%s")
if err != nil {
return
}
parts := strings.SplitN(strings.TrimSpace(out), "\x00", 2)
if len(parts) != 2 {
return
}
if epoch, err := strconv.ParseInt(parts[0], 10, 64); err == nil {
s.lastWhen = time.Unix(epoch, 0)
}
if m := commitHostRe.FindStringSubmatch(parts[1]); m != nil {
s.lastHost = m[1]
}
}
// configuredMirrors returns the mirror targets this repository actually has a
// remote for — free to determine, since it is only local git config.
func configuredMirrors(dir string) []string {
targets, _ := cfg.mirrorTargets()
if len(targets) == 0 {
return nil
}
out, err := gitCapture(dir, "remote")
if err != nil {
return nil
}
have := map[string]bool{}
for _, r := range splitLines(out) {
have[strings.TrimSpace(r)] = true
}
var found []string
for _, t := range targets {
if have[t.Name] {
found = append(found, t.Name)
}
}
return found
}
// 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 {
dirty = "*"
}
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)))
if w.host > 0 {
age := ""
if !s.lastWhen.IsZero() {
age = shortAge(time.Since(s.lastWhen))
}
b.WriteString(" " + col(cGray, padRight(s.lastHost, w.host)))
b.WriteString(" " + col(cGray, fmt.Sprintf("%4s", age)))
}
if len(s.mirrors) > 0 {
b.WriteString(col(cGray, " → "+strings.Join(s.mirrors, " ")))
}
return b.String()
}
// shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d".
func shortAge(d time.Duration) string {
switch {
case d < time.Minute:
return "now"
case d < time.Hour:
return fmt.Sprintf("%dm", int(d.Minutes()))
case d < 24*time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
default:
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
}