WIP: feat(cmd/monorelease): better table

This commit is contained in:
AJ ONeal 2026-02-28 18:55:47 -07:00
parent c8c2b63e93
commit 3abf0838c2
No known key found for this signature in database

View File

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