Turn overview into an inventory across local and server
overview showed dirty and ahead/behind per project, which git can do on its own. mgsh is the only thing that sees both the local base directory and the ssh server, and joining those answers the questions git cannot: which projects were never pushed to the server (candidates for `init`), and which exist there but not on this machine (candidates for `clone`). Both lists are printed after the summary. An unreachable server is reported as such, rather than as "everything is missing". Each row also names the machine that made the last commit and how long ago. That costs nothing: `push` has always stamped "[user@host]" into the commit message, and nothing ever read it back. On a setup spanning several machines it is usually the piece one actually wanted. Rows also show which mirror targets the repository has a remote for, which is local git config and therefore free. The walk is now concurrent and cheaper per project: `git status --porcelain=v2 --branch` yields branch, upstream, ahead/behind and dirty in one subprocess where three were used before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ directory. Go port of the original Perl `mgsh` (`mgsh.perl`).
|
||||
## Contents
|
||||
|
||||
- [Build](#build) · [Usage](#usage) · [Commands](#commands) · [Aliases](#aliases)
|
||||
- [Overview](#overview) · [Credential check](#credential-check)
|
||||
- [Public mirror (`pushremote`)](#public-mirror-pushremote) ·
|
||||
[Releases](#releases)
|
||||
- [Configuration](#configuration) · [Settings reference](#settings-reference) ·
|
||||
@@ -80,7 +81,7 @@ Run `help` for the full list. Highlights:
|
||||
| `pushremote [desc]` | mirror the repo to a public server (gitea/github/gitlab) |
|
||||
| `pull` / `fetch` | pull / fetch from the server |
|
||||
| `status [-a]` / `diff` | short git status (`-a`: overview of all projects) |
|
||||
| `overview` | dirty / ahead-behind summary of all projects |
|
||||
| `overview` | inventory of all projects, local and on the server |
|
||||
| `log` | show the project log |
|
||||
| `edit [n]` | interactive rebase of the last n commits |
|
||||
| `clone [-a] <repo>` | clone a repository (or archive) from the server |
|
||||
@@ -125,6 +126,69 @@ pushremote targets (in push order):
|
||||
Tokens are masked, so the output is safe to paste into a bug report.
|
||||
`config -k` prints just the setting names, one per line.
|
||||
|
||||
### Overview
|
||||
|
||||
`overview` (or `status -a`) is the one view that needs mgsh: it is the only
|
||||
thing that sees the local base directory *and* the git server at once.
|
||||
|
||||
```
|
||||
< src > overview
|
||||
mgsh * ↑2 · desktop 3h · → hub,gitea
|
||||
notes ✓ · laptop 2d
|
||||
website ✓ (no upstream) · laptop 20d
|
||||
3 projects · 1 dirty · 1 in sync
|
||||
not on the git server: scratch, experiments (init)
|
||||
not cloned here: oldproject (clone)
|
||||
```
|
||||
|
||||
Per project: dirty marker, commits ahead/behind the upstream, the branch when it
|
||||
is not `master`/`main`, and the mirror targets the repository has a remote for.
|
||||
|
||||
The machine 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 two lists at the end are the join no git command can do: local projects the
|
||||
server has never seen (`init` them) and server repositories missing on this
|
||||
machine (`clone` them). If the server cannot be reached, mgsh says so instead of
|
||||
claiming everything is missing.
|
||||
|
||||
### Credential check
|
||||
|
||||
`push` runs `git add --all .`, so anything lying in the project gets committed —
|
||||
and with `mirror = true` it reaches a public server in the same breath. That is
|
||||
the only action in mgsh that cannot be undone: a deleted server repository comes
|
||||
back from an archive, a published credential does not.
|
||||
|
||||
So before anything is committed, the staged diff is checked for private keys,
|
||||
GitHub/GitLab/Slack/AWS/PyPI tokens and credential-shaped assignments:
|
||||
|
||||
```
|
||||
< src/notes > push new notes
|
||||
2 possible credential(s) in what is about to be committed:
|
||||
.env:3 credential assignment
|
||||
API_KEY="<the offending line is shown in full here>"
|
||||
deploy_key:1 private key
|
||||
<the BEGIN … PRIVATE KEY header is shown here>
|
||||
(set 'secretscan = off' to skip this check)
|
||||
push anyway? y/N ?
|
||||
```
|
||||
|
||||
Declining stops the push with nothing committed; the changes stay staged, so
|
||||
`git restore --staged <file>` and a `.gitignore` entry are all it takes.
|
||||
|
||||
For a line that only *looks* like a credential and is meant to stay, put
|
||||
`mgsh:allow` in it — a comment on that line is enough. That is better than
|
||||
turning the whole check off for one false positive.
|
||||
|
||||
This is not a complete secret scanner and does not try to be one. It aims for a
|
||||
high hit rate on what actually leaks, with few enough false alarms that the
|
||||
prompt still means something: values that are plainly environment references,
|
||||
constants, template slots (`<token>`, `${VAR}`) or masked stand-ins are ignored —
|
||||
a test checks that mgsh's own README and `mgshrc.example`, both full of
|
||||
credential-shaped text, stay quiet. Switch it off with `secretscan = off`.
|
||||
|
||||
### Aliases
|
||||
|
||||
`alias <name> '<command>'` defines a reusable shortcut, persisted to
|
||||
@@ -309,6 +373,7 @@ project `.mgshrc` may override the setting.
|
||||
| `remote.<name>.visibility` | project | visibility for that target |
|
||||
| `remotes` | project | comma- or space-separated list restricting and ordering the mirror targets |
|
||||
| `mirror` | project | truthy (`1`/`true`/`yes`/`on`) → every `push` also mirrors |
|
||||
| `secretscan` | project | `off` disables the credential check `push` runs before committing (on by default; only an explicit `off` disables it) |
|
||||
|
||||
The three settings written to the global git config are applied at startup, and
|
||||
only when they actually differ, so a plain `mgsh status` does not rewrite
|
||||
|
||||
+1
-7
@@ -82,18 +82,12 @@ func fetchServerRepos() {
|
||||
if serverFetched {
|
||||
return
|
||||
}
|
||||
lines, err := sshOut("/bin/ls .")
|
||||
repos, err := serverRepoNames()
|
||||
if err != nil {
|
||||
// a transient failure (server down, no network) must not cache an
|
||||
// empty list for the rest of the session — the next Tab tries again
|
||||
return
|
||||
}
|
||||
var repos []string
|
||||
for _, ln := range lines {
|
||||
if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil {
|
||||
repos = append(repos, m[1])
|
||||
}
|
||||
}
|
||||
// a missing ./archive is a permanent, unremarkable state: still cache
|
||||
var archives []string
|
||||
if lines, err := sshOut("/bin/ls archive"); err == nil {
|
||||
|
||||
@@ -152,6 +152,22 @@ func sshOut(remote string) ([]string, error) {
|
||||
return lines, err
|
||||
}
|
||||
|
||||
// serverRepoNames lists the bare repositories on the git server, without the
|
||||
// ".git" suffix.
|
||||
func serverRepoNames() ([]string, error) {
|
||||
lines, err := sshOut("/bin/ls .")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, ln := range lines {
|
||||
if m := gitDirRe.FindStringSubmatch(strings.TrimSpace(ln)); m != nil {
|
||||
out = append(out, m[1])
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// serverEntryExists reports whether entry is present in the remote directory
|
||||
// path (relative to the git user's home). The error is returned rather than
|
||||
// folded into the bool so a failed lookup is never mistaken for "not there".
|
||||
|
||||
+224
-27
@@ -1,13 +1,23 @@
|
||||
package main
|
||||
|
||||
// overview.go — the `overview` command (also reachable as `status -a`): a
|
||||
// one-line-per-project summary of every git project under BASE, showing the
|
||||
// dirty state and how far each branch is ahead/behind its upstream.
|
||||
// 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.
|
||||
@@ -17,9 +27,21 @@ type projStatus struct {
|
||||
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
|
||||
}
|
||||
|
||||
// overviewAll prints a status summary for all git projects under BASE.
|
||||
// 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 {
|
||||
@@ -27,27 +49,41 @@ func overviewAll() {
|
||||
return
|
||||
}
|
||||
|
||||
var rows []projStatus
|
||||
width := 0
|
||||
// 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
|
||||
}
|
||||
dir := BASE + "/" + e.Name()
|
||||
if !isDir(dir + "/.git") {
|
||||
continue
|
||||
local = append(local, e.Name())
|
||||
if isDir(BASE + "/" + e.Name() + "/.git") {
|
||||
repos = append(repos, e.Name())
|
||||
}
|
||||
rows = append(rows, projectStatus(e.Name(), dir))
|
||||
if len(e.Name()) > width {
|
||||
width = len(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))
|
||||
return
|
||||
}
|
||||
|
||||
dirtyN, syncN := 0, 0
|
||||
for _, r := range rows {
|
||||
fmt.Println(formatProjStatus(r, width))
|
||||
@@ -58,27 +94,156 @@ func overviewAll() {
|
||||
syncN++
|
||||
}
|
||||
}
|
||||
fmt.Printf("%s\n", col(cGray, fmt.Sprintf("%d projects · %d dirty · %d in sync", len(rows), dirtyN, 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: "-"}
|
||||
if out, err := gitCapture(dir, "rev-parse", "--abbrev-ref", "HEAD"); err == nil {
|
||||
s.branch = strings.TrimSpace(out)
|
||||
}
|
||||
if out, err := gitCapture(dir, "status", "--porcelain"); err == nil && strings.TrimSpace(out) != "" {
|
||||
s.dirty = true
|
||||
}
|
||||
// left/right counts against the upstream: "<behind>\t<ahead>"
|
||||
if out, err := gitCapture(dir, "rev-list", "--left-right", "--count", "@{upstream}...HEAD"); err == nil {
|
||||
if _, e := fmt.Sscanf(strings.TrimSpace(out), "%d\t%d", &s.behind, &s.ahead); e == nil {
|
||||
s.hasUpstream = true
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -104,5 +269,37 @@ func formatProjStatus(s projStatus, width int) string {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package main
|
||||
|
||||
// overview_test.go — the inventory view: what mgsh knows that plain git cannot.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestReadStatusParsesPorcelainV2 covers the single `git status` call that
|
||||
// replaced three separate ones: branch, upstream, ahead/behind and dirty all
|
||||
// come out of its header.
|
||||
func TestReadStatusParsesPorcelainV2(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bare := filepath.Join(t.TempDir(), "o.git")
|
||||
mustGit(t, "", "init", "--bare", "-q", bare)
|
||||
mustGit(t, dir, "init", "-q")
|
||||
mustGit(t, dir, "config", "user.name", "t")
|
||||
mustGit(t, dir, "config", "user.email", "t@e")
|
||||
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "[mike@laptop] work")
|
||||
mustGit(t, dir, "remote", "add", "origin", bare)
|
||||
mustGit(t, dir, "push", "-q", "-u", "origin", "HEAD")
|
||||
|
||||
var s projStatus
|
||||
readStatus(&s, dir)
|
||||
if !s.hasUpstream || s.ahead != 0 || s.behind != 0 || s.dirty {
|
||||
t.Errorf("clean synced repo = %+v", s)
|
||||
}
|
||||
if s.branch == "" || s.branch == "-" {
|
||||
t.Errorf("branch not read: %q", s.branch)
|
||||
}
|
||||
|
||||
// one unstaged file and one unpushed commit
|
||||
if err := os.WriteFile(filepath.Join(dir, "x"), []byte("x"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustGit(t, dir, "commit", "-q", "--allow-empty", "-m", "[mike@desktop] more")
|
||||
|
||||
s = projStatus{}
|
||||
readStatus(&s, dir)
|
||||
if !s.dirty {
|
||||
t.Error("untracked file did not register as dirty")
|
||||
}
|
||||
if s.ahead != 1 {
|
||||
t.Errorf("ahead = %d, want 1", s.ahead)
|
||||
}
|
||||
|
||||
// and the host stamp `push` writes is picked up
|
||||
var l projStatus
|
||||
readLastCommit(&l, dir)
|
||||
if l.lastHost != "desktop" {
|
||||
t.Errorf("lastHost = %q, want desktop", l.lastHost)
|
||||
}
|
||||
if l.lastWhen.IsZero() {
|
||||
t.Error("lastWhen not read")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCommitHostRe: only mgsh's own "[user@host]" stamp counts.
|
||||
func TestCommitHostRe(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"[mike@laptop] fixed a thing": "laptop",
|
||||
"[mike@build-01] ": "build-01",
|
||||
"[@host] no user": "host",
|
||||
"fixed a thing": "",
|
||||
"[not a stamp] text": "",
|
||||
"see [a@b] mid-line": "",
|
||||
}
|
||||
for subj, want := range cases {
|
||||
got := ""
|
||||
if m := commitHostRe.FindStringSubmatch(subj); m != nil {
|
||||
got = m[1]
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("host of %q = %q, want %q", subj, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportInventoryUnreachableServer: when the server cannot be listed, the
|
||||
// overview must say so rather than claim every project is missing there.
|
||||
func TestReportInventoryUnreachableServer(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
reportInventory([]string{"a", "b"}, nil, errors.New("network is unreachable"))
|
||||
})
|
||||
if strings.Contains(out, "not on the git 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)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReportInventorySplitsSides is the join that plain git cannot do.
|
||||
func TestReportInventorySplitsSides(t *testing.T) {
|
||||
out := captureStdout(t, func() {
|
||||
reportInventory([]string{"both", "onlyhere"}, []string{"both", "onlythere"}, nil)
|
||||
})
|
||||
if !strings.Contains(out, "not on the git server:") || !strings.Contains(out, "onlyhere") {
|
||||
t.Errorf("local-only project not reported: %q", out)
|
||||
}
|
||||
if !strings.Contains(out, "not cloned here:") || !strings.Contains(out, "onlythere") {
|
||||
t.Errorf("server-only project not reported: %q", out)
|
||||
}
|
||||
if strings.Contains(out, "both") {
|
||||
t.Errorf("a project present on both sides should not be listed: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortAge(t *testing.T) {
|
||||
cases := []struct {
|
||||
d time.Duration
|
||||
want string
|
||||
}{
|
||||
{30 * time.Second, "now"},
|
||||
{90 * time.Second, "1m"},
|
||||
{2 * time.Hour, "2h"},
|
||||
{36 * time.Hour, "1d"},
|
||||
{20 * 24 * time.Hour, "20d"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := shortAge(c.d); got != c.want {
|
||||
t.Errorf("shortAge(%v) = %q, want %q", c.d, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// captureStdout collects everything a function prints.
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
os.Stdout = w
|
||||
done := make(chan string)
|
||||
go func() {
|
||||
var b strings.Builder
|
||||
io.Copy(&b, r)
|
||||
done <- b.String()
|
||||
}()
|
||||
fn()
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
return <-done
|
||||
}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
4.0.24
|
||||
4.0.27
|
||||
|
||||
Reference in New Issue
Block a user