feat(monorel): add --cmd flag, git status guard, and go.mod boundary walk

- findMainPackages: stop descending into child directories that have their
  own go.mod on disk (even if untracked), treating them as independent
  module roots so they are never double-counted with the parent module

- initModuleGroup: skip if the module has uncommitted changes (excluding
  files inside child module directories so a freshly-run --cmd step does
  not block the parent); new helpers hasUncommittedChanges,
  findChildModuleRoots

- monorel init --cmd: scans recursively for direct children of cmd/
  directories that contain package main but have no go.mod yet, runs
  go mod init + tidy for each, and prints a suggested commit at the end;
  new helpers initCmdModules, readModulePath, runPrintIn
This commit is contained in:
AJ ONeal 2026-02-28 17:16:22 -07:00
parent 69148f9edb
commit 3d065f4a41
No known key found for this signature in database

View File

@ -232,10 +232,11 @@ func runBump(args []string) {
func runInit(args []string) { func runInit(args []string) {
fs := flag.NewFlagSet("monorel init", flag.ExitOnError) fs := flag.NewFlagSet("monorel init", flag.ExitOnError)
var recursive, all, dryRun bool var recursive, all, dryRun, cmd bool
fs.BoolVar(&recursive, "recursive", false, "find all main packages recursively under each path") fs.BoolVar(&recursive, "recursive", false, "find all main packages recursively under each path")
fs.BoolVar(&all, "A", false, "include dot/underscore-prefixed directories; warn rather than error on failures") fs.BoolVar(&all, "A", false, "include dot/underscore-prefixed directories; warn rather than error on failures")
fs.BoolVar(&dryRun, "dry-run", false, "print what would happen without writing files, creating commits, or tags") fs.BoolVar(&dryRun, "dry-run", false, "print what would happen without writing files, creating commits, or tags")
fs.BoolVar(&cmd, "cmd", false, "for each cmd/ child with package main, run go mod init+tidy (suggests a commit at the end)")
fs.Usage = func() { fs.Usage = func() {
fmt.Fprintln(os.Stderr, "usage: monorel init [options] <binary-path>...") fmt.Fprintln(os.Stderr, "usage: monorel init [options] <binary-path>...")
fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "")
@ -244,9 +245,13 @@ func runInit(args []string) {
fmt.Fprintln(os.Stderr, " 2. Commits it (skipped if file is unchanged)") fmt.Fprintln(os.Stderr, " 2. Commits it (skipped if file is unchanged)")
fmt.Fprintln(os.Stderr, " 3. Creates an initial version tag (equivalent to 'bump patch')") fmt.Fprintln(os.Stderr, " 3. Creates an initial version tag (equivalent to 'bump patch')")
fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "With -cmd, first scans for cmd/ subdirectories and runs go mod init+tidy")
fmt.Fprintln(os.Stderr, "for each direct child that contains package main but has no go.mod yet.")
fmt.Fprintln(os.Stderr, "")
fmt.Fprintln(os.Stderr, "Examples:") fmt.Fprintln(os.Stderr, "Examples:")
fmt.Fprintln(os.Stderr, " monorel init ./auth/csvauth/cmd/csvauth") fmt.Fprintln(os.Stderr, " monorel init ./auth/csvauth/cmd/csvauth")
fmt.Fprintln(os.Stderr, " monorel init -recursive . # init all modules under current directory") fmt.Fprintln(os.Stderr, " monorel init -recursive . # init all modules under current directory")
fmt.Fprintln(os.Stderr, " monorel init -cmd -recursive . # make cmd/ children independently versioned")
fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "")
fs.PrintDefaults() fs.PrintDefaults()
} }
@ -257,6 +262,10 @@ func runInit(args []string) {
os.Exit(2) os.Exit(2)
} }
if cmd {
initCmdModules(binPaths, dryRun)
}
allPaths, err := expandPaths(binPaths, recursive, all) allPaths, err := expandPaths(binPaths, recursive, all)
if err != nil { if err != nil {
fatalf("%v", err) fatalf("%v", err)
@ -291,6 +300,15 @@ func initModuleGroup(group *moduleGroup, dryRun bool) {
fmt.Fprintf(os.Stderr, "monorel: skip: %s is at the repository root; binaries at the repo root cannot have prefixed tags\n", modRoot) fmt.Fprintf(os.Stderr, "monorel: skip: %s is at the repository root; binaries at the repo root cannot have prefixed tags\n", modRoot)
return return
} }
// Guard: skip if the module has uncommitted changes (files inside child
// module directories — those with their own go.mod on disk — are excluded
// so that a freshly-run --cmd step does not block the parent module).
if hasUncommittedChanges(modRoot) {
fmt.Fprintf(os.Stderr, "monorel: skip: %s has uncommitted changes; commit or stash them first\n", modRoot)
return
}
prefixParts := strings.Split(prefix, "/") prefixParts := strings.Split(prefix, "/")
projectName := prefixParts[len(prefixParts)-1] projectName := prefixParts[len(prefixParts)-1]
@ -554,6 +572,11 @@ func findMainPackages(root string, all bool) ([]string, error) {
continue continue
} }
child := filepath.Join(dir, name) child := filepath.Join(dir, name)
// Stop at directories with their own go.mod — they are independent
// module roots and should not be included in this module's walk.
if _, err := os.Stat(filepath.Join(child, "go.mod")); err == nil {
continue
}
// Skip directories that contain no git-tracked files. // Skip directories that contain no git-tracked files.
if trackedDirs != nil && !trackedDirs[child] { if trackedDirs != nil && !trackedDirs[child] {
continue continue
@ -596,6 +619,181 @@ func buildTrackedDirs(dir string) map[string]bool {
return dirs return dirs
} }
// readModulePath returns the module path declared in the go.mod file at
// modRoot, or "" if the file cannot be read or the module line is absent.
func readModulePath(modRoot string) string {
data, err := os.ReadFile(filepath.Join(modRoot, "go.mod"))
if err != nil {
return ""
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "module ") {
return strings.TrimSpace(strings.TrimPrefix(line, "module "))
}
}
return ""
}
// findChildModuleRoots returns the absolute paths of all subdirectories of
// modRoot that have their own go.mod on disk (even if untracked by git).
// It stops recursing past the first go.mod it finds in any subtree.
func findChildModuleRoots(modRoot string) []string {
var roots []string
var scan func(dir string)
scan = func(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
child := filepath.Join(dir, e.Name())
if _, err := os.Stat(filepath.Join(child, "go.mod")); err == nil {
roots = append(roots, child)
continue // don't recurse past a submodule
}
scan(child)
}
}
scan(modRoot)
return roots
}
// hasUncommittedChanges reports whether modRoot contains modified or untracked
// files according to "git status --porcelain". Files inside child module
// directories (subdirectories with their own go.mod on disk, even if untracked)
// are excluded from the check.
func hasUncommittedChanges(modRoot string) bool {
status := runIn(modRoot, "git", "status", "--porcelain", "--", ".")
if status == "" {
return false
}
childRoots := findChildModuleRoots(modRoot)
for _, line := range strings.Split(status, "\n") {
if len(line) < 4 {
continue
}
relFile := strings.TrimSpace(line[3:])
abs := filepath.Join(modRoot, filepath.FromSlash(relFile))
inChild := false
for _, cr := range childRoots {
if abs == cr || strings.HasPrefix(abs, cr+string(filepath.Separator)) {
inChild = true
break
}
}
if !inChild {
return true
}
}
return false
}
// initCmdModules scans each of roots recursively, and for every direct child
// of a directory named "cmd" that contains a Go main package but has no go.mod
// yet, runs "go mod init <path>" and "go mod tidy". At the end it prints a
// suggested git command to commit the new files.
func initCmdModules(roots []string, dryRun bool) {
var initialized []string
seen := make(map[string]bool)
var scan func(dir string)
scan = func(dir string) {
entries, err := os.ReadDir(dir)
if err != nil {
return
}
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
isStop := false
for _, stop := range stopMarkers {
if name == stop {
isStop = true
break
}
}
if isStop || (len(name) > 0 && (name[0] == '.' || name[0] == '_')) {
continue
}
child := filepath.Join(dir, name)
if name == "cmd" {
// Inspect each direct child of this cmd/ directory.
cmdEntries, err := os.ReadDir(child)
if err == nil {
for _, ce := range cmdEntries {
if !ce.IsDir() {
continue
}
target := filepath.Join(child, ce.Name())
if seen[target] {
continue
}
seen[target] = true
// Already has its own module — skip.
if _, err := os.Stat(filepath.Join(target, "go.mod")); err == nil {
continue
}
// Not a main package — skip.
if checkPackageMain(target) != nil {
continue
}
// Compute new module path from the parent module.
modRoot, err := findModuleRoot(target)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: --cmd: no module root for %s: %v\n", target, err)
continue
}
parentModPath := readModulePath(modRoot)
if parentModPath == "" {
fmt.Fprintf(os.Stderr, "warning: --cmd: cannot read module path from %s\n", modRoot)
continue
}
rel, _ := filepath.Rel(modRoot, target)
newModPath := parentModPath + "/" + filepath.ToSlash(rel)
if dryRun {
fmt.Fprintf(os.Stderr, "[dry-run] would init module %s\n", newModPath)
fmt.Fprintf(os.Stderr, "[dry-run] would run go mod tidy in %s\n", target)
} else {
fmt.Fprintf(os.Stderr, "init module %s\n", newModPath)
runPrintIn(target, "go", "mod", "init", newModPath)
runPrintIn(target, "go", "mod", "tidy")
}
initialized = append(initialized, target)
}
}
}
scan(child) // always recurse into children
}
}
for _, root := range roots {
abs, err := filepath.Abs(root)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: --cmd: resolving %s: %v\n", root, err)
continue
}
scan(abs)
}
if len(initialized) > 0 {
fmt.Fprintln(os.Stderr)
if dryRun {
fmt.Fprintf(os.Stderr, "[dry-run] %d cmd module(s) would be initialised\n", len(initialized))
} else {
fmt.Fprintf(os.Stderr, "note: initialised %d cmd module(s); to commit them, run:\n", len(initialized))
fmt.Fprintf(os.Stderr, " git add '**/cmd/**/go.*' && git commit -m \"chore(release): independently versioned modules for all\"\n")
}
} else if !dryRun {
fmt.Fprintln(os.Stderr, "note: --cmd: no uninitialised cmd modules found")
}
}
// findModuleRoot walks upward from absDir looking for a directory that // findModuleRoot walks upward from absDir looking for a directory that
// contains go.mod. It stops (with an error) if it encounters a stopMarker // contains go.mod. It stops (with an error) if it encounters a stopMarker
// (default: ".git") before finding go.mod, preventing searches from crossing // (default: ".git") before finding go.mod, preventing searches from crossing
@ -1160,6 +1358,19 @@ func runIn(dir, name string, args ...string) string {
return strings.TrimSpace(string(out)) return strings.TrimSpace(string(out))
} }
// runPrintIn runs name with args in dir, forwarding its stdout and stderr to
// the current process's stderr so that build-tool output (go mod tidy, etc.)
// is visible in the terminal.
func runPrintIn(dir, name string, args ...string) {
cmd := exec.Command(name, args...)
cmd.Dir = dir
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "warning: %s %v in %s: %v\n", name, args, dir, err)
}
}
func fatalf(format string, args ...any) { func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "monorel: error: "+format+"\n", args...) fmt.Fprintf(os.Stderr, "monorel: error: "+format+"\n", args...)
os.Exit(1) os.Exit(1)