diff --git a/README.md b/README.md index c6b2c8d..547ad5b 100644 --- a/README.md +++ b/README.md @@ -157,16 +157,18 @@ thing that sees the local base directory *and* the git server at once. ``` < src > overview notes * ↑2 laptop 3h - website * ✓ desktop 2d → hub - Betaflight3.0.0 ✓ workstation 20d → gitea hub - scratch (wip) – laptop 1h -4 projects · 2 dirty · 2 in sync - not on the server (init) experiments, sandbox + website * ✓ desktop 2d → hub + Betaflight3.0.0 ✓ workstation 20d → gitea hub + experiments init + sandbox (wip) – laptop 1h init +4 projects · 2 dirty · 2 in sync · 2 to init ``` Every field sits in its own column, so the eye can go down one instead of -hunting along each line. The projects that need something done come first; -within a group the order stays alphabetical, so positions do not jump around. +hunting along each line. The projects that need something done come first, the +settled ones next, and the directories the git server does not have yet come +last — those are a different kind of task. Within each group the order stays +alphabetical, so positions do not jump around. | column | meaning | |---|---| @@ -175,19 +177,24 @@ within a group the order stays alphabetical, so positions do not jump around. | `↑n` `↓n` | commits ahead of / behind the upstream (`↑2↓1` when both) | | `✓` | in sync with the upstream | | `–` | the branch tracks nothing — never pushed | +| `init` | the git server does not have this one; run `init` | | host, age | who last committed and when, from the `[user@host]` stamp | | `→` | mirror targets this repository has a remote for | +Every directory under the base gets a row, including those that are not +repositories at all — `init` is exactly what turns one into a project, so it +belongs in the table rather than in a list underneath it. Such a row simply has +no git state to show. + The host and age come from the commit itself — `push` writes `[user@host]` into every message, so `overview` can say where a project was last worked on without storing anything. On a setup spanning a laptop and a workstation that is usually the piece of information you actually wanted. -The line at the end is the join no git command can do: the local projects the -git server has never seen, which are the ones `init` is for. If the server -cannot be reached, mgsh says so instead of claiming everything is missing. The -other direction — repositories on the server that are not here — is what `list` -shows. +The `init` column is the join no git command can do. If the server cannot be +reached, no row is marked and mgsh says so — not knowing is not the same as +knowing they are missing. The other direction, repositories on the server that +are not here, is what `list` shows. ### Credential check diff --git a/mgsh_test.go b/mgsh_test.go index d896a89..b876fd2 100644 --- a/mgsh_test.go +++ b/mgsh_test.go @@ -830,6 +830,7 @@ func TestFormatProjStatus(t *testing.T) { []string{"g", "laptop", "→ hub gitea"}, nil}, } for _, c := range cases { + c.s.isRepo = true // these all describe real repositories got := formatProjStatus(c.s, w) for _, sub := range c.contains { if !strings.Contains(got, sub) { @@ -878,23 +879,24 @@ func TestOverviewColumnsAlign(t *testing.T) { } func TestAttentionRank(t *testing.T) { - needs := []projStatus{ - {dirty: true}, - {ahead: 1}, - {behind: 1}, + ranks := []struct { + s projStatus + want int + }{ + {projStatus{isRepo: true, dirty: true}, 0}, + {projStatus{isRepo: true, ahead: 1}, 0}, + {projStatus{isRepo: true, behind: 1}, 0}, + {projStatus{isRepo: true, hasUpstream: true}, 1}, + {projStatus{isRepo: true}, 1}, // clean, no upstream + // not on the server is a different kind of task and goes last, even + // when the working tree is dirty — it cannot be pushed anyway + {projStatus{isRepo: true, notOnServer: true}, 2}, + {projStatus{isRepo: true, dirty: true, notOnServer: true}, 2}, + {projStatus{notOnServer: true}, 2}, } - quiet := []projStatus{ - {hasUpstream: true}, - {}, // clean, no upstream: nothing to do about it here - } - for _, s := range needs { - if attentionRank(s) != 0 { - t.Errorf("%+v should sort to the top", s) - } - } - for _, s := range quiet { - if attentionRank(s) != 1 { - t.Errorf("%+v should sort below the ones needing action", s) + for _, c := range ranks { + if got := attentionRank(c.s); got != c.want { + t.Errorf("attentionRank(%+v) = %d, want %d", c.s, got, c.want) } } } diff --git a/overview.go b/overview.go index 11bb2ab..b51f7e3 100644 --- a/overview.go +++ b/overview.go @@ -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". diff --git a/overview_test.go b/overview_test.go index 5b83fda..a57d80b 100644 --- a/overview_test.go +++ b/overview_test.go @@ -82,42 +82,63 @@ func TestCommitHostRe(t *testing.T) { } } -// TestReportUnpublishedUnreachableServer: when the server cannot be listed, the -// overview must say so rather than claim every project is missing there. -func TestReportUnpublishedUnreachableServer(t *testing.T) { - out := captureStdout(t, func() { - reportUnpublished([]string{"a", "b"}, nil, errors.New("network is unreachable")) - }) - if strings.Contains(out, "not on the server") { - t.Errorf("an unreachable server was reported as missing repositories: %q", out) - } - if !strings.Contains(out, "not reachable") { - t.Errorf("no hint that the server was unreachable: %q", out) +// TestMarkUnpublishedUnreachableServer: a listing that failed must leave every +// row unmarked. Not knowing is not the same as knowing they are missing — +// marking all of them would tell the user to re-init their whole base. +func TestMarkUnpublishedUnreachableServer(t *testing.T) { + rows := []projStatus{{name: "a"}, {name: "b"}} + markUnpublished(rows, nil, errors.New("network is unreachable")) + for _, r := range rows { + if r.notOnServer { + t.Errorf("%s marked as missing although the server could not be listed", r.name) + } } } -// TestReportUnpublished names only the local projects the server has never -// seen. The other direction — repositories there but not here — is what `list` -// is for, and repeating it here only added noise. -func TestReportUnpublished(t *testing.T) { - out := captureStdout(t, func() { - reportUnpublished([]string{"both", "onlyhere"}, []string{"both", "onlythere"}, nil) - }) - if !strings.Contains(out, "not on the server (init)") || !strings.Contains(out, "onlyhere") { - t.Errorf("local-only project not reported: %q", out) +// TestMarkUnpublished flags only what the server really does not have. +func TestMarkUnpublished(t *testing.T) { + rows := []projStatus{{name: "both"}, {name: "onlyhere"}} + markUnpublished(rows, []string{"both", "onlythere"}, nil) + if rows[0].notOnServer { + t.Error("a project present on both sides was marked") } - if strings.Contains(out, "onlythere") { - t.Errorf("server-only project should no longer be listed: %q", out) - } - if strings.Contains(out, "both") { - t.Errorf("a project present on both sides should not be listed: %q", out) + if !rows[1].notOnServer { + t.Error("a local-only project was not marked") } +} - // nothing unpublished: no line at all, not an empty label - if out := captureStdout(t, func() { - reportUnpublished([]string{"both"}, []string{"both"}, nil) - }); strings.TrimSpace(out) != "" { - t.Errorf("nothing to report should print nothing, got %q", out) +// TestUnpublishedRowsCarryTheHint: the entries live in the table now, with the +// action in their own column, rather than in a list underneath it. +func TestUnpublishedRowsCarryTheHint(t *testing.T) { + useColor = false + rows := []projStatus{ + {name: "published", isRepo: true, hasUpstream: true}, + {name: "fresh", isRepo: true, notOnServer: true}, + {name: "notarepo", notOnServer: true}, + } + w := measureOverview(rows) + if !w.hint { + t.Fatal("hint column not reserved although rows need it") + } + got := []string{} + for _, r := range rows { + got = append(got, formatProjStatus(r, w)) + } + if strings.Contains(got[0], "init") { + t.Errorf("a published project was hinted: %q", got[0]) + } + for _, i := range []int{1, 2} { + if !strings.Contains(got[i], "init") { + t.Errorf("row %d missing the init hint: %q", i, got[i]) + } + } + // a directory that is not a repository has no sync state to report + if strings.ContainsAny(got[2], "✓–↑↓") { + t.Errorf("non-repository row claims a git state: %q", got[2]) + } + // with nothing to hint the column disappears entirely + if measureOverview(rows[:1]).hint { + t.Error("hint column reserved although no row needs it") } } diff --git a/version.txt b/version.txt index c3ec58b..266f857 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.0.38 +4.0.40