Replace bash release helper scripts with Go progam

This commit is contained in:
jregan
2020-10-08 10:45:12 -07:00
parent 4052cd4fd8
commit 0c169e96e5
31 changed files with 2130 additions and 176 deletions

View File

@@ -0,0 +1,195 @@
package arguments
import (
"fmt"
"os"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/semver"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/utils"
)
const (
doItFlag = "--doIt"
cmdPin = "pin"
cmdUnPin = "unpin"
cmdTidy = "tidy"
cmdList = "list"
cmdRelease = "release"
cmdUnRelease = "unrelease"
cmdDebug = "debug"
)
var (
commands = []string{
cmdPin, cmdUnPin, cmdTidy, cmdList, cmdRelease, cmdUnRelease, cmdDebug}
// TODO: make this a PATH-like flag
// e.g.: --excludes ".git:.idea:site:docs"
excSlice = []string{
".git",
".github",
".idea",
"docs",
"examples",
"hack",
"site",
"releasing",
}
)
type Command int
const (
Tidy Command = iota
UnPin
Pin
List
Release
UnRelease
Debug
)
type Args struct {
cmd Command
moduleName misc.ModuleShortName
version semver.SemVer
bump semver.SvBump
doIt bool
}
func (a *Args) GetCommand() Command {
return a.cmd
}
func (a *Args) Bump() semver.SvBump {
return a.bump
}
func (a *Args) Version() semver.SemVer {
return a.version
}
func (a *Args) ModuleName() misc.ModuleShortName {
return a.moduleName
}
func (a *Args) Exclusions() (result []string) {
// Make sure the list has no repeats.
for k := range utils.SliceToSet(excSlice) {
result = append(result, k)
}
return
}
func (a *Args) DoIt() bool {
return a.doIt
}
type myArgs struct {
args []string
doIt bool
}
func (a *myArgs) next() (result string) {
if !a.more() {
panic("no args left")
}
result = a.args[0]
a.args = a.args[1:]
return
}
func (a *myArgs) more() bool {
return len(a.args) > 0
}
func newArgs() *myArgs {
result := &myArgs{}
for _, a := range os.Args[1:] {
if a == doItFlag {
result.doIt = true
} else {
result.args = append(result.args, a)
}
}
return result
}
func Parse() (result *Args, err error) {
result = &Args{}
clArgs := newArgs()
result.doIt = clArgs.doIt
result.moduleName = misc.ModuleUnknown
if !clArgs.more() {
return nil, fmt.Errorf("command needs at least one arg")
}
command := clArgs.next()
switch command {
case cmdPin:
if !clArgs.more() {
return nil, fmt.Errorf("pin needs a moduleName to pin")
}
result.moduleName = misc.ModuleShortName(clArgs.next())
if clArgs.more() {
result.version, err = semver.Parse(clArgs.next())
if err != nil {
return nil, err
}
} else {
result.version = semver.Zero()
}
result.cmd = Pin
case cmdUnPin:
if !clArgs.more() {
return nil, fmt.Errorf("unpin needs a moduleName to unpin")
}
result.moduleName = misc.ModuleShortName(clArgs.next())
result.cmd = UnPin
case cmdTidy:
result.cmd = Tidy
case cmdList:
result.cmd = List
case cmdRelease:
if !clArgs.more() {
return nil, fmt.Errorf("specify {module} to release")
}
result.moduleName = misc.ModuleShortName(clArgs.next())
bump := "patch"
if clArgs.more() {
bump = clArgs.next()
}
switch bump {
case "major":
result.bump = semver.Major
case "minor":
result.bump = semver.Minor
case "patch":
result.bump = semver.Patch
default:
return nil, fmt.Errorf(
"unknown bump %s; specify one of 'major', 'minor' or 'patch'", bump)
}
result.cmd = Release
case cmdUnRelease:
if !clArgs.more() {
return nil, fmt.Errorf("specify {module} to unrelease")
}
result.moduleName = misc.ModuleShortName(clArgs.next())
result.cmd = UnRelease
case cmdDebug:
if !clArgs.more() {
return nil, fmt.Errorf("specify {module} to debug")
}
result.moduleName = misc.ModuleShortName(clArgs.next())
result.cmd = Debug
default:
return nil, fmt.Errorf(
"unknown command %q; must be one of %v", command, commands)
}
if clArgs.more() {
return nil, fmt.Errorf("unknown extra args: %v", clArgs.args)
}
return
}

View File

@@ -0,0 +1,82 @@
package edit
import (
"fmt"
"os/exec"
"strings"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/semver"
)
// Editor runs `go mod` commands on an instance of Module.
// If doIt is false, the command is printed, but not run.
type Editor struct {
module misc.LaModule
doIt bool
}
func New(m misc.LaModule, doIt bool) *Editor {
return &Editor{
doIt: doIt,
module: m,
}
}
func (e *Editor) run(args ...string) error {
c := exec.Command(
"go",
append([]string{"mod"}, args...)...)
c.Dir = string(e.module.ShortName())
if e.doIt {
out, err := c.CombinedOutput()
if err != nil {
return fmt.Errorf("%s out=%q", err.Error(), out)
}
} else {
fmt.Printf("in %-60s; %s\n", c.Dir, c.String())
}
return nil
}
func upstairs(depth int) string {
var b strings.Builder
for i := 0; i < depth; i++ {
b.WriteString("../")
}
return b.String()
}
func (e *Editor) Tidy() error {
return e.run("tidy")
}
func (e *Editor) Pin(target misc.LaModule, oldV, newV semver.SemVer) error {
err := e.run(
"edit",
"-dropreplace="+target.ImportPath()+"@"+oldV.String(),
"-require="+target.ImportPath()+"@"+newV.String(),
)
if err != nil {
return err
}
return e.run("tidy")
}
func (e *Editor) UnPin(target misc.LaModule, oldV semver.SemVer) error {
var r strings.Builder
r.WriteString(target.ImportPath())
r.WriteString("@")
r.WriteString(oldV.String())
r.WriteString("=")
r.WriteString(upstairs(e.module.ShortName().Depth()))
r.WriteString(string(target.ShortName()))
err := e.run(
"edit",
"-replace="+r.String(),
)
if err != nil {
return err
}
return e.run("tidy")
}

