371 lines
10 KiB
Go
371 lines
10 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, 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
|
||
isRepo bool // has a .git of its own
|
||
dirty bool
|
||
ahead, behind int
|
||
hasUpstream bool
|
||
notOnServer bool // known to be missing from the git server
|
||
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}
|
||
}()
|
||
|
||
// every directory gets a row, repository or not: one that is not a
|
||
// repository yet is exactly what `init` is for, and putting it in the table
|
||
// beats a separate list underneath
|
||
var local []string
|
||
for _, e := range entries {
|
||
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||
local = append(local, e.Name())
|
||
}
|
||
}
|
||
|
||
rows := scanProjects(local)
|
||
srv := <-srvCh
|
||
markUnpublished(rows, srv.names, srv.err)
|
||
|
||
// 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(cDark, "nothing under "+BASE))
|
||
return
|
||
}
|
||
|
||
w := measureOverview(rows)
|
||
repoN, dirtyN, syncN, initN := 0, 0, 0, 0
|
||
for _, r := range rows {
|
||
fmt.Println(formatProjStatus(r, w))
|
||
if r.notOnServer {
|
||
initN++
|
||
}
|
||
if !r.isRepo {
|
||
continue
|
||
}
|
||
repoN++
|
||
if r.dirty {
|
||
dirtyN++
|
||
}
|
||
if r.hasUpstream && r.ahead == 0 && r.behind == 0 && !r.dirty {
|
||
syncN++
|
||
}
|
||
}
|
||
|
||
summary := fmt.Sprintf("%d projects · %d dirty · %d in sync", repoN, dirtyN, syncN)
|
||
if initN > 0 {
|
||
summary += fmt.Sprintf(" · %d to init", initN)
|
||
}
|
||
fmt.Println(col(cDark, summary))
|
||
if srv.err != nil {
|
||
fmt.Println(col(cDark, " git server not reachable — local view only"))
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// markUnpublished flags the rows the git server has never seen — the ones
|
||
// `init` is for. A listing that failed leaves every row unmarked: not knowing
|
||
// is not the same as knowing they are missing, and marking all of them would
|
||
// tell the user to re-init their whole base directory.
|
||
func markUnpublished(rows []projStatus, server []string, err error) {
|
||
if err != nil {
|
||
return
|
||
}
|
||
onServer := map[string]bool{}
|
||
for _, n := range server {
|
||
onServer[n] = true
|
||
}
|
||
for i := range rows {
|
||
rows[i].notOnServer = !onServer[rows[i].name]
|
||
}
|
||
}
|
||
|
||
// projectStatus gathers the git state of a single project directory. A
|
||
// directory without a repository is reported as it is, and costs no
|
||
// subprocesses at all.
|
||
func projectStatus(name, dir string) projStatus {
|
||
s := projStatus{name: name, branch: "-"}
|
||
if s.isRepo = isDir(dir + "/.git"); !s.isRepo {
|
||
return s
|
||
}
|
||
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
|
||
hint bool // any row carries an action hint
|
||
}
|
||
|
||
// hintWidth is the width of the action column, sized for its only word.
|
||
const hintWidth = 4
|
||
|
||
// 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))
|
||
w.hint = w.hint || r.notOnServer
|
||
}
|
||
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.isRepo:
|
||
return "" // nothing to compare: there is no repository here yet
|
||
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 cDark
|
||
}
|
||
}
|
||
|
||
// attentionRank groups the rows: work in progress at the top, then everything
|
||
// that is settled, and last the directories the server does not have yet. With
|
||
// many projects, scanning the whole list for the two dirty ones is the actual
|
||
// work — and an un-inited directory is a different kind of task, not something
|
||
// to push past the daily ones.
|
||
func attentionRank(s projStatus) int {
|
||
switch {
|
||
case s.notOnServer:
|
||
return 2
|
||
case s.dirty || s.ahead > 0 || s.behind > 0:
|
||
return 0
|
||
default:
|
||
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(cDark, padRight(s.lastHost, w.host)))
|
||
b.WriteString(" " + col(cDark, fmt.Sprintf("%4s", age)))
|
||
}
|
||
if w.hint {
|
||
hint := ""
|
||
if s.notOnServer {
|
||
hint = "init"
|
||
}
|
||
b.WriteString(" " + col(cYellow, padRight(hint, hintWidth)))
|
||
}
|
||
if len(s.mirrors) > 0 {
|
||
b.WriteString(col(cDark, " → "+strings.Join(s.mirrors, " ")))
|
||
}
|
||
return strings.TrimRight(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))
|
||
}
|
||
}
|