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 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))) } reportUnpublished(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 } // reportUnpublished names the local projects the git server has never seen — // the ones `init` is for. The reverse direction, server repositories missing // here, is what `list` is for and is not repeated. func reportUnpublished(local, server []string, err error) { if err != nil { fmt.Println(col(cGray, " git server not reachable — local view only")) return } onServer := map[string]bool{} for _, n := range server { onServer[n] = true } var missing []string for _, n := range local { if !onServer[n] { missing = append(missing, n) } } if len(missing) == 0 { return } sort.Strings(missing) fmt.Printf(" %s %s\n", col(cYellow, "not on the server (init)"), col(cGray, strings.Join(missing, ", "))) } // 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)) } }