View File

@@ -0,0 +1,32 @@
package edit
import (
"testing"
)
func TestUpstairs(t *testing.T) {
var testCases = map[string]struct {
depth int
expected string
}{
"zero": {
depth: 0,
expected: "",
},
"one": {
depth: 1,
expected: "../",
},
"five": {
depth: 5,
expected: "../../../../../",
},
}
for n, tc := range testCases {
if tc.expected != upstairs(tc.depth) {
t.Fatalf(
"%s: for depth %d, expected %q, got %q",
n, tc.depth, tc.expected, upstairs(tc.depth))
}
}
}

View File

@@ -0,0 +1,75 @@
package main
import (
"bufio"
"log"
"os"
"strings"
)
const (
inputFile = "README.md"
outputFile = "usage.go"
)
// Convert README.md to a usage function.
func main() {
inFile, err := os.Open(inputFile)
if err != nil {
log.Fatal(err)
}
defer inFile.Close()
scanner := bufio.NewScanner(inFile)
w := NewWriter(outputFile)
w.prLn("// Code generated by internal/gen/main.go; DO NOT EDIT.")
w.prLn("package main")
w.prLn("")
w.prLn("")
w.prLn("const (")
w.prLn(" usageMsg = `")
// Skip the first two lines.
scanner.Scan()
scanner.Scan()
for scanner.Scan() {
line := scanner.Text()
line = strings.Replace(line, "`", "'", -1)
line = strings.Replace(line, "\\[", "[", -1)
line = strings.Replace(line, "\\]", "]", -1)
w.prLn(line)
}
w.prLn("`")
w.prLn(")")
w.close()
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
}
type writer struct {
f *os.File
}
func NewWriter(n string) *writer {
f, err := os.Create(n)
if err != nil {
log.Fatalf("unable to create `%s`; %v", n, err)
}
return &writer{f: f}
}
func (w *writer) prLn(line string) {
_, err := w.f.WriteString(line + "\n")
if err != nil {
log.Printf("Trouble writing: %s", line)
log.Fatal(err)
}
}
func (w *writer) close() {
if err := w.f.Close(); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,340 @@
package git
import (
"fmt"
"os/exec"
"sort"
"strings"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/semver"
)
const (
refsTags = "refs/tags/"
pathSep = "/"
remoteOrigin = misc.TrackedRepo("origin")
remoteUpstream = misc.TrackedRepo("upstream")
mainBranch = "master"
indent = " "
doing = " [x] "
faking = " [ ] "
)
type safetyLevel int
const (
// Commands that don't hurt, e.g. checking out an existing branch.
noHarmDone safetyLevel = iota
// Commands that write, and could be hard to undo.
undoPainful
)
type Verbosity int
const (
Low Verbosity = iota
High
)
var recognizedRemotes = []misc.TrackedRepo{remoteUpstream, remoteOrigin}
// Runner runs specific git tasks using the git CLI.
type Runner struct {
// From which directory do we run the commands.
workDir string
// Run commands, or merely print commands.
doIt bool
// Run commands, or merely print commands.
verbosity Verbosity
}
func NewLoud(wd string, doIt bool) *Runner {
return newRunner(wd, doIt, High)
}
func NewQuiet(wd string, doIt bool) *Runner {
return newRunner(wd, doIt, Low)
}
func newRunner(wd string, doIt bool, v Verbosity) *Runner {
return &Runner{workDir: wd, doIt: doIt, verbosity: v}
}
func (gr *Runner) comment(f string) {
if gr.verbosity == Low {
return
}
fmt.Print(indent)
fmt.Println(f)
}
func (gr *Runner) doing(s string) {
if gr.verbosity == Low {
return
}
fmt.Print(indent)
fmt.Print(doing)
fmt.Println(s)
}
func (gr *Runner) faking(s string) {
if gr.verbosity == Low {
return
}
fmt.Print(indent)
fmt.Print(faking)
fmt.Println(s)
}
func (gr *Runner) run(sl safetyLevel, args ...string) (string, error) {
c := exec.Command("git", args...)
c.Dir = gr.workDir
if gr.doIt || sl == noHarmDone {
gr.doing(c.String())
out, err := c.CombinedOutput()
if err != nil {
return "", fmt.Errorf(
"%s out=%q", err.Error(), strings.TrimSpace(string(out)))
}
return string(out), nil
}
gr.faking(c.String())
return "", nil
}
func (gr *Runner) runNoOut(s safetyLevel, args ...string) error {
_, err := gr.run(s, args...)
return err
}
// TODO: allow for other remote names.
func (gr *Runner) DetermineRemoteToUse() (misc.TrackedRepo, error) {
gr.comment("determining remote to use")
out, err := gr.run(noHarmDone, "remote")
if err != nil {
return "", err
}
remotes := strings.Split(out, "\n")
if len(remotes) < 1 {
return "", fmt.Errorf("need at least one remote")
}
for _, n := range recognizedRemotes {
if contains(remotes, n) {
return n, nil
}
}
return "", fmt.Errorf(
"unable to find recognized remote %v", recognizedRemotes)
}
func contains(list []string, item misc.TrackedRepo) bool {
for _, n := range list {
if n == string(item) {
return true
}
}
return false
}
func (gr *Runner) LoadLocalTags() (result misc.VersionMap, err error) {
gr.comment("loading local tags")
var out string
out, err = gr.run(noHarmDone, "tag", "-l")
if err != nil {
return nil, err
}
result = make(misc.VersionMap)
lines := strings.Split(out, "\n")
for _, l := range lines {
n, v, err := parseModuleSpec(l)
if err != nil {
// ignore it
continue
}
result[n] = append(result[n], v)
}
for _, versions := range result {
sort.Sort(versions)
}
return
}
func (gr *Runner) LoadRemoteTags(
remote misc.TrackedRepo) (result misc.VersionMap, err error) {
gr.comment("loading remote tags")
var out string
out, err = gr.run(noHarmDone, "ls-remote", "--ref", string(remote))
if err != nil {
return nil, err
}
result = make(misc.VersionMap)
lines := strings.Split(out, "\n")
for _, l := range lines {
fields := strings.Fields(l)
if len(fields) < 2 {
// ignore it
continue
}
if !strings.HasPrefix(fields[1], refsTags) {
// ignore it
continue
}
path := fields[1][len(refsTags):]
n, v, err := parseModuleSpec(path)
if err != nil {
// ignore it
continue
}
result[n] = append(result[n], v)
}
for _, versions := range result {
sort.Sort(versions)
}
return
}
func parseModuleSpec(
path string) (n misc.ModuleShortName, v semver.SemVer, err error) {
fields := strings.Split(path, pathSep)
v, err = semver.Parse(fields[len(fields)-1])
if err != nil {
// Silently ignore versions we don't understand.
return "", v, err
}
n = misc.ModuleAtTop
if len(fields) > 1 {
n = misc.ModuleShortName(
strings.Join(fields[:len(fields)-1], pathSep))
}
return
}
func (gr *Runner) Debug(remote misc.TrackedRepo) error {
return nil // gr.CheckoutMainBranch(remote)
}
func (gr *Runner) AssureCleanWorkspace() error {
gr.comment("assuring a clean workspace")
out, err := gr.run(noHarmDone, "status")
if err != nil {
return err
}
if !strings.Contains(out, "nothing to commit, working tree clean") {
return fmt.Errorf("the workspace isn't clean")
}
return nil
}
func (gr *Runner) AssureOnMainBranch() error {
gr.comment("assuring main branch checked out")
out, err := gr.run(noHarmDone, "status")
if err != nil {
return err
}
if !strings.Contains(out, "On branch "+mainBranch) {
return fmt.Errorf("expected to be on branch %q", mainBranch)
}
return nil
}
// CheckoutMainBranch does that.
func (gr *Runner) CheckoutMainBranch() error {
gr.comment("checking out main branch")
return gr.runNoOut(noHarmDone, "checkout", mainBranch)
}
// FetchRemote does that.
func (gr *Runner) FetchRemote(remote misc.TrackedRepo) error {
gr.comment("fetching remote")
return gr.runNoOut(noHarmDone, "fetch", string(remote))
}
// MergeFromRemoteMain does a fast forward only merge with main branch.
func (gr *Runner) MergeFromRemoteMain(remote misc.TrackedRepo) error {
remo := strings.Join(
[]string{string(remote), mainBranch}, pathSep)
gr.comment("merging from remote")
return gr.runNoOut(undoPainful, "merge", "--ff-only", remo)
}
// CheckoutReleaseBranch attempts to checkout or create a branch.
// If it's on the remote already, fail if we cannot check it out locally.
func (gr *Runner) CheckoutReleaseBranch(
remote misc.TrackedRepo, branch string) error {
yes, err := gr.doesRemoteBranchExist(remote, branch)
if err != nil {
return err
}
if yes {
gr.comment("checking out branch")
if out, err := gr.run(noHarmDone, "checkout", branch); err != nil {
fmt.Printf("error with checkout: %q", err.Error())
fmt.Printf("out: %q", out)
return fmt.Errorf(
"branch %q exists on remote %q, but isn't present locally",
branch, string(remote))
}
return nil
}
gr.comment("creating branch")
// The branch doesn't exist. Create it.
out, err := gr.run(noHarmDone, "checkout", "-b", branch)
if err != nil {
return err
}
if !strings.Contains(out, "Switched to a new branch ") {
return fmt.Errorf("unexpected branch creation output: %q", out)
}
return nil
}
func (gr *Runner) doesRemoteBranchExist(
remote misc.TrackedRepo, branch string) (bool, error) {
gr.comment("looking for branch on remote")
out, err := gr.run(noHarmDone, "branch", "-r")
if err != nil {
return false, err
}
lookFor := strings.Join([]string{string(remote), branch}, pathSep)
lines := strings.Split(out, "\n")
for _, l := range lines {
if strings.TrimSpace(l) == lookFor {
return true, nil
}
}
return false, nil
}
func (gr *Runner) PushBranchToRemote(
remote misc.TrackedRepo, branch string) error {
gr.comment("pushing branch to remote")
return gr.runNoOut(undoPainful, "push", "-f", string(remote), branch)
}
func (gr *Runner) CreateLocalReleaseTag(tag, branch string) error {
msg := fmt.Sprintf("\"Release %s on branch %s\"", tag, branch)
gr.comment("creating local release tag")
return gr.runNoOut(
undoPainful,
"tag", "-a",
"-m", msg,
tag)
}
func (gr *Runner) DeleteLocalTag(tag string) error {
gr.comment("deleting local tag")
return gr.runNoOut(undoPainful, "tag", "--delete", tag)
}
func (gr *Runner) PushTagToRemote(
remote misc.TrackedRepo, tag string) error {
gr.comment("pushing tag to remote")
return gr.runNoOut(undoPainful, "push", string(remote), tag)
}
func (gr *Runner) DeleteTagFromRemote(
remote misc.TrackedRepo, tag string) error {
gr.comment("deleting tags from remote")
return gr.runNoOut(undoPainful, "push", string(remote), ":"+refsTags+tag)
}

View File

@@ -0,0 +1,126 @@
package misc
import (
"fmt"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/semver"
)
// ModFunc is a function accepting a module, and returning an error.
type ModFunc func(LaModule) error
type LaRepository interface {
// RepoPath is the import of the of repository,
// e.g. github.com/kubernetes-sigs/kustomize
// The directory {srcRoot}/{importPath} should contain a
// dotGit directory.
// This directory might be a Go module, or contain directories
// that are Go modules, or both.
RepoPath() string
// AbsPath is the full local filesystem path.
AbsPath() string
// FindModule returns a module or nil.
FindModule(ModuleShortName) LaModule
}
type LaModule interface {
// ShortName is the module's name without the repo.
ShortName() ModuleShortName
// ImportPath is the relative path below the Go src root,
// which is the same path as would be used to
// import the module.
ImportPath() string
// AbsPath is the absolute path to the module's
// go.mod file on the local file system.
AbsPath() string
// Latest version tagged locally.
VersionLocal() semver.SemVer
// Latest version tagged remotely.
VersionRemote() semver.SemVer
// Does this module depend on the argument, and
// if so at what version?
DependsOn(LaModule) (bool, semver.SemVer)
// GetReplacements returns a list of replacements.
GetReplacements() []string
}
// VersionMap holds the versions associated with modules.
type VersionMap map[ModuleShortName]semver.Versions
func (m VersionMap) Report() {
for n, versions := range m {
fmt.Println(n)
for _, v := range versions {
fmt.Print(" ")
fmt.Println(v)
}
}
}
func (m VersionMap) Latest(
n ModuleShortName) semver.SemVer {
versions := m[n]
if versions == nil {
return semver.Zero()
}
return versions[0]
}
type LesModules []LaModule
func (s LesModules) LenLongestName() (ans int) {
for _, m := range s {
l := len(m.ShortName())
if l > ans {
ans = l
}
}
return
}
func (s LesModules) Apply(f ModFunc) error {
for _, m := range s {
err := f(m)
if err != nil {
return err
}
}
return nil
}
func (s LesModules) Find(target ModuleShortName) LaModule {
for _, m := range s {
if m.ShortName() == target {
return m
}
}
return nil
}
func (s LesModules) GetAllThatDependOn(
target LaModule) (result TaggedModules) {
for _, m := range s {
if yes, v := m.DependsOn(target); yes {
result = append(result, TaggedModule{M: m, V: v})
}
}
return
}
func (s LesModules) InternalDeps(
target LaModule) (result TaggedModules) {
for _, m := range s {
if yes, v := target.DependsOn(m); yes {
result = append(result, TaggedModule{M: m, V: v})
}
}
return
}

View File

@@ -0,0 +1,23 @@
package misc
import (
"path/filepath"
"strings"
)
// ModuleShortName is the in-repo path to the directory holding the module
// (holding the go.mod file). It's the unique in-repo name of the module.
// It's the name used to tag the repo at a particular module version.
// E.g. "" (empty), "kyaml", "cmd/config", "plugin/example/whatever".
type ModuleShortName string
// Never used in a tag.
const ModuleAtTop = ModuleShortName("{top}")
const ModuleUnknown = ModuleShortName("{unknown}")
func (m ModuleShortName) Depth() int {
if m == ModuleAtTop || m == ModuleUnknown {
return 0
}
return strings.Count(string(m), string(filepath.Separator)) + 1
}

View File

@@ -0,0 +1,36 @@
package misc_test
import (
"testing"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc"
)
func TestDepth(t *testing.T) {
var testCases = map[string]struct {
path string
expectedDepth int
}{
"zero": {
path: "{top}",
expectedDepth: 0,
},
"one": {
path: "one",
expectedDepth: 1,
},
"three": {
path: "one/two/three",
expectedDepth: 3,
},
}
for n, tc := range testCases {
m := misc.ModuleShortName(tc.path)
d := m.Depth()
if d != tc.expectedDepth {
t.Fatalf(
"%s: %s, expected %d, got %d",
n, tc.path, tc.expectedDepth, d)
}
}
}

View File

@@ -0,0 +1,42 @@
package misc
import (
"fmt"
"strings"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/semver"
)
// TaggedModule is a module known to be tagged with the given version.
type TaggedModule struct {
M LaModule
V semver.SemVer
}
func (p TaggedModule) String() string {
if p.V.IsZero() {
return string(p.M.ShortName())
}
return string(p.M.ShortName()) + "/" + p.V.String()
}
type TaggedModules []TaggedModule
func (s TaggedModules) String() string {
// format := "%-"+strconv.Itoa(s.LenLongestString()+2)+"s"
var b strings.Builder
for i := range s {
b.WriteString(fmt.Sprintf("%-15s", s[i]))
}
return b.String()
}
func (s TaggedModules) LenLongestString() (ans int) {
for _, m := range s {
l := len(m.String())
if l > ans {
ans = l
}
}
return
}

View File

@@ -0,0 +1,4 @@
package misc
// TrackedRepo identifies a git remote repository.
type TrackedRepo string

View File

@@ -0,0 +1,79 @@
package mod
import (
"fmt"
"path/filepath"
"golang.org/x/mod/modfile"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/semver"
)
// Module is an immutable representation of a Go module.
type Module struct {
repo misc.LaRepository
shortName misc.ModuleShortName
mf *modfile.File
vLocal semver.SemVer
vRemote semver.SemVer
}
func New(
repo misc.LaRepository,
shortName misc.ModuleShortName,
mf *modfile.File,
vl semver.SemVer,
vr semver.SemVer) *Module {
return &Module{
repo: repo,
shortName: shortName,
mf: mf,
vLocal: vl,
vRemote: vr,
}
}
func (m *Module) GitRepo() misc.LaRepository {
return m.repo
}
func (m *Module) VersionLocal() semver.SemVer {
return m.vLocal
}
func (m *Module) VersionRemote() semver.SemVer {
return m.vRemote
}
func (m *Module) ShortName() misc.ModuleShortName {
return m.shortName
}
func (m *Module) ImportPath() string {
return filepath.Join(m.repo.RepoPath(), string(m.ShortName()))
}
func (m *Module) AbsPath() string {
return filepath.Join(m.repo.AbsPath(), string(m.ShortName()))
}
func (m *Module) DependsOn(target misc.LaModule) (bool, semver.SemVer) {
for _, r := range m.mf.Require {
if r.Mod.Path == target.ImportPath() {
v, err := semver.Parse(r.Mod.Version)
if err != nil {
panic(err)
}
return true, v
}
}
return false, semver.Zero()
}
func (m *Module) GetReplacements() (result []string) {
for _, r := range m.mf.Replace {
result = append(
result, fmt.Sprintf("%s => %s", r.Old.String(), r.New.String()))
}
return
}

View File

@@ -0,0 +1 @@
package mod_test

View File

@@ -0,0 +1,134 @@
package repo
import (
"fmt"
"path/filepath"
"strings"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/git"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/utils"
)
const (
dotGitFileName = ".git"
srcHint = "/src/"
goModFile = "go.mod"
)
// DotGitData holds basic information about a local .git file
type DotGitData struct {
// srcPath is the absolute path to the local Go src directory.
// This used to be $GOPATH/src.
// It's the directory containing git repository clones.
srcPath string
// The path below srcPath to a particular repository
// directory, a directory containing a .git directory.
// Typically {repoOrg}/{repoUserName}, e.g. sigs.k8s.io/cli-utils
repoPath string
}
func (dg *DotGitData) SrcPath() string {
return dg.srcPath
}
func (dg *DotGitData) RepoPath() string {
return dg.repoPath
}
func (dg *DotGitData) AbsPath() string {
return filepath.Join(dg.srcPath, dg.repoPath)
}
// NewDotGitDataFromPath wants the incoming path to hold dotGit
// E.g.
// ~/gopath/src/sigs.k8s.io/kustomize
// ~/gopath/src/github.com/monopole/gorepomod
func NewDotGitDataFromPath(path string) (*DotGitData, error) {
if !utils.DirExists(filepath.Join(path, dotGitFileName)) {
return nil, fmt.Errorf(
"%q doesn't have a %q file", path, dotGitFileName)
}
// This is an attempt to figure out where the user has cloned
// their repos. In the old days, it was an import path under
// $GOPATH/src. If we cannot guess it, we may need to ask for it,
// or maybe proceed without knowing it.
index := strings.Index(path, srcHint)
if index < 0 {
return nil, fmt.Errorf(
"path %q doesn't contain %q", path, srcHint)
}
return &DotGitData{
srcPath: path[:index+len(srcHint)-1],
repoPath: path[index+len(srcHint):],
}, nil
}
// It's a factory factory.
func (dg *DotGitData) NewRepoFactory(
exclusions []string) (*ManagerFactory, error) {
modules, err := loadProtoModules(dg.AbsPath(), exclusions)
if err != nil {
return nil, err
}
err = dg.checkModules(modules)
if err != nil {
return nil, err
}
runner := git.NewQuiet(dg.AbsPath(), true)
remoteName, err := runner.DetermineRemoteToUse()
if err != nil {
return nil, err
}
// Some tags might exist for modules that
// have been renamed or deleted; ignore those.
// There might be newer tags locally than remote,
// so report both.
localTags, err := runner.LoadLocalTags()
if err != nil {
return nil, err
}
remoteTags, err := runner.LoadRemoteTags(remoteName)
if err != nil {
return nil, err
}
return &ManagerFactory{
dg: dg,
modules: modules,
remoteName: remoteName,
versionMapLocal: localTags,
versionMapRemote: remoteTags,
}, nil
}
func (dg *DotGitData) checkModules(modules []*protoModule) error {
for _, pm := range modules {
file := filepath.Join(pm.PathToGoMod(), goModFile)
// Do the paths make sense?
if !strings.HasPrefix(pm.FullPath(), dg.RepoPath()) {
return fmt.Errorf(
"module %q doesn't start with the repository name %q",
pm.FullPath(), dg.RepoPath())
}
shortName := pm.ShortName(dg.RepoPath())
if shortName == misc.ModuleAtTop {
if pm.PathToGoMod() != dg.AbsPath() {
return fmt.Errorf("in %q, problem with top module", file)
}
} else {
// Do the relative path and short name make sense?
if !strings.HasSuffix(pm.PathToGoMod(), string(shortName)) {
return fmt.Errorf(
"in %q, the module name %q doesn't match the file's pathToGoMod %q",
file, shortName, pm.PathToGoMod())
}
}
}
return nil
}

View File

@@ -0,0 +1,6 @@
package repo
import "testing"
func TestLoadTags(t *testing.T) {
}

View File

@@ -0,0 +1,194 @@
package repo
import (
"fmt"
"strconv"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/edit"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/git"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/semver"
)
// Manager manages a git repo.
// All data already loaded and validated, it's ready to go.
type Manager struct {
// Underlying file system facts.
dg *DotGitData
// The remote used for fetching tags, pushing tags,
// and pushing release branches.
remoteName misc.TrackedRepo
// The list of known Go modules in the repo.
modules misc.LesModules
}
func (mgr *Manager) AbsPath() string {
return mgr.dg.AbsPath()
}
func (mgr *Manager) RepoPath() string {
return mgr.dg.RepoPath()
}
func (mgr *Manager) FindModule(
target misc.ModuleShortName) misc.LaModule {
return mgr.modules.Find(target)
}
func (mgr *Manager) Tidy(doIt bool) error {
return mgr.modules.Apply(func(m misc.LaModule) error {
return edit.New(m, doIt).Tidy()
})
}
func (mgr *Manager) Pin(
doIt bool, target misc.LaModule, newV semver.SemVer) error {
return mgr.modules.Apply(func(m misc.LaModule) error {
if yes, oldVersion := m.DependsOn(target); yes {
return edit.New(m, doIt).Pin(target, oldVersion, newV)
}
return nil
})
}
func (mgr *Manager) UnPin(doIt bool, target misc.LaModule) error {
return mgr.modules.Apply(func(m misc.LaModule) error {
if yes, oldVersion := m.DependsOn(target); yes {
return edit.New(m, doIt).UnPin(target, oldVersion)
}
return nil
})
}
func hasUnPinnedDeps(m misc.LaModule) string {
if len(m.GetReplacements()) > 0 {
return "yes"
}
return ""
}
func (mgr *Manager) List() error {
fmt.Printf(" src path: %s\n", mgr.dg.SrcPath())
fmt.Printf(" repo path: %s\n", mgr.RepoPath())
fmt.Printf(" remote: %s\n", mgr.remoteName)
format := "%-" +
strconv.Itoa(mgr.modules.LenLongestName()+2) +
"s%-11s%-11s%17s %s\n"
fmt.Printf(
format, "NAME", "LOCAL", "REMOTE",
"HAS-UNPINNED-DEPS", "INTRA-REPO-DEPENDENCIES")
return mgr.modules.Apply(func(m misc.LaModule) error {
fmt.Printf(
format, m.ShortName(),
m.VersionLocal().Pretty(),
m.VersionRemote().Pretty(),
hasUnPinnedDeps(m),
mgr.modules.InternalDeps(m))
return nil
})
}
func determineBranchAndTag(
m misc.LaModule, v semver.SemVer) (string, string) {
if m.ShortName() == misc.ModuleAtTop {
return fmt.Sprintf("release-%s", v.BranchLabel()), v.String()
}
return fmt.Sprintf(
"release-%s-%s", m.ShortName(), v.BranchLabel()),
string(m.ShortName()) + "/" + v.String()
}
func (mgr *Manager) Debug(_ misc.LaModule, doIt bool) error {
gr := git.NewLoud(mgr.AbsPath(), doIt)
return gr.Debug(mgr.remoteName)
}
// Release supports a gitlab flow style release process.
//
// * All development happens in the branch named "master".
// * Each minor release gets its own branch.
// *
func (mgr *Manager) Release(
target misc.LaModule, bump semver.SvBump, doIt bool) error {
if reps := target.GetReplacements(); len(reps) > 0 {
return fmt.Errorf(
"to release %q, first pin these replacements: %v",
target.ShortName(), reps)
}
newVersion := target.VersionLocal().Bump(bump)
if newVersion.Equals(target.VersionRemote()) {
return fmt.Errorf(
"version %s already exists on remote - delete it first", newVersion)
}
if newVersion.LessThan(target.VersionRemote()) {
fmt.Printf(
"version %s is less than the most recent remote version (%s)",
newVersion, target.VersionRemote())
}
gr := git.NewLoud(mgr.AbsPath(), doIt)
relBranch, relTag := determineBranchAndTag(target, newVersion)
fmt.Printf(
"Releasing %s, stepping from %s to %s\n",
target.ShortName(), target.VersionLocal(), newVersion)
if err := gr.AssureCleanWorkspace(); err != nil {
return err
}
if err := gr.FetchRemote(mgr.remoteName); err != nil {
return err
}
if err := gr.CheckoutMainBranch(); err != nil {
return err
}
if err := gr.MergeFromRemoteMain(mgr.remoteName); err != nil {
return err
}
if err := gr.AssureCleanWorkspace(); err != nil {
return err
}
if err := gr.CheckoutReleaseBranch(mgr.remoteName, relBranch); err != nil {
return err
}
if err := gr.MergeFromRemoteMain(mgr.remoteName); err != nil {
return err
}
if err := gr.PushBranchToRemote(mgr.remoteName, relBranch); err != nil {
return err
}
if err := gr.CreateLocalReleaseTag(relTag, relBranch); err != nil {
return err
}
if err := gr.PushTagToRemote(mgr.remoteName, relTag); err != nil {
return err
}
if err := gr.CheckoutMainBranch(); err != nil {
return err
}
return nil
}
func (mgr *Manager) UnRelease(target misc.LaModule, doIt bool) error {
fmt.Printf(
"Unreleasing %s/%s\n",
target.ShortName(), target.VersionRemote())
_, tag := determineBranchAndTag(target, target.VersionRemote())
gr := git.NewLoud(mgr.AbsPath(), doIt)
if err := gr.DeleteTagFromRemote(mgr.remoteName, tag); err != nil {
return err
}
if err := gr.DeleteLocalTag(tag); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,35 @@
package repo
import (
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/mod"
)
// ManagerFactory is a collection of clean data needed to build
// clean, fully wired up instances of Manager.
type ManagerFactory struct {
dg *DotGitData
modules []*protoModule
remoteName misc.TrackedRepo
versionMapLocal misc.VersionMap
versionMapRemote misc.VersionMap
}
func (mf *ManagerFactory) NewRepoManager() *Manager {
result := &Manager{
dg: mf.dg,
remoteName: mf.remoteName,
}
var modules misc.LesModules
for _, pm := range mf.modules {
shortName := pm.ShortName(mf.dg.RepoPath())
modules = append(
modules,
mod.New(
result, shortName, pm.mf,
mf.versionMapLocal.Latest(shortName),
mf.versionMapRemote.Latest(shortName)))
}
result.modules = modules
return result
}

View File

@@ -0,0 +1,101 @@
package repo
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"golang.org/x/mod/modfile"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/utils"
)
const (
dotDir = "."
)
// protoModule holds parts being collected to represent a module.
type protoModule struct {
pathToGoMod string
mf *modfile.File
}
func (pm *protoModule) FullPath() string {
return pm.mf.Module.Mod.Path
}
func (pm *protoModule) PathToGoMod() string {
return pm.pathToGoMod
}
// Represents the trailing version label in a module name.
// See https://blog.golang.org/v2-go-modules
var trailingVersionPattern = regexp.MustCompile("/v\\d+$")
func (pm *protoModule) ShortName(
repoImportPath string) misc.ModuleShortName {
fp := pm.FullPath()
if fp == repoImportPath {
return misc.ModuleAtTop
}
p := fp[len(repoImportPath)+1:]
stripped := trailingVersionPattern.ReplaceAllString(p, "")
return misc.ModuleShortName(stripped)
}
func loadProtoModules(
repoRoot string, exclusions []string) (result []*protoModule, err error) {
var paths []string
paths, err = getPathsToModules(repoRoot, exclusions)
if err != nil {
return
}
for _, p := range paths {
var pm *protoModule
pm, err = loadProtoModule(p)
if err != nil {
return
}
result = append(result, pm)
}
return
}
func loadProtoModule(path string) (*protoModule, error) {
mPath := filepath.Join(path, goModFile)
content, err := ioutil.ReadFile(mPath)
if err != nil {
return nil, fmt.Errorf("error reading %q: %v\n", mPath, err)
}
f, err := modfile.Parse(mPath, content, nil)
if err != nil {
return nil, err
}
return &protoModule{pathToGoMod: path, mf: f}, nil
}
func getPathsToModules(
repoRoot string, exclusions []string) (result []string, err error) {
exclusionMap := utils.SliceToSet(exclusions)
err = filepath.Walk(
repoRoot,
func(path string, info os.FileInfo, err error) error {
if err != nil {
return fmt.Errorf("trouble at pathToGoMod %q: %v\n", path, err)
}
if info.IsDir() {
if _, ok := exclusionMap[info.Name()]; ok {
return filepath.SkipDir
}
return nil
}
if info.Name() == goModFile {
result = append(result, path[:len(path)-len(goModFile)-1])
return filepath.SkipDir
}
return nil
})
return
}

View File

@@ -0,0 +1,47 @@
package repo
import (
"testing"
"golang.org/x/mod/modfile"
"golang.org/x/mod/module"
"sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc"
)
func TestShortName(t *testing.T) {
var testCases = map[string]struct {
name misc.ModuleShortName
modFile *modfile.File
}{
"one": {
name: misc.ModuleShortName("garage"),
modFile: &modfile.File{
Module: &modfile.Module{
Mod: module.Version{
Path: "gh.com/micheal/garage",
Version: "v2.3.4",
},
},
},
},
"three": {
name: misc.ModuleShortName("fruit/yellow/banana"),
modFile: &modfile.File{
Module: &modfile.Module{
Mod: module.Version{
Path: "gh.com/micheal/fruit/yellow/banana",
Version: "v2.3.4",
},
},
},
},
}
for n, tc := range testCases {
m := protoModule{pathToGoMod: "irrelevant", mf: tc.modFile}
actual := m.ShortName("gh.com/micheal")
if actual != tc.name {
t.Errorf(
"%s: expected %s, got %s", n, tc.name, actual)
}
}
}

View File

@@ -0,0 +1,98 @@
package semver
import (
"fmt"
"strconv"
"strings"
)
// SemVer is the immutable semantic version per https://semver.org
type SemVer struct {
major int
minor int
patch int
}
func New(major, minor, patch int) SemVer {
return SemVer{
major: major,
minor: minor,
patch: patch,
}
}
var zero = New(0, 0, 0)
func Zero() SemVer {
return zero
}
// Versions implements sort.Interface to get decreasing order.
type Versions []SemVer
func (v Versions) Len() int { return len(v) }
func (v Versions) Less(i, j int) bool { return v[j].LessThan(v[i]) }
func (v Versions) Swap(i, j int) { v[i], v[j] = v[j], v[i] }
func Parse(raw string) (SemVer, error) {
if len(raw) < 6 {
// e.g. minimal length is 6, e.g. "v1.2.3"
return zero, fmt.Errorf("%q too short to be a version", raw)
}
if raw[0] != 'v' {
return zero, fmt.Errorf("%q must start with letter 'v'", raw)
}
fields := strings.Split(raw[1:], ".")
if len(fields) < 3 {
return zero, fmt.Errorf("%q doesn't have the form v1.2.3", raw)
}
n := make([]int, 3)
for i := 0; i < 3; i++ {
var err error
n[i], err = strconv.Atoi(fields[i])
if err != nil {
return zero, err
}
}
return New(n[0], n[1], n[2]), nil
}
func (v SemVer) Bump(b SvBump) SemVer {
switch b {
case Major:
return New(v.major+1, 0, 0)
case Minor:
return New(v.major, v.minor+1, 0)
default:
return New(v.major, v.minor, v.patch+1)
}
}
func (v SemVer) BranchLabel() string {
return fmt.Sprintf("v%d.%d", v.major, v.minor)
}
func (v SemVer) String() string {
return fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch)
}
func (v SemVer) Pretty() string {
if v.IsZero() {
return ""
}
return v.String()
}
func (v SemVer) Equals(o SemVer) bool {
return v.major == o.major && v.minor == o.minor && v.patch == o.patch
}
func (v SemVer) LessThan(o SemVer) bool {
return v.major < o.major ||
(v.major == o.major && v.minor < o.minor) ||
(v.major == o.major && v.minor == o.minor && v.patch < o.patch)
}
func (v SemVer) IsZero() bool {
return v.Equals(zero)
}

