674 lines
14 KiB
Go

// monorelease - Manages releases for code in monorepos.
//
// Authored in 2026 by AJ ONeal <aj@therootcompany.com>, assisted by Grok Ai.
// To the extent possible under law, the author(s) have dedicated all copyright
// and related and neighboring rights to this software to the public domain
// worldwide. This software is distributed without any warranty.
//
// You should have received a copy of the CC0 Public Domain Dedication along with
// this software. If not, see <https://creativecommons.org/publicdomain/zero/1.0/>.
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"sync"
)
func runGit(args ...string) (string, error) {
cmd := exec.Command("git", args...)
var b bytes.Buffer
cmd.Stdout = &b
err := cmd.Run()
return strings.TrimSpace(b.String()), err
}
func RunGoFrom(chdir string, args ...string) (string, error) {
cmd := exec.Command("go", args...)
if chdir != "" {
cmd.Dir = chdir
}
var b bytes.Buffer
cmd.Stdout = &b
err := cmd.Run()
return strings.TrimSpace(b.String()), err
}
func isVersion(s string) bool {
re := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$`)
return re.MatchString(s)
}
type Tag struct {
ShortHash string
Name string
}
type GitFile struct {
Path string
XY string
Releasable string
}
func (f GitFile) IsClean() bool {
return f.XY == ""
}
func (f GitFile) IsTracked() bool {
return f.XY != "??"
}
type Releasable struct {
Status string
Type string
Name string
Version string
CurrentTag string
Path string
releasable string
}
type Releaser struct {
Root string
Prefix string
Committed []string
Untracked []string
TagList []Tag
StatusLines []string
GitFiles map[string]GitFile
AllGoFiles []string
RepoName string
Ignore map[rune]bool
GoModulePrefixes []string
GoToModule map[string]string
ModulePrefixes []string
}
type GoModule struct {
PackageName string
Path string
}
type NodePackage struct {
Path string
}
func (r *Releaser) Init() {
var wg sync.WaitGroup
var untracked string
var tagsStr string
wg.Go(func() {
out, _ := runGit("remote", "get-url", "origin")
r.RepoName, _ = strings.CutSuffix(strings.TrimSpace(path.Base(out)), ".git")
})
wg.Go(func() {
out, _ := runGit("rev-parse", "--show-toplevel")
r.Root = strings.TrimSpace(out)
})
wg.Go(func() {
out, _ := runGit("rev-parse", "--show-prefix")
r.Prefix = strings.TrimSuffix(strings.TrimSpace(out), "/")
})
wg.Go(func() {
out, _ := runGit("ls-files", "--others", "--exclude-standard")
untracked = strings.TrimSpace(out)
})
wg.Go(func() {
out, _ := runGit("tag", "--list", "--sort=version:refname", "--format=%(objectname:short=7) %(refname:strip=2)")
tagsStr = strings.TrimSpace(out)
})
wg.Wait()
if untracked != "" {
r.Untracked = strings.Split(untracked, "\n")
}
if tagsStr != "" {
for line := range strings.SplitSeq(tagsStr, "\n") {
if line == "" {
continue
}
parts := strings.Fields(line)
if len(parts) >= 2 {
r.TagList = append(r.TagList, Tag{ShortHash: parts[0], Name: parts[1]})
}
}
}
for i, j := 0, len(r.TagList)-1; i < j; i, j = i+1, j-1 {
r.TagList[i], r.TagList[j] = r.TagList[j], r.TagList[i]
}
statusStr, _ := runGit("status", "--porcelain", ".")
r.StatusLines = strings.Split(statusStr, "\n")
lsStr, _ := runGit("ls-files")
r.Committed = strings.Split(lsStr, "\n")
r.GitFiles = make(map[string]GitFile)
for _, line := range r.StatusLines {
if len(line) < 3 || line[2] != ' ' {
continue
}
xy := line[0:2]
p := line[3:]
r.GitFiles[p] = GitFile{Path: p, XY: xy, Releasable: ""}
}
goCommStr, _ := runGit("ls-files", "--", "*.go")
goUntrStr, _ := runGit("ls-files", "--others", "--exclude-standard", "--", "*.go")
for _, s := range []string{goCommStr, goUntrStr} {
if s == "" {
continue
}
for f := range strings.SplitSeq(s, "\n") {
if f != "" {
r.AllGoFiles = append(r.AllGoFiles, f)
}
}
}
_ = os.Chdir(r.Root)
}
func (r *Releaser) LatestTag(modPath string) string {
for _, t := range r.TagList {
match := false
if modPath == "" {
if !strings.Contains(t.Name, "/") && isVersion(t.Name) {
match = true
}
} else if suf, ok := strings.CutPrefix(t.Name, modPath+"/"); ok {
if isVersion(suf) {
match = true
}
}
if match {
return t.Name
}
}
return ""
}
func getDirtyTypes(xy string) []rune {
types := []rune{}
if xy == "??" {
types = append(types, '?')
return types
}
if strings.Contains(xy, "A") {
types = append(types, 'A')
}
if strings.ContainsAny(xy, "M") {
types = append(types, 'M')
}
if strings.ContainsAny(xy, "R") {
types = append(types, 'R')
}
if strings.ContainsAny(xy, "C") {
types = append(types, 'C')
}
if strings.Contains(xy, "D") {
types = append(types, 'D')
}
return types
}
func (r *Releaser) DirtyStates(modPath string) map[rune]bool {
dr := make(map[rune]bool)
if modPath != "" {
pre := modPath + "/"
for p, gf := range r.GitFiles {
if p == modPath || strings.HasPrefix(p, pre) {
for _, t := range getDirtyTypes(gf.XY) {
dr[t] = true
}
}
}
} else {
for p, gf := range r.GitFiles {
matched := false
for _, pre := range r.ModulePrefixes {
if pre != "" && strings.HasPrefix(p, pre) {
matched = true
break
}
}
if !matched {
for _, t := range getDirtyTypes(gf.XY) {
dr[t] = true
}
}
}
}
return dr
}
func getVersionStatus(r *Releaser, modPath, manifestRel string) (ver string, commits int, tagStr, status string) {
ver = "" // empty for no change
latest := r.LatestTag(modPath)
tagStr = "-"
if latest != "" {
tagStr = latest
}
pArg := "."
if modPath != "" {
pArg = "./" + modPath
}
var suf string
if latest != "" {
suf = latest
if modPath != "" {
suf = strings.TrimPrefix(latest, modPath+"/")
}
cS, _ := runGit("rev-list", "--count", latest+"..", "--", pArg)
commits, _ = strconv.Atoi(cS)
if commits > 0 {
ver = fmt.Sprintf("%s-%d", suf, commits)
}
} else {
ver = "v0.1.0"
cS, _ := runGit("rev-list", "--count", "--", pArg)
commits, _ = strconv.Atoi(cS)
if commits > 0 {
ver = fmt.Sprintf("%s-%d", ver, commits)
}
}
dirtyMap := r.DirtyStates(modPath)
dirtyStr := ""
for _, c := range []rune{'?', 'A', 'M', 'R', 'C', 'D'} {
if dirtyMap[c] && !r.Ignore[c] {
dirtyStr += string(c)
}
}
untrackedMod := false
if gf, ok := r.GitFiles[manifestRel]; ok && gf.XY == "??" {
untrackedMod = true
} else {
untrackedMod = slices.Contains(r.Untracked, manifestRel)
}
status = "" // current, clean
if untrackedMod {
status = "-" // untracked
ver = "-"
if tagStr == "-" {
tagStr = ""
}
} else if dirtyStr != "" {
if ver == "" {
// ver = "v0.0.0"
ver = suf
}
ver += "+dev"
status = "dirty (" + dirtyStr + ")"
} else if commits > 0 {
status = "++"
}
return
}
func (r *Releaser) DiscoverGoModules() []GoModule {
modCh := make(chan GoModule, 10)
var wg sync.WaitGroup
var mods []GoModule
go func() {
for mod := range modCh {
mods = append(mods, mod)
}
}()
for _, f := range append(r.Committed, r.Untracked...) {
if f == "" {
continue
}
if f == "go.mod" || strings.HasSuffix(f, "/go.mod") {
p := filepath.Dir(f)
if p == "." {
p = ""
}
mods = append(mods, GoModule{PackageName: "", Path: p})
// TODO for when we need the real package name
wg.Go(func() {
// pkg, _ := RunGoFrom(p, "list", "-f", "{{.Name}}", ".")
// modCh <- GoModule{PackageName: pkg, Path: p}
})
}
}
wg.Wait()
close(modCh)
sort.Slice(mods, func(i, j int) bool { return mods[i].Path < mods[j].Path })
r.GoModulePrefixes = make([]string, 0, len(mods))
for _, m := range mods {
pre := m.Path
if pre != "" {
pre += "/"
}
r.GoModulePrefixes = append(r.GoModulePrefixes, pre)
}
sort.Slice(r.GoModulePrefixes, func(i, j int) bool { return len(r.GoModulePrefixes[i]) > len(r.GoModulePrefixes[j]) })
r.GoToModule = make(map[string]string)
for _, gf := range r.AllGoFiles {
dir := filepath.Dir(gf)
if dir == "." {
dir = ""
}
matchP := ""
for _, pre := range r.GoModulePrefixes {
if pre != "" && strings.HasPrefix(dir+"/", pre) || pre == "" {
matchP = strings.TrimSuffix(pre, "/")
break
}
}
r.GoToModule[gf] = matchP
}
return mods
}
func (r *Releaser) DiscoverNodePackages() []NodePackage {
seen := make(map[string]bool)
for _, f := range append(r.Committed, r.Untracked...) {
if f == "" {
continue
}
if f == "package.json" || strings.HasSuffix(f, "/package.json") {
p := filepath.Dir(f)
if p == "." {
p = ""
}
seen[p] = true
}
}
var pkgs []NodePackage
for p := range seen {
pkgs = append(pkgs, NodePackage{Path: p})
}
sort.Slice(pkgs, func(i, j int) bool { return pkgs[i].Path < pkgs[j].Path })
return pkgs
}
func (m GoModule) Process(r *Releaser) []Releasable {
manifestRel := "go.mod"
if m.Path != "" {
manifestRel = m.Path + "/go.mod"
}
ver, _, tagStr, status := getVersionStatus(r, m.Path, manifestRel)
name := ""
goModFile := manifestRel
if data, err := os.ReadFile(goModFile); err == nil {
for line := range strings.SplitSeq(string(data), "\n") {
line = strings.TrimSpace(line)
if full, ok := strings.CutPrefix(line, "module "); ok {
name = filepath.Base(full)
break
}
}
}
if name == "" {
if m.Path == "" {
name = r.RepoName
} else {
name = filepath.Base(m.Path)
}
}
mainDirs := make(map[string]bool)
for _, gf := range r.AllGoFiles {
if r.GoToModule[gf] != m.Path {
continue
}
if strings.HasSuffix(gf, "_test.go") {
continue
}
data, err := os.ReadFile(gf)
if err != nil {
continue
}
hasMain := false
for line := range strings.SplitSeq(string(data), "\n") {
if strings.TrimSpace(line) == "package main" {
hasMain = true
break
}
}
if hasMain {
dir := filepath.Dir(gf)
if dir == "." {
dir = ""
}
mainDirs[dir] = true
}
}
bins := make(map[string]string)
for dir := range mainDirs {
var bname, fullP string
if dir == "" || dir == "." {
bname = r.RepoName
fullP = "."
} else {
bname = filepath.Base(dir)
fullP = dir
}
bins[bname] = fullP
}
hasRootMain := false
rootD := m.Path
if rootD == "" {
rootD = ""
}
for d := range mainDirs {
if d == rootD {
hasRootMain = true
break
}
}
showModRow := !hasRootMain
rows := []Releasable{}
if showModRow {
relname := m.Path
if relname == "" {
relname = r.RepoName
}
rows = append(rows, Releasable{
Status: status,
Type: "mod",
Name: name,
Version: ver,
CurrentTag: tagStr,
Path: "./" + manifestRel,
releasable: relname,
})
}
if len(bins) > 0 {
names := make([]string, 0, len(bins))
for n := range bins {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
fullP := bins[n]
binShow := fullP
if fullP == "" || fullP == "." {
binShow = "."
} else {
binShow += "/"
}
rows = append(rows, Releasable{
Status: status,
Type: "bin",
Name: n,
Version: ver,
CurrentTag: tagStr,
Path: "./" + binShow,
releasable: strings.TrimSuffix(binShow, "/"),
})
}
}
return rows
}
func (p NodePackage) Process(r *Releaser) []Releasable {
manifestRel := "package.json"
if p.Path != "" {
manifestRel = p.Path + "/package.json"
}
ver, _, tagStr, status := getVersionStatus(r, p.Path, manifestRel)
pkgFile := manifestRel
name := ""
bins := make(map[string]string)
if data, err := os.ReadFile(pkgFile); 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:]
}
}
switch v := pi.Bin.(type) {
case string:
if v != "" {
rel := filepath.Clean(filepath.Join(p.Path, v))
n := name
if n == "" {
n = strings.TrimSuffix(filepath.Base(v), filepath.Ext(v))
}
if n == "" || n == "." {
n = "bin"
}
bins[n] = rel
}
case map[string]any:
for k, vv := range v {
if s, ok := vv.(string); ok && s != "" {
rel := filepath.Clean(filepath.Join(p.Path, s))
bins[k] = rel
}
}
}
}
}
if name == "" {
if p.Path == "" {
name = r.RepoName
} else {
name = filepath.Base(p.Path)
}
}
showModRow := len(bins) == 0
rows := []Releasable{}
if showModRow {
rows = append(rows, Releasable{
Status: status,
Type: "mod",
Name: name,
Version: ver,
CurrentTag: tagStr,
Path: "./" + manifestRel,
})
}
if len(bins) > 0 {
names := make([]string, 0, len(bins))
for n := range bins {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
rel := bins[n]
rows = append(rows, Releasable{
Status: status,
Type: "bin",
Name: n,
Version: ver,
CurrentTag: tagStr,
Path: "./" + rel,
})
}
}
return rows
}
type MainConfig struct {
ignoreDirty string
useCSV bool
csvComma string
rows []Releasable
}
func main() {
cli := &MainConfig{}
fs := flag.NewFlagSet("rerelease status", flag.ExitOnError)
// var verbose = flag.Bool("verbose", false, "")
fs.StringVar(&cli.ignoreDirty, "ignore-dirty", "", "ignore dirty states [? A M R C D]")
fs.BoolVar(&cli.useCSV, "csv", false, "output CSV instead of table")
fs.StringVar(&cli.csvComma, "comma", ",", "CSV field separator")
_ = fs.Parse(os.Args[1:])
cli.init()
cli.status()
}
func (cli *MainConfig) init() {
r := &Releaser{}
r.Init()
r.Ignore = make(map[rune]bool)
for _, c := range cli.ignoreDirty {
r.Ignore[c] = true
}
goMods := r.DiscoverGoModules()
nodePkgs := r.DiscoverNodePackages()
r.ModulePrefixes = make([]string, 0)
for _, gm := range goMods {
pre := gm.Path
if pre != "" {
pre += "/"
}
r.ModulePrefixes = append(r.ModulePrefixes, pre)
}
for _, np := range nodePkgs {
pre := np.Path
if pre != "" {
pre += "/"
}
r.ModulePrefixes = append(r.ModulePrefixes, pre)
}
sort.Slice(r.ModulePrefixes, func(i, j int) bool {
return len(r.ModulePrefixes[i]) > len(r.ModulePrefixes[j])
})
cli.rows = []Releasable{}
for _, m := range goMods {
cli.rows = append(cli.rows, m.Process(r)...)
}
for _, p := range nodePkgs {
cli.rows = append(cli.rows, p.Process(r)...)
}
}