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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -1 +1 @@
|
|||||||
4.0.27
|
4.0.29
|
||||||
|
|||||||
Reference in New Issue
Block a user