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
+51 -17
View File
@@ -14,32 +14,46 @@ import (
var (
optRe = regexp.MustCompile(`^-(\w)$`)
numRe = regexp.MustCompile(`^\d+$`)
// a `ls -ltr` long-listing line: mode, link count, owner, group, size, then
// the date columns and the name. Owner and group are matched as opaque
// a `ls -ltr` long-listing line: mode, link count, owner, group, size, the
// 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
// 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$`)
sanRe = regexp.MustCompile(`[,;:\\/='"|?><-]+`)
wsRe = regexp.MustCompile(`\s+`)
)
// lsEntry extracts the "<date columns> <name>" tail of a `ls -ltr` line whose
// entry name ends in suffix, with the suffix removed. It returns "" for any
// other line (the leading "total" line, entries of a different kind).
func lsEntry(line, suffix string) string {
// lsEntry is one parsed entry of the server's listing.
type lsEntry struct {
name string // with the ".git" / ".git.tar.gz" suffix removed
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))
if m == nil {
return ""
return lsEntry{}, false
}
name := m[1]
name := m[5]
if i := strings.Index(name, " -> "); i >= 0 {
name = name[:i] // a symlinked bare repo lists as "link.git -> target.git"
}
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
@@ -184,20 +198,40 @@ func runCommandDepth(line string, depth int) bool {
if opt["a"] {
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))
if err != nil {
errorln("could not list repositories on the git server")
errorln("could not list " + many + " on the git server")
break
}
var entries []lsEntry
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
}
if name := lsEntry(ln, suffix); name != "" {
fmt.Println(colorRepoLine(name))
}
entries = append(entries, e)
}
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
prj := PRJ