diff --git a/README.md b/README.md index 9d24fcd..5d54910 100644 --- a/README.md +++ b/README.md @@ -134,14 +134,20 @@ 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 + Betaflight3.0.0 Sep 28 2016 181M + website Mar 3 2024 2.1M + notes Jan 3 14:32 876K +3 repositories · 184M ``` -`list -a` lists the archives instead, with their sizes; a pattern filters by -name (`list note`). +The size is the repository's real disk usage on the server, asked of `du` in +the same round trip as the listing — a long listing reports the inode size for +a directory, which is the same number for every repository and says nothing. If +the server produces no usable sizes the column is left out rather than filled +with zeroes. + +`list -a` lists the archives instead, whose sizes come from the listing itself; +a pattern filters by name (`list note`). ### Overview diff --git a/commands.go b/commands.go index a0c0651..29493bb 100644 --- a/commands.go +++ b/commands.go @@ -24,6 +24,45 @@ var ( wsRe = regexp.MustCompile(`\s+`) ) +// listMarker separates the two sections of the combined listing command, so +// `list` gets both the long listing and the disk usage in one round trip. +const listMarker = "---mgsh---" + +// duRe matches one `du -sk` line: kilobytes, then the path. +var duRe = regexp.MustCompile(`^(\d+)\s+(.*)$`) + +// splitAtMarker divides the remote output into the part before and after the +// marker line. Everything is in the first section when the marker is absent — +// which is what happens when only a plain listing was asked for. +func splitAtMarker(lines []string, marker string) (before, after []string) { + for i, ln := range lines { + if strings.TrimSpace(ln) == marker { + return lines[:i], lines[i+1:] + } + } + return lines, nil +} + +// parseDuSizes turns `du -sk` output into a name -> bytes map. A long listing +// reports the inode size for a directory — the same number for every bare +// repository — so this is the only way to say how large one actually is. +// A symlinked repository reports the size of the link, not of its target. +func parseDuSizes(lines []string) map[string]int64 { + out := map[string]int64{} + for _, ln := range lines { + m := duRe.FindStringSubmatch(strings.TrimRight(ln, "\r")) + if m == nil { + continue + } + kb, err := strconv.ParseInt(m[1], 10, 64) + if err != nil { + continue + } + out[strings.TrimPrefix(strings.TrimSpace(m[2]), "./")] = kb * 1024 + } + return out +} + // lsEntry is one parsed entry of the server's listing. type lsEntry struct { name string // with the ".git" / ".git.tar.gz" suffix removed @@ -203,19 +242,34 @@ func runCommandDepth(line string, depth int) bool { one, many = "archive", "archives" } pat := strings.ToLower(word(words, 1)) - lines, err := sshOut("/bin/ls -ltr " + shq(path)) + remote := "/bin/ls -ltr " + shq(path) + if !opt["a"] { + // archives are files and carry a real size; repositories are + // directories, whose listed size is the inode's, so ask du in the + // same round trip + remote += "; echo " + shq(listMarker) + "; du -sk *.git 2>/dev/null" + } + lines, err := sshOut(remote) if err != nil { errorln("could not list " + many + " on the git server") break } + lsLines, duLines := splitAtMarker(lines, listMarker) + sizes := parseDuSizes(duLines) + var entries []lsEntry - for _, ln := range lines { + var total int64 + for _, ln := range lsLines { 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 !opt["a"] { + e.size = sizes[e.name+suffix] // 0 when du said nothing + } + total += e.size entries = append(entries, e) } if len(entries) == 0 { @@ -226,12 +280,18 @@ func runCommandDepth(line string, depth int) bool { fmt.Println(col(cGray, what)) break } - fmt.Print(formatRepoList(entries, opt["a"])) + // no size column when the server gave no usable sizes, rather than a + // column of zeroes + fmt.Print(formatRepoList(entries, total > 0)) label := many if len(entries) == 1 { label = one } - fmt.Println(col(cGray, fmt.Sprintf("%d %s", len(entries), label))) + summary := fmt.Sprintf("%d %s", len(entries), label) + if total > 0 { + summary += " · " + humanSize(total) + } + fmt.Println(col(cGray, summary)) case "show": // show a repository's log directly on the server prj := PRJ diff --git a/mgsh_test.go b/mgsh_test.go index 52bef38..4dd89f8 100644 --- a/mgsh_test.go +++ b/mgsh_test.go @@ -943,3 +943,45 @@ func TestExpandAlias(t *testing.T) { } } } + +func TestSplitAtMarker(t *testing.T) { + lines := []string{"a", "b", "---mgsh---", "c", "d"} + before, after := splitAtMarker(lines, "---mgsh---") + if strings.Join(before, ",") != "a,b" || strings.Join(after, ",") != "c,d" { + t.Errorf("split = %v / %v", before, after) + } + // no marker: everything is the first section, so a server that produced no + // du output simply yields no sizes + before, after = splitAtMarker([]string{"a", "b"}, "---mgsh---") + if strings.Join(before, ",") != "a,b" || after != nil { + t.Errorf("split without marker = %v / %v", before, after) + } +} + +func TestParseDuSizes(t *testing.T) { + lines := []string{ + "185432\tBetaflight3.0.0.git", + "2144\twebsite.git", + "876 spaced-with-blanks.git", // some du implementations use spaces + "1024\t./with-dot-slash.git", + "1500\tmy project.git", // a name with a space survives + "garbage", + "", + } + got := parseDuSizes(lines) + want := map[string]int64{ + "Betaflight3.0.0.git": 185432 * 1024, + "website.git": 2144 * 1024, + "spaced-with-blanks.git": 876 * 1024, + "with-dot-slash.git": 1024 * 1024, + "my project.git": 1500 * 1024, + } + if len(got) != len(want) { + t.Fatalf("parseDuSizes = %v, want %d entries", got, len(want)) + } + for k, v := range want { + if got[k] != v { + t.Errorf("parseDuSizes[%q] = %d, want %d", k, got[k], v) + } + } +} diff --git a/version.txt b/version.txt index 3ced8af..6634c5d 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -4.0.29 +4.0.31