Fold the unpublished projects into the overview table
They were a list underneath the table, which meant reading the same project names in two different shapes. They are rows now, with the action in an "init" column that only appears when some row needs it, and they sort to the bottom as their own group: an un-inited directory is a different kind of task and should not push the daily ones down. Every directory under the base gets a row, not just the repositories -- `init` is exactly what turns a plain directory into a project, so leaving those out would have hidden the ones the column is for. Such a row has no git state to show and costs no subprocesses either, since projectStatus now checks for .git before running any. The count line gained "N to init"; the projects count still counts repositories, so the two numbers stay meaningful side by side. An unreachable server marks nothing at all, as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+71
-39
@@ -24,9 +24,11 @@ import (
|
||||
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
|
||||
@@ -60,19 +62,19 @@ func overviewAll() {
|
||||
srvCh <- serverList{names, err}
|
||||
}()
|
||||
|
||||
var local, repos []string
|
||||
// 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(), ".") {
|
||||
continue
|
||||
}
|
||||
local = append(local, e.Name())
|
||||
if isDir(BASE + "/" + e.Name() + "/.git") {
|
||||
repos = append(repos, e.Name())
|
||||
if e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
|
||||
local = append(local, e.Name())
|
||||
}
|
||||
}
|
||||
|
||||
rows := scanProjects(repos)
|
||||
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 {
|
||||
@@ -80,12 +82,21 @@ func overviewAll() {
|
||||
})
|
||||
|
||||
if len(rows) == 0 {
|
||||
fmt.Println(col(cGray, "no git projects under "+BASE))
|
||||
fmt.Println(col(cGray, "nothing under "+BASE))
|
||||
return
|
||||
}
|
||||
|
||||
w := measureOverview(rows)
|
||||
dirtyN, syncN := 0, 0
|
||||
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++
|
||||
}
|
||||
@@ -93,12 +104,15 @@ func overviewAll() {
|
||||
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)
|
||||
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(cGray, summary))
|
||||
if srv.err != nil {
|
||||
fmt.Println(col(cGray, " git server not reachable — local view only"))
|
||||
}
|
||||
}
|
||||
|
||||
// scanProjects collects the state of every project concurrently. Each project
|
||||
@@ -120,36 +134,31 @@ func scanProjects(names []string) []projStatus {
|
||||
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) {
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
for i := range rows {
|
||||
rows[i].notOnServer = !onServer[rows[i].name]
|
||||
}
|
||||
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.
|
||||
// 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)
|
||||
@@ -232,7 +241,13 @@ func configuredMirrors(dir string) []string {
|
||||
// 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 }
|
||||
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 {
|
||||
@@ -241,6 +256,7 @@ func measureOverview(rows []projStatus) overviewWidths {
|
||||
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
|
||||
}
|
||||
@@ -261,6 +277,8 @@ func projLabel(s projStatus) string {
|
||||
// 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:
|
||||
@@ -287,13 +305,20 @@ func syncColor(s projStatus) string {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
if s.dirty || s.ahead > 0 || s.behind > 0 {
|
||||
switch {
|
||||
case s.notOnServer:
|
||||
return 2
|
||||
case s.dirty || s.ahead > 0 || s.behind > 0:
|
||||
return 0
|
||||
default:
|
||||
return 1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// formatProjStatus renders one row of the overview table.
|
||||
@@ -317,10 +342,17 @@ func formatProjStatus(s projStatus, w overviewWidths) string {
|
||||
b.WriteString(" " + col(cGray, padRight(s.lastHost, w.host)))
|
||||
b.WriteString(" " + col(cGray, 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(cGray, " → "+strings.Join(s.mirrors, " ")))
|
||||
}
|
||||
return b.String()
|
||||
return strings.TrimRight(b.String(), " ")
|
||||
}
|
||||
|
||||
// shortAge renders a duration compactly: 90s -> "1m", 36h -> "1d".
|
||||
|
||||
Reference in New Issue
Block a user