Files
mgsh/overview.go
T
mikeandClaude Opus 5 cad7a4ec2c Turn overview into an inventory across local and server
overview showed dirty and ahead/behind per project, which git can do on
its own. mgsh is the only thing that sees both the local base directory
and the ssh server, and joining those answers the questions git cannot:
which projects were never pushed to the server (candidates for `init`),
and which exist there but not on this machine (candidates for `clone`).
Both lists are printed after the summary. An unreachable server is
reported as such, rather than as "everything is missing".

Each row also names the machine that made the last commit and how long
ago. That costs nothing: `push` has always stamped "[user@host]" into
the commit message, and nothing ever read it back. On a setup spanning
several machines it is usually the piece one actually wanted. Rows also
show which mirror targets the repository has a remote for, which is
local git config and therefore free.

The walk is now concurrent and cheaper per project: `git status
--porcelain=v2 --branch` yields branch, upstream, ahead/behind and dirty
in one subprocess where three were used before.

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

306 lines
7.9 KiB
Go

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"
)
// 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
width := 0
for _, n := range repos {
if len(n) > width {
width = len(n)
}
}
if len(rows) == 0 {
fmt.Println(col(cGray, "no git projects under "+BASE))
}
dirtyN, syncN := 0, 0
for _, r := range rows {
fmt.Println(formatProjStatus(r, width))
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)
if len(missingRemote) > 0 {
fmt.Printf("%s %s\n", col(cYellow, " not on the git server:"),
strings.Join(missingRemote, ", ")+col(cGray, " (init)"))
}
if len(missingLocal) > 0 {
fmt.Printf("%s %s\n", col(cCyan, " not cloned here: "),
strings.Join(missingLocal, ", ")+col(cGray, " (clone)"))
}
}
// 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
}
// formatProjStatus renders one aligned overview row.
func formatProjStatus(s projStatus, width int) string {
var marks []string
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)")
}
}
line := " " + col(cGreen, padRight(s.name, width+2)) + state
if s.branch != "master" && s.branch != "main" && s.branch != "-" {
line += col(cGray, " ("+s.branch+")")
}
var tail []string
if s.lastHost != "" || !s.lastWhen.IsZero() {
t := s.lastHost
if !s.lastWhen.IsZero() {
if t != "" {
t += " "
}
t += shortAge(time.Since(s.lastWhen))
}
tail = append(tail, t)
}
if len(s.mirrors) > 0 {
tail = append(tail, "→ "+strings.Join(s.mirrors, ","))
}
if len(tail) > 0 {
line += col(cGray, " · "+strings.Join(tail, " · "))
}
return line
}
// 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))
}
}