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 8c68b28dc2
5 changed files with 205 additions and 66 deletions
+93 -36
View File
@@ -74,19 +74,65 @@ func TestFormatLogRecentCompact(t *testing.T) {
}
}
func TestColorRepoLine(t *testing.T) {
func TestFormatRepoList(t *testing.T) {
useColor = false
in := "Sep 28 2016 Betaflight3.0.0"
if got := colorRepoLine(in); got != in {
t.Errorf("colorRepoLine with color off changed input: %q", got)
entries := []lsEntry{
{name: "short", date: "Sep 28 2016", size: 4096},
{name: "a-much-longer-name", date: "Jan 3 14:32", size: 1536},
}
useColor = true
got := colorRepoLine(in)
if !strings.Contains(got, "Betaflight3.0.0") || !strings.Contains(got, cGreen) || !strings.Contains(got, cYellow) {
t.Errorf("colorRepoLine did not color parts: %q", got)
out := formatRepoList(entries, false)
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
if len(lines) != 2 {
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
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) {
@@ -147,38 +193,49 @@ gitemail = # value is only a comment
}
}
func TestLsEntry(t *testing.T) {
cases := []struct{ line, suffix, want string }{
// ownership is not assumed: any user/group must list
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"},
{"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"},
{"drwxr-xr-x 7 mike staff 4096 Sep 28 2016 myproj.git", ".git", "Sep 28 2016 myproj"},
{"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
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git.tar.gz", "Sep 28 2016 myproj"},
{"-rw-r--r-- 1 git git 512 Sep 28 2016 myproj.git.tar.gz", ".git", ""},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git.tar.gz", ""},
func TestParseLsEntry(t *testing.T) {
cases := []struct {
line, suffix string
name, date string
size int64
ok bool
}{
// ownership is not assumed: any user/group must parse
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
{"drwxr-xr-x 7 deploy deploy 4096 Sep 28 2016 myproj.git", ".git", "myproj", "Sep 28 2016", 4096, true},
{"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
{"total 48", ".git", ""},
{"", ".git", ""},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", ""},
{"total 48", ".git", "", "", 0, false},
{"", ".git", "", "", 0, false},
{"drwxr-xr-x 7 git git 4096 Sep 28 2016 notes", ".git", "", "", 0, false},
}
for _, c := range cases {
if got := lsEntry(c.line, c.suffix); got != c.want {
t.Errorf("lsEntry(%q, %q) = %q, want %q", c.line, c.suffix, got, c.want)
e, ok := parseLsEntry(c.line, c.suffix)
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)
}
// every date renders to the same width, whichever form ls used
if e.date != c.date || len(e.date) != 12 {
t.Errorf("parseLsEntry(%q) date = %q (len %d), want %q at 12",
c.line, e.date, len(e.date), c.date)
}
}
}
func TestLsEntrySymlink(t *testing.T) {
// a symlinked bare repo lists its target too — only the link name counts
in := "lrwxrwxrwx 1 git git 14 Sep 28 2016 myproj.git -> /srv/other.git"
if got := lsEntry(in, ".git"); got != "Sep 28 2016 myproj" {
t.Errorf("lsEntry(symlink) = %q, want %q", got, "Sep 28 2016 myproj")
}
// 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)
}
}