diff --git a/Makefile b/Makefile index 9412603ce..21e22ff6c 100644 --- a/Makefile +++ b/Makefile @@ -48,8 +48,8 @@ $(MYGOBIN)/golangci-lint-kustomize: ) $(MYGOBIN)/gorepomod: - cd api; \ - go install github.com/monopole/gorepomod + cd cmd/gorepomod; \ + go install . $(MYGOBIN)/mdrip: cd api; \ diff --git a/cmd/gorepomod/Makefile b/cmd/gorepomod/Makefile new file mode 100644 index 000000000..c85352386 --- /dev/null +++ b/cmd/gorepomod/Makefile @@ -0,0 +1,15 @@ +MYGOBIN := $(shell go env GOPATH)/bin + +$(MYGOBIN)/gorepomod: usage.go + go install . + +.PHONY: test +test: $(MYGOBIN)/gorepomod + go test ./... + +usage.go: README.md $(MYGOBIN)/goimports + go generate . \ + $(MYGOBIN)/goimports -w usage.go + +$(MYGOBIN)/goimports: + go install golang.org/x/tools/cmd/goimports diff --git a/cmd/gorepomod/README.md b/cmd/gorepomod/README.md new file mode 100644 index 000000000..38b372464 --- /dev/null +++ b/cmd/gorepomod/README.md @@ -0,0 +1,103 @@ +# gorepomod + +Helps when you have a git repository with multiple Go modules. + +It handles tasks one might otherwise attempt with + +``` +find ./ -name "go.mod" | xargs {some hack} +``` + +Run it from a git repository root. + +It walks the repository, reads `go.mod` files, builds +a model of Go modules and intra-repo module +dependencies, then performs some operation. + +Install: +``` +go get github.com/monopole/gorepomod +``` + +## Usage + +_Commands that change things (everything but `list`) +do nothing but log commands +unless you add the `--doIt` flag, +allowing the change._ + +#### `gorepomod list` + +Lists modules and intra-repo dependencies. + +Use this to get module names for use in other commands. + +#### `gorepomod tidy` + +Creates a change with mechanical updates +to `go.mod` and `go.sum` files. + +#### `gorepomod unpin {module}` + +Creates a change to `go.mod` files. + +For each module _m_ in the repository, +if _m_ depends on a _{module}_, +then _m_'s dependency on it will be replaced by +a relative path to the in-repo module. + +#### `gorepomod pin {module} [{version}]` + +Creates a change to `go.mod` files. + +The opposite of `unpin`. + +The change removes replacements and pins _m_ to a +specific, previously tagged and released version of _{module}_. + +The argument _{version}_ defaults to recent version of _{module}_. + +_{version}_ should be in semver form, e.g. `v1.2.3`. + + +#### `gorepomod release {module} [patch|minor|major]` + +Computes a new version for the module, tags the repo +with that version, and pushes the tag to the remote. + +The value of the 2nd argument, either `patch` (the default), +`minor` or `major`, determines the new version. + +If the existing version is _v1.2.7_, then the new version will be: + - `patch` -> _v1.2.8_ + - `minor` -> _v1.3.0_ + - `major` -> _v2.0.0_ + +After establishing the the version, the command looks for a branch named + +> _release-{module}/-v{major}.{minor}_ + +If the branch doesn't exist, the command creates it and pushes it to the remote. + +The command then creates a new tag in the form + +> _{module}/v{major}.{minor}.{patch}_ + +The command pushes this tag to the remote. This typically triggers +cloud activity to create release artifacts. + +#### `gorepomod unrelease {module}` + +This undoes the work of `release`, by deleting the +most recent tag both locally and at the remote. + +You can then fix whatever, and re-release. + +This, however, must be done almost immediately. + +If there's a chance someone (or some cloud robot) already +imported the module at the given tag, then don't do this, +because it will confuse module caches. + +Do a new patch release instead. + diff --git a/cmd/gorepomod/go.mod b/cmd/gorepomod/go.mod new file mode 100644 index 000000000..39769bd52 --- /dev/null +++ b/cmd/gorepomod/go.mod @@ -0,0 +1,5 @@ +module sigs.k8s.io/kustomize/cmd/gorepomod + +go 1.15 + +require golang.org/x/mod v0.3.0 diff --git a/cmd/gorepomod/go.sum b/cmd/gorepomod/go.sum new file mode 100644 index 000000000..b33195612 --- /dev/null +++ b/cmd/gorepomod/go.sum @@ -0,0 +1,14 @@ +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898 h1:/atklqdjdhuosWIl6AIbOeHJjicWYPqR9bpxqxYG2pA= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/cmd/gorepomod/internal/arguments/args.go b/cmd/gorepomod/internal/arguments/args.go new file mode 100644 index 000000000..cef4f7f45 --- /dev/null +++ b/cmd/gorepomod/internal/arguments/args.go @@ -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 +} diff --git a/cmd/gorepomod/internal/edit/editor.go b/cmd/gorepomod/internal/edit/editor.go new file mode 100644 index 000000000..85ae43311 --- /dev/null +++ b/cmd/gorepomod/internal/edit/editor.go @@ -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") +} diff --git a/cmd/gorepomod/internal/edit/editor_test.go b/cmd/gorepomod/internal/edit/editor_test.go new file mode 100644 index 000000000..e39449cde --- /dev/null +++ b/cmd/gorepomod/internal/edit/editor_test.go @@ -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)) + } + } +} diff --git a/cmd/gorepomod/internal/gen/main.go b/cmd/gorepomod/internal/gen/main.go new file mode 100644 index 000000000..2f6acd3f7 --- /dev/null +++ b/cmd/gorepomod/internal/gen/main.go @@ -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) + } +} diff --git a/cmd/gorepomod/internal/git/runner.go b/cmd/gorepomod/internal/git/runner.go new file mode 100644 index 000000000..c30a8a852 --- /dev/null +++ b/cmd/gorepomod/internal/git/runner.go @@ -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) +} diff --git a/cmd/gorepomod/internal/misc/interfaces.go b/cmd/gorepomod/internal/misc/interfaces.go new file mode 100644 index 000000000..b5074c6fc --- /dev/null +++ b/cmd/gorepomod/internal/misc/interfaces.go @@ -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 +} diff --git a/cmd/gorepomod/internal/misc/moduleshortname.go b/cmd/gorepomod/internal/misc/moduleshortname.go new file mode 100644 index 000000000..6a115ffdc --- /dev/null +++ b/cmd/gorepomod/internal/misc/moduleshortname.go @@ -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 +} diff --git a/cmd/gorepomod/internal/misc/moduleshortname_test.go b/cmd/gorepomod/internal/misc/moduleshortname_test.go new file mode 100644 index 000000000..ce5786565 --- /dev/null +++ b/cmd/gorepomod/internal/misc/moduleshortname_test.go @@ -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) + } + } +} diff --git a/cmd/gorepomod/internal/misc/taggedmodule.go b/cmd/gorepomod/internal/misc/taggedmodule.go new file mode 100644 index 000000000..9b853991a --- /dev/null +++ b/cmd/gorepomod/internal/misc/taggedmodule.go @@ -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 +} diff --git a/cmd/gorepomod/internal/misc/trackedrepo.go b/cmd/gorepomod/internal/misc/trackedrepo.go new file mode 100644 index 000000000..c38751d2c --- /dev/null +++ b/cmd/gorepomod/internal/misc/trackedrepo.go @@ -0,0 +1,4 @@ +package misc + +// TrackedRepo identifies a git remote repository. +type TrackedRepo string diff --git a/cmd/gorepomod/internal/mod/module.go b/cmd/gorepomod/internal/mod/module.go new file mode 100644 index 000000000..e51b454cb --- /dev/null +++ b/cmd/gorepomod/internal/mod/module.go @@ -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 +} diff --git a/cmd/gorepomod/internal/mod/module_test.go b/cmd/gorepomod/internal/mod/module_test.go new file mode 100644 index 000000000..99e3556fd --- /dev/null +++ b/cmd/gorepomod/internal/mod/module_test.go @@ -0,0 +1 @@ +package mod_test diff --git a/cmd/gorepomod/internal/repo/dotgitdata.go b/cmd/gorepomod/internal/repo/dotgitdata.go new file mode 100644 index 000000000..96b4f040a --- /dev/null +++ b/cmd/gorepomod/internal/repo/dotgitdata.go @@ -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 +} diff --git a/cmd/gorepomod/internal/repo/dotgitdata_test.go b/cmd/gorepomod/internal/repo/dotgitdata_test.go new file mode 100644 index 000000000..aa0c9a249 --- /dev/null +++ b/cmd/gorepomod/internal/repo/dotgitdata_test.go @@ -0,0 +1,6 @@ +package repo + +import "testing" + +func TestLoadTags(t *testing.T) { +} diff --git a/cmd/gorepomod/internal/repo/manager.go b/cmd/gorepomod/internal/repo/manager.go new file mode 100644 index 000000000..4d8930c30 --- /dev/null +++ b/cmd/gorepomod/internal/repo/manager.go @@ -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 +} diff --git a/cmd/gorepomod/internal/repo/managerfactory.go b/cmd/gorepomod/internal/repo/managerfactory.go new file mode 100644 index 000000000..d2ede87a8 --- /dev/null +++ b/cmd/gorepomod/internal/repo/managerfactory.go @@ -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 +} diff --git a/cmd/gorepomod/internal/repo/protomodule.go b/cmd/gorepomod/internal/repo/protomodule.go new file mode 100644 index 000000000..bfff59d25 --- /dev/null +++ b/cmd/gorepomod/internal/repo/protomodule.go @@ -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 +} diff --git a/cmd/gorepomod/internal/repo/protomodule_test.go b/cmd/gorepomod/internal/repo/protomodule_test.go new file mode 100644 index 000000000..791294fe2 --- /dev/null +++ b/cmd/gorepomod/internal/repo/protomodule_test.go @@ -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) + } + } +} diff --git a/cmd/gorepomod/internal/semver/semver.go b/cmd/gorepomod/internal/semver/semver.go new file mode 100644 index 000000000..8dcb2fd42 --- /dev/null +++ b/cmd/gorepomod/internal/semver/semver.go @@ -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) +} diff --git a/cmd/gorepomod/internal/semver/semver_test.go b/cmd/gorepomod/internal/semver/semver_test.go new file mode 100644 index 000000000..d89734a96 --- /dev/null +++ b/cmd/gorepomod/internal/semver/semver_test.go @@ -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()) + } + } +} diff --git a/cmd/gorepomod/internal/semver/svbump.go b/cmd/gorepomod/internal/semver/svbump.go new file mode 100644 index 000000000..2ccddf696 --- /dev/null +++ b/cmd/gorepomod/internal/semver/svbump.go @@ -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] +} diff --git a/cmd/gorepomod/internal/utils/utils.go b/cmd/gorepomod/internal/utils/utils.go new file mode 100644 index 000000000..c252373ad --- /dev/null +++ b/cmd/gorepomod/internal/utils/utils.go @@ -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 +} diff --git a/cmd/gorepomod/main.go b/cmd/gorepomod/main.go new file mode 100644 index 000000000..dcffd89e8 --- /dev/null +++ b/cmd/gorepomod/main.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + "os" + + "sigs.k8s.io/kustomize/cmd/gorepomod/internal/arguments" + "sigs.k8s.io/kustomize/cmd/gorepomod/internal/misc" + "sigs.k8s.io/kustomize/cmd/gorepomod/internal/repo" +) + +//go:generate go run internal/gen/main.go + +func loadRepoManager(args *arguments.Args) (*repo.Manager, error) { + path, err := os.Getwd() + if err != nil { + return nil, err + } + dg, err := repo.NewDotGitDataFromPath(path) + if err != nil { + return nil, err + } + pr, err := dg.NewRepoFactory(args.Exclusions()) + if err != nil { + return nil, err + } + return pr.NewRepoManager(), nil +} + +func actualMain() error { + args, err := arguments.Parse() + if err != nil { + return err + } + + mgr, err := loadRepoManager(args) + if err != nil { + return err + } + + var targetModule misc.LaModule = nil + if args.ModuleName() != misc.ModuleUnknown { + targetModule = mgr.FindModule(args.ModuleName()) + if targetModule == nil { + return fmt.Errorf( + "cannot find module %q in repo %s", + args.ModuleName(), mgr.RepoPath()) + } + } + + switch args.GetCommand() { + case arguments.List: + return mgr.List() + case arguments.Tidy: + return mgr.Tidy(args.DoIt()) + case arguments.Pin: + v := args.Version() + if v.IsZero() { + v = targetModule.VersionLocal() + } + return mgr.Pin(args.DoIt(), targetModule, v) + case arguments.UnPin: + return mgr.UnPin(args.DoIt(), targetModule) + case arguments.Release: + return mgr.Release(targetModule, args.Bump(), args.DoIt()) + case arguments.UnRelease: + return mgr.UnRelease(targetModule, args.DoIt()) + case arguments.Debug: + return mgr.Debug(targetModule, args.DoIt()) + default: + return fmt.Errorf("cannot handle cmd %v", args.GetCommand()) + } +} + +func main() { + if len(os.Args) < 2 { + fmt.Print(usageMsg) + return + } + if err := actualMain(); err != nil { + fmt.Println(err) + os.Exit(1) + } +} diff --git a/cmd/gorepomod/usage.go b/cmd/gorepomod/usage.go new file mode 100644 index 000000000..11d577dca --- /dev/null +++ b/cmd/gorepomod/usage.go @@ -0,0 +1,109 @@ +// Code generated by internal/gen/main.go; DO NOT EDIT. +package main + + +const ( + usageMsg = ` +Helps when you have a git repository with multiple Go modules. + +It handles tasks one might otherwise attempt with + +''' +find ./ -name "go.mod" | xargs {some hack} +''' + +Run it from a git repository root. + +It walks the repository, reads 'go.mod' files, builds +a model of Go modules and intra-repo module +dependencies, then performs some operation. + +Install: +''' +go get github.com/monopole/gorepomod +''' + +## Usage + +_Commands that change things (everything but 'list') +do nothing but log commands +unless you add the '--doIt' flag, +allowing the change._ + +#### 'gorepomod list' + +Lists modules and intra-repo dependencies. + +Use this to get module names for use in other commands. + +#### 'gorepomod tidy' + +Creates a change with mechanical updates +to 'go.mod' and 'go.sum' files. + +#### 'gorepomod unpin {module}' + +Creates a change to 'go.mod' files. + +For each module _m_ in the repository, +if _m_ depends on a _{module}_, +then _m_'s dependency on it will be replaced by +a relative path to the in-repo module. + +#### 'gorepomod pin {module} [{version}]' + +Creates a change to 'go.mod' files. + +The opposite of 'unpin'. + +The change removes replacements and pins _m_ to a +specific, previously tagged and released version of _{module}_. + +The argument _{version}_ defaults to recent version of _{module}_. + +_{version}_ should be in semver form, e.g. 'v1.2.3'. + + +#### 'gorepomod release {module} [patch|minor|major]' + +Computes a new version for the module, tags the repo +with that version, and pushes the tag to the remote. + +The value of the 2nd argument, either 'patch' (the default), +'minor' or 'major', determines the new version. + +If the existing version is _v1.2.7_, then the new version will be: + - 'patch' -> _v1.2.8_ + - 'minor' -> _v1.3.0_ + - 'major' -> _v2.0.0_ + +After establishing the the version, the command looks for a branch named + +> _release-{module}/-v{major}.{minor}_ + +If the branch doesn't exist, the command creates it and pushes it to the remote. + +The command then creates a new tag in the form + +> _{module}/v{major}.{minor}.{patch}_ + +The command pushes this tag to the remote. This typically triggers +cloud activity to create release artifacts. + +#### 'gorepomod unrelease {module}' + +This undoes the work of 'release', by deleting the +most recent tag both locally and at the remote. + +You can then fix whatever, and re-release. + +This, however, must be done almost immediately. + +If there's a chance someone (or some cloud robot) already +imported the module at the given tag, then don't do this, +because it will confuse module caches. + +Do a new patch release instead. + +` +) diff --git a/hack/pinUnpin.sh b/hack/pinUnpin.sh deleted file mode 100755 index 4f4cb74c4..000000000 --- a/hack/pinUnpin.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2019 The Kubernetes Authors. -# SPDX-License-Identifier: Apache-2.0 -# -# In general, pin modules to a specific version of the -# kustomize API before a release of that module, and -# unpin the module after the module release so that -# development proceeds against the API's HEAD. -# -# E.g. for the kustomize CLI module, do this before -# releasing the CLI: -# -# ./hack/pinUnpin.sh pin kustomize v0.3.1 -# -# where v0.3.1 is the most recently released version of -# the API, and do the following afterwards: -# -# ./hack/pinUnpin.sh unPin kustomize - -set -o nounset -set -o pipefail - -if [ "$#" -lt 2 ]; then - echo "usage:" - echo " ./hack/pinUnpin.sh pin kustomize v0.3.1" - echo " or " - echo " ./hack/pinUnpin.sh unPin kustomize" - exit 1 -fi - -operation=$1 -if [[ ("$operation" != "pin") && ("$operation" != "unPin") ]]; then - echo "unknown operation $operation" - exit 1 -fi - -module=$2 -if [ ! -d "$module" ]; then - echo "directory $module doesn't exist" - exit 1 -fi - -version="unnecessary" -if [ "$operation" == "pin" ]; then - if [ "$#" -le 2 ]; then - echo "Specify version to pin, e.g. '$0 $module pin v0.2.0'" - exit 1 - fi - version=$3 -fi - -function unPin { - oldV=$(grep -m 1 sigs.k8s.io/kustomize/api go.mod | awk '{print $NF}') - go mod edit -replace=sigs.k8s.io/kustomize/api@${oldV}=../api - go mod tidy -} - -function pin { - oldV=$(grep -m 1 sigs.k8s.io/kustomize/api go.mod | awk '{print $NF}') - go mod edit -dropreplace=sigs.k8s.io/kustomize/api@${oldV} - go mod edit -require=sigs.k8s.io/kustomize/api@$version - go mod tidy -} - -pushd $module >& /dev/null -$operation -popd >& /dev/null diff --git a/hack/pinUnpinPluginApiDep.sh b/hack/pinUnpinPluginApiDep.sh deleted file mode 100755 index 1e07d202b..000000000 --- a/hack/pinUnpinPluginApiDep.sh +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env bash -# Copyright 2019 The Kubernetes Authors. -# SPDX-License-Identifier: Apache-2.0 -# -# To fix all plugin dependence to a particular -# released version of the kustomize API, -# run this from the repo root: -# -# ./hack/pinUnpinPluginApiDep.sh pin api v0.2.0 -# -# To replace fixed dependence with -# dependence on local filesystem (HEAD) -# run this from the repo root: -# -# ./hack/pinUnpinPluginApiDep.sh unPin api -# -# All plugins, even plugins not written in Go, -# have a unit test written in Go that depends -# on a particular version of the api for a test -# harness. The plugins written in Go, either -# as exec or Go-plugin style plugins, -# will likely depend directly on the kustomize -# API, and any number of other 3rd party packages. -# -# The Go plugins in the `builtin` directory -# are in practice converted to static libraries -# in the API, so should remain unpinned (dependent -# on HEAD). The other example plugins can be pinned -# or unpinned on a case by case basis, since -# they are just examples - but likely should -# remain unpinned too. Nothing in the outside -# world should depend on these plugin modules, -# so there's no reason for them to be pinned. - -set -o errexit -set -o nounset -#set -o pipefail - -function doUnPin { - oldV=$(grep -m 1 sigs.k8s.io/kustomize/${module} go.mod | awk '{print $NF}') - if [ ! -z $oldV ]; then - go mod edit -replace=sigs.k8s.io/kustomize/${module}@${oldV}=$1 - fi - go mod tidy -} - -function doPin { - oldV=$(grep -m 1 sigs.k8s.io/kustomize/${module} go.mod | awk '{print $NF}') - if [ ! -z $oldV ]; then - go mod edit -dropreplace=sigs.k8s.io/kustomize/${module}@${oldV} - go mod edit -require=sigs.k8s.io/kustomize/${module}@$1 - fi - go mod tidy -} - -function forEachGoMod { - for goMod in $(find $2 -name 'go.mod'); do - d=$(dirname "${goMod}") - echo "$1 $d" - (cd $d; $1 $3) - done -} - -function unPin { - echo "Unpinning $module" - forEachGoMod doUnPin ./plugin/builtin ../../../${module} - forEachGoMod doUnPin ./plugin/someteam.example.com/v1 ../../../../${module} -} - -function pin { - echo "Pinning $module to $version" - forEachGoMod doPin ./plugin/builtin ${version} - forEachGoMod doPin ./plugin/someteam.example.com/v1 ${version} -} - -if [ "$#" -eq 0 ]; then - echo "Pin or unpin plugins, e.g." - echo " " - echo " ./hack/pinUnpinPluginApiDep.sh pin api v0.2.0" - echo " " - echo " ./hack/pinUnpinPluginApiDep.sh unPin api" - echo " " - exit 1 -fi - -operation=$1 -if [[ ("$operation" != "pin") && ("$operation" != "unPin") ]]; then - echo "unknown operation $operation" - exit 1 -fi - -module=$2 -if [[ ("$module" != "api") && ("$module" != "kyaml") ]]; then - echo "unknown module $module" - exit 1 -fi - -version="unnecessary" -if [ "$operation" == "pin" ]; then - if [ "$#" -le 2 ]; then - echo "Specify version to pin, e.g. '$0 pin v0.2.0'" - exit 1 - fi - version=$3 -fi - -$operation