Format list as an aligned table

The name is what the eye looks for, but it came last, behind a ragged
date column, so nothing lined up. Worse, colorRepoLine rebuilt the line
with strings.Fields and single spaces, which destroyed the alignment ls
had produced -- the output was aligned only when colour was off.

The listing line is now parsed properly instead of being split at the
size field: name, date and size come out as fields, the date is re-padded
to a fixed twelve columns so "Sep 28  2016" and "Jan  3 14:32" agree, and
the name leads in a column sized to the longest entry. Colour decorates
that layout without changing it, which a test now checks by stripping the
escapes and comparing. `list -a` shows archive sizes, which were parsed
and thrown away before.

An empty result says so instead of printing nothing, which was
indistinguishable from a failure, and the count line matches the rest of
mgsh. The pattern now filters on the repository name rather than the
whole listing line: matching the owner or the date was never intended and
`list 2016` quietly did it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 16:53:54 +02:00
co-authored by Claude Opus 5
parent cad7a4ec2c
commit 1a67a7d904
5 changed files with 205 additions and 66 deletions
+18 -1
View File
@@ -85,7 +85,7 @@ Run `help` for the full list. Highlights:
| `log` | show the project log | | `log` | show the project log |
| `edit [n]` | interactive rebase of the last n commits | | `edit [n]` | interactive rebase of the last n commits |
| `clone [-a] <repo>` | clone a repository (or archive) from the server | | `clone [-a] <repo>` | clone a repository (or archive) from the server |
| `list [-a] [pattern]` | list repositories on the server | | `list [-a] [pattern]` | list repositories on the server (`-a`: archives, with sizes) |
| `show <repo>` | show a repository log directly on the server | | `show <repo>` | show a repository log directly on the server |
| `archive [comment]` | snapshot the server-side repo into `./archive` | | `archive [comment]` | snapshot the server-side repo into `./archive` |
| `init` | make a new repository from the current directory | | `init` | make a new repository from the current directory |
@@ -126,6 +126,23 @@ pushremote targets (in push order):
Tokens are masked, so the output is safe to paste into a bug report. Tokens are masked, so the output is safe to paste into a bug report.
`config -k` prints just the setting names, one per line. `config -k` prints just the setting names, one per line.
### Listing the server
`list` shows what is on the git server, name first and aligned, ordered by
modification time — `push` touches the bare repository, so the most recently
worked-on project sits closest to the prompt:
```
< src > list
Betaflight3.0.0 Sep 28 2016
website Mar 3 2024
notes Jan 3 14:32
3 repositories
```
`list -a` lists the archives instead, with their sizes; a pattern filters by
name (`list note`).
### Overview ### Overview
`overview` (or `status -a`) is the one view that needs mgsh: it is the only `overview` (or `status -a`) is the one view that needs mgsh: it is the only
+42 -11
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"fmt" "fmt"
"strconv"
"strings" "strings"
) )
@@ -43,17 +44,47 @@ func padRight(s string, n int) string {
return s return s
} }
// colorRepoLine colors a `list` entry: the leading `ls -ltr` date (3 fields) in // formatRepoList renders the server listing for `list`: the name first, in a
// yellow and the repository name in green. // column wide enough for the longest one, then the date, and for archives the
func colorRepoLine(s string) string { // size. Names come first because that is what the eye scans for; putting the
if !useColor { // ragged date there instead is what made the old output hard to read.
return s //
// The order is left as it arrives: `ls -ltr` sorts by modification time, and
// `push` touches the bare repository, so the most recently worked-on project
// ends up closest to the prompt.
func formatRepoList(entries []lsEntry, withSize bool) string {
width := 0
for _, e := range entries {
if len(e.name) > width {
width = len(e.name)
} }
parts := strings.Fields(s)
if len(parts) >= 4 {
date := strings.Join(parts[:3], " ")
name := strings.Join(parts[3:], " ")
return col(cYellow, date) + " " + col(cGreen, name)
} }
return col(cGreen, s) var b strings.Builder
for _, e := range entries {
fmt.Fprintf(&b, " %s %s", col(cGreen, padRight(e.name, width)), col(cYellow, e.date))
if withSize {
fmt.Fprintf(&b, " %s", col(cGray, fmt.Sprintf("%7s", humanSize(e.size))))
}
b.WriteByte('\n')
}
return b.String()
}
// humanSize renders a byte count compactly, the way `ls -h` does: a decimal
// only while it still carries information, so "3.2M" but "512K".
func humanSize(n int64) string {
const unit = 1024
if n < unit {
return strconv.FormatInt(n, 10) + "B"
}
div, exp := int64(unit), 0
for v := n / unit; v >= unit && exp < 4; v /= unit {
div *= unit
exp++
}
v := float64(n) / float64(div)
if v < 10 {
return fmt.Sprintf("%.1f%c", v, "KMGTP"[exp])
}
return fmt.Sprintf("%.0f%c", v, "KMGTP"[exp])
} }
+50 -16
View File
@@ -14,32 +14,46 @@ import (
var ( var (
optRe = regexp.MustCompile(`^-(\w)$`) optRe = regexp.MustCompile(`^-(\w)$`)
numRe = regexp.MustCompile(`^\d+$`) numRe = regexp.MustCompile(`^\d+$`)
// a `ls -ltr` long-listing line: mode, link count, owner, group, size, then // a `ls -ltr` long-listing line: mode, link count, owner, group, size, the
// the date columns and the name. Owner and group are matched as opaque // three date columns, then the name. Owner and group are matched as opaque
// fields — the bare repositories need not belong to a user or group // fields — the bare repositories need not belong to a user or group
// literally named "git". // literally named "git".
lsEntryRe = regexp.MustCompile(`^\S+\s+\d+\s+\S+\s+\S+\s+\d+\s+(.*)$`) lsEntryRe = regexp.MustCompile(`^\S+\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\S+)\s+(\S+)\s+(\S+)\s+(.*)$`)
gitDirRe = regexp.MustCompile(`^(.*)\.git$`) gitDirRe = regexp.MustCompile(`^(.*)\.git$`)
sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`) sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`)
wsRe = regexp.MustCompile(`\s+`) wsRe = regexp.MustCompile(`\s+`)
) )
// lsEntry extracts the "<date columns> <name>" tail of a `ls -ltr` line whose // lsEntry is one parsed entry of the server's listing.
// entry name ends in suffix, with the suffix removed. It returns "" for any type lsEntry struct {
// other line (the leading "total" line, entries of a different kind). name string // with the ".git" / ".git.tar.gz" suffix removed
func lsEntry(line, suffix string) string { date string // the ls date columns, normalised to a fixed 12 columns
size int64
}
// parseLsEntry reads one `ls -ltr` line whose entry name ends in suffix. It
// returns false for anything else: the leading "total" line, entries of another
// kind, or output that does not look like a long listing at all.
func parseLsEntry(line, suffix string) (lsEntry, bool) {
m := lsEntryRe.FindStringSubmatch(strings.TrimSpace(line)) m := lsEntryRe.FindStringSubmatch(strings.TrimSpace(line))
if m == nil { if m == nil {
return "" return lsEntry{}, false
} }
name := m[1] name := m[5]
if i := strings.Index(name, " -> "); i >= 0 { if i := strings.Index(name, " -> "); i >= 0 {
name = name[:i] // a symlinked bare repo lists as "link.git -> target.git" name = name[:i] // a symlinked bare repo lists as "link.git -> target.git"
} }
if !strings.HasSuffix(name, suffix) { if !strings.HasSuffix(name, suffix) {
return "" return lsEntry{}, false
} }
return strings.TrimSuffix(name, suffix) size, _ := strconv.ParseInt(m[1], 10, 64)
return lsEntry{
name: strings.TrimSuffix(name, suffix),
// ls pads these itself, but only in its own column widths; re-pad so
// "Sep 28 2016" and "Jan 3 14:32" line up at 12 either way
date: fmt.Sprintf("%s %2s %5s", m[2], m[3], m[4]),
size: size,
}, true
} }
// validProject reports whether name is usable as a project name: a single path // validProject reports whether name is usable as a project name: a single path
@@ -184,20 +198,40 @@ func runCommandDepth(line string, depth int) bool {
if opt["a"] { if opt["a"] {
path, suffix = "./archive", ".git.tar.gz" path, suffix = "./archive", ".git.tar.gz"
} }
pat := word(words, 1) one, many := "repository", "repositories"
if opt["a"] {
one, many = "archive", "archives"
}
pat := strings.ToLower(word(words, 1))
lines, err := sshOut("/bin/ls -ltr " + shq(path)) lines, err := sshOut("/bin/ls -ltr " + shq(path))
if err != nil { if err != nil {
errorln("could not list repositories on the git server") errorln("could not list " + many + " on the git server")
break break
} }
var entries []lsEntry
for _, ln := range lines { for _, ln := range lines {
if pat != "" && !strings.Contains(strings.ToLower(ln), strings.ToLower(pat)) { e, ok := parseLsEntry(ln, suffix)
// the pattern filters the name, not the whole listing line — an
// accidental match on the date or the owner helps nobody
if !ok || (pat != "" && !strings.Contains(strings.ToLower(e.name), pat)) {
continue continue
} }
if name := lsEntry(ln, suffix); name != "" { entries = append(entries, e)
fmt.Println(colorRepoLine(name))
} }
if len(entries) == 0 {
what := "no " + many + " on the git server"
if pat != "" {
what = "no " + many + " matching '" + word(words, 1) + "'"
} }
fmt.Println(col(cGray, what))
break
}
fmt.Print(formatRepoList(entries, opt["a"]))
label := many
if len(entries) == 1 {
label = one
}
fmt.Println(col(cGray, fmt.Sprintf("%d %s", len(entries), label)))
case "show": // show a repository's log directly on the server case "show": // show a repository's log directly on the server
prj := PRJ prj := PRJ
+91 -34
View File
@@ -74,19 +74,65 @@ func TestFormatLogRecentCompact(t *testing.T) {
} }
} }
func TestColorRepoLine(t *testing.T) { func TestFormatRepoList(t *testing.T) {
useColor = false useColor = false
in := "Sep 28 2016 Betaflight3.0.0" entries := []lsEntry{
if got := colorRepoLine(in); got != in { {name: "short", date: "Sep 28 2016", size: 4096},
t.Errorf("colorRepoLine with color off changed input: %q", got) {name: "a-much-longer-name", date: "Jan 3 14:32", size: 1536},
} }
useColor = true out := formatRepoList(entries, false)
got := colorRepoLine(in) lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if !strings.Contains(got, "Betaflight3.0.0") || !strings.Contains(got, cGreen) || !strings.Contains(got, cYellow) { if len(lines) != 2 {
t.Errorf("colorRepoLine did not color parts: %q", got) t.Fatalf("expected 2 lines, got %d: %q", len(lines), out)
} }
// order is preserved: `ls -ltr` already sorted by modification time
if !strings.Contains(lines[0], "short") || !strings.Contains(lines[1], "a-much-longer-name") {
t.Errorf("order not preserved: %q", out)
}
// the date starts at the same column on every line
if strings.Index(lines[0], "Sep") != strings.Index(lines[1], "Jan") {
t.Errorf("date column not aligned:\n%s", out)
}
if strings.Contains(out, "4.0K") {
t.Errorf("size shown for repositories: %q", out)
}
if withSize := formatRepoList(entries, true); !strings.Contains(withSize, "4.0K") ||
!strings.Contains(withSize, "1.5K") {
t.Errorf("archive sizes missing: %q", withSize)
}
// colour must decorate the layout, never change it
useColor = true
colored := formatRepoList(entries, false)
useColor = false useColor = false
strip := func(s string) string {
for _, c := range []string{cReset, cGreen, cYellow, cGray} {
s = strings.ReplaceAll(s, c, "")
}
return s
}
if strip(colored) != out {
t.Errorf("colour changed the layout:\n%q\n%q", strip(colored), out)
}
}
func TestHumanSize(t *testing.T) {
cases := []struct {
n int64
want string
}{
{0, "0B"}, {512, "512B"}, {1024, "1.0K"}, {1536, "1.5K"},
{1024 * 1024, "1.0M"}, {3 * 1024 * 1024 * 1024, "3.0G"},
// past 10 the decimal carries nothing, as with `ls -h`
{512 * 1024, "512K"}, {99 * 1024 * 1024, "99M"},
}
for _, c := range cases {
if got := humanSize(c.n); got != c.want {
t.Errorf("humanSize(%d) = %q, want %q", c.n, got, c.want)
}
}
} }
func TestParseConfig(t *testing.T) { func TestParseConfig(t *testing.T) {
@@ -147,38 +193,49 @@ gitemail = # value is only a comment
} }
} }
func TestLsEntry(t *testing.T) { func TestParseLsEntry(t *testing.T) {
cases := []struct{ line, suffix, want string }{ cases := []struct {
// ownership is not assumed: any user/group must list line, suffix string
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, name, date string
{"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, size int64
{"drwxr-xr-x 7 mike staff 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, ok bool
{"drwxr-xr-x. 7 git users 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"}, }{
// archives only match the archive suffix, and vice versa // ownership is not assumed: any user/group must parse
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git.tar.gz", "Sep 28 2016 myproj"}, {"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git", ""}, {"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git.tar.gz", ""}, {"drwxr-xr-x. 7 git users 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
// a recent entry carries a time instead of a year, and still lines up
{"drwxr-xr-x 7 mike staff 224 Jan 3 14:32 myproj.git", ".git", "myproj", "Jan 3 14:32", 224, true},
// a symlinked bare repo lists its target too — only the link name counts
{"lrwxrwxrwx 1 git git 14 Sep 28 2016 myproj.git -> /srv/other.git", ".git", "myproj", "Sep 28 2016", 14, true},
// archives carry a size worth showing
{"-rw-r--r-- 1 git git 524288 Sep 28 2016 myproj.git.tar.gz", ".git.tar.gz", "myproj", "Sep 28 2016", 524288, true},
// suffixes must not cross over
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git", "", "", 0, false},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git.tar.gz", "", "", 0, false},
{"lrwxrwxrwx 1 git git 5 Sep 28 2016 notes -> x.git", ".git", "", "", 0, false},
// non-entries // non-entries
{"total 48", ".git", ""}, {"total 48", ".git", "", "", 0, false},
{"", ".git", ""}, {"", ".git", "", "", 0, false},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", ""}, {"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", "", "", 0, false},
} }
for _, c := range cases { for _, c := range cases {
if got := lsEntry(c.line, c.suffix); got != c.want { e, ok := parseLsEntry(c.line, c.suffix)
t.Errorf("lsEntry(%q, %q) = %q, want %q", c.line, c.suffix, got, c.want) if ok != c.ok {
t.Errorf("parseLsEntry(%q, %q) ok = %v, want %v", c.line, c.suffix, ok, c.ok)
continue
} }
if !ok {
continue
} }
} if e.name != c.name || e.size != c.size {
t.Errorf("parseLsEntry(%q) = %+v, want name %q size %d", c.line, e, c.name, c.size)
func TestLsEntrySymlink(t *testing.T) { }
// a symlinked bare repo lists its target too — only the link name counts // every date renders to the same width, whichever form ls used
in := "lrwxrwxrwx 1 git git 14 Sep 28 2016 myproj.git -> /srv/other.git" if e.date != c.date || len(e.date) != 12 {
if got := lsEntry(in, ".git"); got != "Sep 28 2016 myproj" { t.Errorf("parseLsEntry(%q) date = %q (len %d), want %q at 12",
t.Errorf("lsEntry(symlink) = %q, want %q", got, "Sep 28 2016 myproj") c.line, e.date, len(e.date), c.date)
} }
// and a symlink to something that is not a repo must not match
if got := lsEntry("lrwxrwxrwx 1 git git 5 Sep 28 2016 notes -> x.git", ".git"); got != "" {
t.Errorf("lsEntry(non-repo symlink) = %q, want empty", got)
} }
} }
+1 -1
View File
@@ -1 +1 @@
4.0.27 4.0.29