From 3abf0838c26cc78a209fb086ec558fcd79687e0b Mon Sep 17 00:00:00 2001 From: AJ ONeal Date: Sat, 28 Feb 2026 18:55:47 -0700 Subject: [PATCH] WIP: feat(cmd/monorelease): better table --- cmd/monorelease/main.go | 662 +++++++++++++++++++++++----------------- 1 file changed, 389 insertions(+), 273 deletions(-) diff --git a/cmd/monorelease/main.go b/cmd/monorelease/main.go index e8d74c8..3157942 100644 --- a/cmd/monorelease/main.go +++ b/cmd/monorelease/main.go @@ -25,12 +25,10 @@ import ( "strings" ) -var ( - verbose = flag.Bool("verbose", false, "") - ignoreDirty = flag.String("ignore-dirty", "", "ignore dirty states [u n m d]") - csvOut = flag.Bool("csv", false, "output real CSV") - comma = flag.String("comma", ",", "CSV field delimiter") -) +var verbose = flag.Bool("verbose", false, "") +var ignoreDirty = flag.String("ignore-dirty", "", "ignore dirty states [u n m d]") +var useCSV = flag.Bool("csv", false, "output CSV instead of table") +var csvComma = flag.String("comma", ",", "CSV field separator") func runGit(args ...string) (string, error) { cmd := exec.Command("git", args...) @@ -45,13 +43,33 @@ func isVersion(s string) bool { return re.MatchString(s) } +type Module struct { + Path string + Type string + Untracked bool + Name string + Bins map[string]string + HasRootMain bool +} + type Row struct { - Status string - Type string - Name string - Version string - CurrentTag string - Path string + Status string + Typ string + Name string + Version string + Tag string + Path string +} + +func manifestPath(m Module) string { + f := "go.mod" + if m.Type == "node" { + f = "package.json" + } + if m.Path == "" { + return "./" + f + } + return "./" + m.Path + "/" + f } func main() { @@ -62,21 +80,16 @@ func main() { ignoreSet[c] = true } - repoDir, _ := runGit("rev-parse", "--show-toplevel") - _ = os.Chdir(strings.TrimSpace(repoDir)) + rootB, _ := exec.Command("git", "rev-parse", "--show-toplevel").Output() + root := strings.TrimSpace(string(rootB)) + _ = os.Chdir(root) + repoName := filepath.Base(root) - repoName := filepath.Base(repoDir) - - gitFiles, _ := runGit("ls-files") - committed := strings.Split(gitFiles, "\n") + ls, _ := runGit("ls-files") + committed := strings.Split(ls, "\n") + modMap := make(map[string]Module) modTypes := map[string]string{"go.mod": "go", "package.json": "node"} - modMap := make(map[string]struct { - Path string - Type string - Untracked bool - Bins map[string]string - }) for _, f := range committed { for suf, typ := range modTypes { @@ -85,19 +98,13 @@ func main() { if p == "." { p = "" } - modMap[p] = struct { - Path string - Type string - Untracked bool - Bins map[string]string - }{p, typ, false, make(map[string]string)} + modMap[p] = Module{Path: p, Type: typ, Untracked: false} break } } } - - moreFiles, _ := runGit("ls-files", "--others", "--exclude-standard") - for _, f := range strings.Split(moreFiles, "\n") { + untrackedS, _ := runGit("ls-files", "--others", "--exclude-standard") + for _, f := range strings.Split(untrackedS, "\n") { if f == "" { continue } @@ -108,333 +115,442 @@ func main() { p = "" } if _, ok := modMap[p]; !ok { - modMap[p] = struct { - Path string - Type string - Untracked bool - Bins map[string]string - }{p, typ, true, make(map[string]string)} + modMap[p] = Module{Path: p, Type: typ, Untracked: true} } break } } } - goFiles, _ := runGit("ls-files", "*.go") - moreGoFiles, _ := runGit("ls-files", "--others", "--exclude-standard", "*.go") - allGoFiles := append( - strings.Split(goFiles, "\n"), - strings.Split(moreGoFiles, "\n")..., - ) - - // go module prefix matching - goPrefixes := []string{} - for p := range modMap { - if modMap[p].Type == "go" { - goPrefixes = append(goPrefixes, p+"/") + goCommS, _ := runGit("ls-files", "--", "*.go") + goUntrS, _ := runGit("ls-files", "--others", "--exclude-standard", "--", "*.go") + allGoFiles := []string{} + for _, s := range []string{goCommS, goUntrS} { + for _, line := range strings.Split(s, "\n") { + if line != "" { + allGoFiles = append(allGoFiles, line) + } } } - if _, ok := modMap[""]; ok && modMap[""].Type == "go" { - goPrefixes = append(goPrefixes, "") - } - sort.Slice(goPrefixes, func(i, j int) bool { return len(goPrefixes[i]) > len(goPrefixes[j]) }) - goModuleOf := make(map[string]string) - for _, f := range allGoFiles { - if f == "" { - continue + modules := make([]Module, 0, len(modMap)) + for _, m := range modMap { + modules = append(modules, m) + } + sort.Slice(modules, func(i, j int) bool { return modules[i].Path < modules[j].Path }) + + goModulePrefixes := []string{} + for _, m := range modules { + if m.Type == "go" { + pre := m.Path + if pre != "" { + pre += "/" + } + goModulePrefixes = append(goModulePrefixes, pre) } - dir := filepath.Dir(f) + } + sort.Slice(goModulePrefixes, func(i, j int) bool { return len(goModulePrefixes[i]) > len(goModulePrefixes[j]) }) + + goToModule := make(map[string]string) + for _, gf := range allGoFiles { + dir := filepath.Dir(gf) if dir == "." { dir = "" } - for _, pre := range goPrefixes { - if pre == "" || strings.HasPrefix(dir+"/", pre) { - goModuleOf[f] = strings.TrimSuffix(pre, "/") + matchP := "" + for _, pre := range goModulePrefixes { + if pre != "" && strings.HasPrefix(dir+"/", pre) { + matchP = strings.TrimSuffix(pre, "/") + break + } else if pre == "" { + matchP = "" break } } + if matchP != "" || goToModule[gf] == "" { + goToModule[gf] = matchP + } } - // populate bins - for p, m := range modMap { + for i := range modules { + m := &modules[i] + m.Bins = make(map[string]string) + m.Name = "" if m.Type == "node" { - pkgPath := filepath.Join(p, "package.json") - if p == "" { - pkgPath = "package.json" + pkgFile := "package.json" + if m.Path != "" { + pkgFile = filepath.Join(m.Path, "package.json") } - data, _ := os.ReadFile(pkgPath) - var pkg struct { - Name string `json:"name"` - Bin map[string]any `json:"bin"` - } - json.Unmarshal(data, &pkg) - if pkg.Bin == nil { - continue - } - for name, v := range pkg.Bin { - if s, ok := v.(string); ok && s != "" { - rel := filepath.Clean(filepath.Join(p, s)) - m.Bins[name] = rel + data, err := os.ReadFile(pkgFile) + if err == nil { + var pi struct { + Name string `json:"name"` + Bin any `json:"bin"` + } + if json.Unmarshal(data, &pi) == nil { + if pi.Name != "" { + name := pi.Name + if idx := strings.LastIndex(name, "/"); idx != -1 { + name = name[idx+1:] + } + m.Name = name + } + switch v := pi.Bin.(type) { + case string: + if v != "" { + rel := filepath.Clean(filepath.Join(m.Path, v)) + n := m.Name + if n == "" { + n = strings.TrimSuffix(filepath.Base(v), filepath.Ext(v)) + } + if n == "" || n == "." { + n = "bin" + } + m.Bins[n] = rel + } + case map[string]any: + for k, vv := range v { + if s, ok := vv.(string); ok && s != "" { + rel := filepath.Clean(filepath.Join(m.Path, s)) + m.Bins[k] = rel + } + } + } } } } else if m.Type == "go" { - mainDirs := make(map[string]struct{}) - for _, f := range allGoFiles { - if f == "" || goModuleOf[f] != p || strings.HasSuffix(f, "_test.go") { + goModPath := "go.mod" + if m.Path != "" { + goModPath = filepath.Join(m.Path, "go.mod") + } + if data, err := os.ReadFile(goModPath); err == nil { + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "module ") { + full := strings.TrimSpace(strings.TrimPrefix(line, "module ")) + m.Name = filepath.Base(full) + break + } + } + } + if m.Name == "" { + if m.Path == "" { + m.Name = repoName + } else { + m.Name = filepath.Base(m.Path) + } + } + + mainDirs := make(map[string]bool) + for _, gf := range allGoFiles { + if goToModule[gf] != m.Path { continue } - data, _ := os.ReadFile(f) - if strings.Contains(string(data), "\npackage main\n") || strings.HasPrefix(string(data), "package main\n") { - d := filepath.Dir(f) - if d == "." { - d = "" + if strings.HasSuffix(gf, "_test.go") { + continue + } + data, err := os.ReadFile(gf) + if err != nil { + continue + } + hasMain := false + for _, line := range strings.Split(string(data), "\n") { + if strings.TrimSpace(line) == "package main" { + hasMain = true + break } - mainDirs[d] = struct{}{} + } + if hasMain { + dir := filepath.Dir(gf) + if dir == "." { + dir = "" + } + mainDirs[dir] = true } } - for d := range mainDirs { - name := repoName - if d != "" { - name = filepath.Base(d) + for dir := range mainDirs { + var name, fullP string + if dir == "" || dir == "." { + name = repoName + fullP = "." + } else { + name = filepath.Base(dir) + fullP = dir + } + m.Bins[name] = fullP + if (dir == "" && m.Path == "") || dir == m.Path { + m.HasRootMain = true } - m.Bins[name] = d } } - modMap[p] = m - } - - // dirty - statusLines, _ := runGit("status", "--porcelain", ".") - porLines := strings.Split(statusLines, "\n") - dirty := make(map[string]bool) - modPrefixes := make([]string, 0, len(modMap)) - for p := range modMap { - if p != "" { - modPrefixes = append(modPrefixes, p+"/") + if m.Name == "" { + if m.Path == "" { + m.Name = repoName + } else { + m.Name = filepath.Base(m.Path) + } } } - sort.Slice(modPrefixes, func(i, j int) bool { return len(modPrefixes[i]) > len(modPrefixes[j]) }) - for _, line := range porLines { - if len(line) < 4 { + dirtyReasons := make(map[string]map[rune]bool) + por, _ := runGit("status", "--porcelain", ".") + for _, line := range strings.Split(por, "\n") { + if len(line) < 3 || line[2] != ' ' { continue } - st := line[:2] - file := line[3:] - if st == " " || st == "!!" { - continue + status := line[0:2] + fileP := line[3:] + + types := []rune{} + if status == "??" { + types = append(types, 'u') + } + if strings.Contains(status, "A") { + types = append(types, 'n') + } + if strings.Contains(status, "M") || strings.Contains(status, "R") || strings.Contains(status, "C") { + types = append(types, 'm') + } + if strings.Contains(status, "D") { + types = append(types, 'd') } - states := []rune{} - if st == "??" { - states = append(states, 'u') - } - if strings.Contains(st, "A") || strings.Contains(st, "?") { - states = append(states, 'n') - } - if strings.Contains(st, "M") || strings.Contains(st, "R") || strings.Contains(st, "C") { - states = append(states, 'm') - } - if strings.Contains(st, "D") { - states = append(states, 'd') - } - - isDirty := false - for _, r := range states { - if !ignoreSet[r] { - isDirty = true + thisDirty := false + for _, t := range types { + if !ignoreSet[t] { + thisDirty = true break } } - if !isDirty { + if !thisDirty { continue } - found := false + matched := false + modPrefixes := make([]string, 0, len(modules)) + for _, m := range modules { + if m.Path != "" { + modPrefixes = append(modPrefixes, m.Path+"/") + } + } + sort.Slice(modPrefixes, func(i, j int) bool { return len(modPrefixes[i]) > len(modPrefixes[j]) }) for _, pre := range modPrefixes { - if strings.HasPrefix(file, pre) { - dirty[strings.TrimSuffix(pre, "/")] = true - found = true + if strings.HasPrefix(fileP, pre) { + modP := strings.TrimSuffix(pre, "/") + if dirtyReasons[modP] == nil { + dirtyReasons[modP] = make(map[rune]bool) + } + for _, t := range types { + dirtyReasons[modP][t] = true + } + matched = true break } } - if !found { - dirty[""] = true + if !matched { + if dirtyReasons[""] == nil { + dirtyReasons[""] = make(map[rune]bool) + } + for _, t := range types { + dirtyReasons[""][t] = true + } } } - // tags - tagLines, _ := runGit("tag", "--list", "--sort=-version:refname") - tags := strings.Split(tagLines, "\n") - latestTag := make(map[string]string) + tagsS, _ := runGit("tag", "--list", "--sort=-version:refname") + tags := strings.Split(tagsS, "\n") + modLatest := make(map[string]string) for _, t := range tags { if t == "" { continue } - for p := range modMap { - if p == "" { + matched := false + for _, m := range modules { + match := false + if m.Path == "" { if !strings.Contains(t, "/") && isVersion(t) { - if _, ok := latestTag[p]; !ok { - latestTag[p] = t - } - break + match = true } - } else if strings.HasPrefix(t, p+"/") && isVersion(strings.TrimPrefix(t, p+"/")) { - if _, ok := latestTag[p]; !ok { - latestTag[p] = t + } else if strings.HasPrefix(t, m.Path+"/") { + suf := t[len(m.Path)+1:] + if isVersion(suf) { + match = true } + } + if match { + if _, ok := modLatest[m.Path]; !ok { + modLatest[m.Path] = t + } + matched = true break } } + if !matched && *verbose { + fmt.Printf("unmatched tag: %s\n", t) + } } - // rows - var rows []Row - for p, m := range modMap { - tag := latestTag[p] - suf := tag - if p != "" { - suf = strings.TrimPrefix(tag, p+"/") + rows := []Row{} + for _, m := range modules { + latest := modLatest[m.Path] + tagStr := "-" + if latest != "" { + tagStr = latest } - ver := "v0.0.0" - if tag != "" { - ver = suf + pArg := "." + if m.Path != "" { + pArg = "./" + m.Path } - commits := 0 - if tag != "" { - scope := "." - if p != "" { - scope = "./" + p + var commits int + ver := "v0.0.0" + if latest != "" { + suf := latest + if m.Path != "" { + suf = strings.TrimPrefix(latest, m.Path+"/") } - n, _ := runGit("rev-list", "--count", tag+"..", "--", scope) - c, _ := strconv.Atoi(n) - commits = c + ver = suf + cS, _ := runGit("rev-list", "--count", latest+"..", "--", pArg) + commits, _ = strconv.Atoi(cS) if commits > 0 { ver += fmt.Sprintf("-%d", commits) } } else { - ver += "-1" - } - if dirty[p] { - ver += "-dirty" + cS, _ := runGit("rev-list", "--count", "--", pArg) + commits, _ = strconv.Atoi(cS) + if commits > 0 { + ver = fmt.Sprintf("v0.0.0-%d", commits) + } } - pathShow := "." - if p != "" { - pathShow = "./" + p + dirtyStr := "" + if dr, ok := dirtyReasons[m.Path]; ok && len(dr) > 0 { + order := []rune{'u', 'n', 'm', 'd'} + for _, c := range order { + if dr[c] { + dirtyStr += string(c) + } + } } - statusParts := []string{} + status := "current" if m.Untracked { - statusParts = append(statusParts, "untracked") + status = "untracked" + } else if dirtyStr != "" { + status = "dirty (" + dirtyStr + ")" + } else if commits > 0 { + status = "new commits" } - if dirty[p] { - var ds []string - s, _ := runGit("status", "--porcelain", pathShow) - if strings.Contains(s, " M") { - ds = append(ds, "m") - } - s, _ = runGit("status", "--porcelain", pathShow) - if strings.Contains(s, "??") { - ds = append(ds, "u") - } - // simplified; can expand later - if len(ds) > 0 { - statusParts = append(statusParts, fmt.Sprintf("dirty (%s)", strings.Join(ds, ""))) - } - } - if commits > 0 { - statusParts = append(statusParts, "new commits") - } - if len(statusParts) == 0 { - statusParts = append(statusParts, "current") - } - status := strings.Join(statusParts, ", ") - // module row - rows = append(rows, Row{ - Status: status, - Type: "mod", - Name: filepath.Base(pathShow), - Version: ver, - CurrentTag: tag, - Path: filepath.Join(pathShow, m.Type+".mod"), - }) - - // bin rows - binNames := make([]string, 0, len(m.Bins)) - for n := range m.Bins { - binNames = append(binNames, n) + showModRow := true + if (m.Type == "go" && m.HasRootMain) || (m.Type == "node" && len(m.Bins) > 0) { + showModRow = false } - sort.Strings(binNames) - for _, name := range binNames { - binPath := m.Bins[name] - binShow := "." - if binPath != "" { - binShow = "./" + binPath - } + if showModRow { rows = append(rows, Row{ - Status: status, - Type: "bin", - Name: name, - Version: ver, - CurrentTag: tag, - Path: binShow + "/", + Status: status, + Typ: "mod", + Name: m.Name, + Version: ver, + Tag: tagStr, + Path: manifestPath(m), }) } - } - // output - if *csvOut { - w := csv.NewWriter(os.Stdout) - w.Comma = rune((*comma)[0]) - w.Write([]string{"status", "type", "name", "version", "current tag", "path"}) - for _, r := range rows { - w.Write([]string{r.Status, r.Type, r.Name, r.Version, r.CurrentTag, r.Path}) - } - w.Flush() - return - } - - // aligned table - var max [6]int - for _, r := range rows { - l := []string{r.Status, r.Type, r.Name, r.Version, r.CurrentTag, r.Path} - for i, s := range l { - if len(s) > max[i] { - max[i] = len(s) + if len(m.Bins) > 0 { + names := make([]string, 0, len(m.Bins)) + for n := range m.Bins { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + binLoc := m.Bins[n] + var binShow string + if m.Type == "go" { + if binLoc == "" || binLoc == "." { + binLoc = m.Path + if binLoc == "" { + binLoc = "." + } + } + if binLoc == "." { + binShow = "." + } else { + binShow = binLoc + "/" + } + } else { + binShow = binLoc + } + rows = append(rows, Row{ + Status: status, + Typ: "bin", + Name: n, + Version: ver, + Tag: tagStr, + Path: "./" + binShow, + }) } } } - fmt.Printf("%-*s | %-*s | %-*s | %-*s | %-*s | %s\n", - max[0], "status", - max[1], "type", - max[2], "name", - max[3], "version", - max[4], "current tag", - "path", - ) - fmt.Printf("%s-+-%s-+-%s-+-%s-+-%s-+-%s\n", - strings.Repeat("-", max[0]), - strings.Repeat("-", max[1]), - strings.Repeat("-", max[2]), - strings.Repeat("-", max[3]), - strings.Repeat("-", max[4]), - strings.Repeat("-", max[5]), - ) - - for _, r := range rows { - fmt.Printf("%-*s | %-*s | %-*s | %-*s | %-*s | %s\n", - max[0], r.Status, - max[1], r.Type, - max[2], r.Name, - max[3], r.Version, - max[4], r.CurrentTag, - r.Path, - ) + if *useCSV { + c := ',' + if *csvComma != "" && len(*csvComma) > 0 { + c = rune((*csvComma)[0]) + } + w := csv.NewWriter(os.Stdout) + w.Comma = c + _ = w.Write([]string{"status", "type", "name", "version", "current tag", "path"}) + for _, r := range rows { + _ = w.Write([]string{r.Status, r.Typ, r.Name, r.Version, r.Tag, r.Path}) + } + w.Flush() + } else { + headers := []string{"status", "type", "name", "version", "current tag", "path"} + colWidths := make([]int, len(headers)) + for i, h := range headers { + colWidths[i] = len(h) + } + for _, r := range rows { + if len(r.Status) > colWidths[0] { + colWidths[0] = len(r.Status) + } + if len(r.Typ) > colWidths[1] { + colWidths[1] = len(r.Typ) + } + if len(r.Name) > colWidths[2] { + colWidths[2] = len(r.Name) + } + if len(r.Version) > colWidths[3] { + colWidths[3] = len(r.Version) + } + if len(r.Tag) > colWidths[4] { + colWidths[4] = len(r.Tag) + } + if len(r.Path) > colWidths[5] { + colWidths[5] = len(r.Path) + } + } + fmt.Print("|") + for i, h := range headers { + fmt.Printf(" %-*s |", colWidths[i], h) + } + fmt.Println() + fmt.Print("|") + for _, w := range colWidths { + fmt.Printf(" %s |", strings.Repeat("-", w)) + } + fmt.Println() + for _, r := range rows { + fmt.Print("|") + fmt.Printf(" %-*s |", colWidths[0], r.Status) + fmt.Printf(" %-*s |", colWidths[1], r.Typ) + fmt.Printf(" %-*s |", colWidths[2], r.Name) + fmt.Printf(" %-*s |", colWidths[3], r.Version) + fmt.Printf(" %-*s |", colWidths[4], r.Tag) + fmt.Printf(" %-*s |", colWidths[5], r.Path) + fmt.Println() + } } }