View File

@@ -0,0 +1,105 @@
package semver
import (
"testing"
)
func TestParse(t *testing.T) {
var testCases = map[string]struct {
raw string
v SemVer
errMsg string
}{
"one": {
raw: "v1.2.3",
v: SemVer{major: 1, minor: 2, patch: 3},
errMsg: "",
},
"two": {
raw: "v2.0.9999",
v: SemVer{major: 2, minor: 0, patch: 9999},
errMsg: "",
},
"three": {
raw: "pizza",
v: zero,
errMsg: "\"pizza\" too short to be a version",
},
"non-digit": {
raw: "v1.x.222",
v: zero,
errMsg: "strconv.Atoi: parsing \"x\": invalid syntax",
},
"bad fields": {
raw: "v1.222",
v: zero,
errMsg: "\"v1.222\" doesn't have the form v1.2.3",
},
}
for n, tc := range testCases {
v, err := Parse(tc.raw)
if err == nil {
if tc.errMsg != "" {
t.Errorf(
"%s: no error, but expected err %q", n, tc.errMsg)
}
if !v.Equals(tc.v) {
t.Errorf(
"%s: expected %v, got %v", n, tc.v, v)
}
} else {
if tc.errMsg == "" {
t.Errorf(
"%s: unexpected error %v", n, err)
} else {
if tc.errMsg != err.Error() {
t.Errorf(
"%s: expected err msg %q, but got %q",
n, tc.errMsg, err.Error())
}
}
}
}
}
func TestLessThan(t *testing.T) {
var testCases = map[string]struct {
v1 SemVer
v2 SemVer
expected bool
}{
"one": {
v1: SemVer{major: 2, minor: 2, patch: 3},
v2: SemVer{major: 1, minor: 2, patch: 3},
expected: false,
},
"two": {
v1: SemVer{major: 1, minor: 3, patch: 3},
v2: SemVer{major: 1, minor: 2, patch: 3},
expected: false,
},
"three": {
v1: SemVer{major: 1, minor: 2, patch: 4},
v2: SemVer{major: 1, minor: 2, patch: 3},
expected: false,
},
"eq": {
v1: SemVer{major: 2, minor: 2, patch: 3},
v2: SemVer{major: 2, minor: 2, patch: 3},
expected: false,
},
"four": {
v1: zero,
v2: SemVer{major: 0, minor: 0, patch: 1},
expected: true,
},
}
for n, tc := range testCases {
actual := tc.v1.LessThan(tc.v2)
if actual != tc.expected {
t.Errorf(
"%s: expected %v, got %v for %s LessThan %s",
n, tc.expected, actual, tc.v1.String(), tc.v2.String())
}
}
}

View File

@@ -0,0 +1,17 @@
package semver
type SvBump int
const (
Patch SvBump = iota
Minor
Major
)
func (b SvBump) String() string {
return map[SvBump]string{
Patch: "Patch",
Minor: "Minor",
Major: "Major",
}[b]
}

View File

@@ -0,0 +1,26 @@
package utils
import (
"log"
"os"
)
func DirExists(name string) bool {
info, err := os.Stat(name)
if os.IsNotExist(err) {
return false
}
return info.IsDir()
}
func SliceToSet(slice []string) map[string]bool {
result := make(map[string]bool)
for _, x := range slice {
if _, ok := result[x]; ok {
log.Fatalf("programmer error - repeated value: %s", x)
} else {
result[x] = true
}
}
return result
}