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)) } }