diff --git a/README.md b/README.md new file mode 100644 index 0000000..b9a10d9 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# git-semver + +Manage semantic version tags in a git repository. + +Follows the command-line interface and configuration syntax of https://github.com/markchalloner/git-semver but this +version is implemented using https://github.com/Masterminds/semver to parse and manipulate semantic versions. + +## Installation + +```bash +go install github.com/ray1729/git-semver +``` + +## Usage + +```bash +git-semver help +``` + +## Configuration + +Configuration is read from the first of these paths that is found: `$PWD/.git-semver`, `$XDG_CONFIG_HOME/git-semver`, `$HOME/.config/git-semver`, `$HOME/.git-semver/config`. + +Configuration format is one `key=value` pair per line, lines beginning with `#` are ignored. The following keys are +understood: + +* `VERSION_PREFIX` specify a prefix string for the created version tags +* `GIT_SIGN` boolean value indicating whether or not to sign tags + +## License + +MIT License + +Copyright 2023 Raymond Miller + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ddbf07f --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module github.com/ray1729/git-semver + +go 1.19 + +require ( + github.com/Masterminds/semver/v3 v3.2.1 + github.com/urfave/cli/v2 v2.25.3 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..412434f --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0= +github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/urfave/cli/v2 v2.25.3 h1:VJkt6wvEBOoSjPFQvOkv6iWIrsJyCrKGtCtxXWwmGeY= +github.com/urfave/cli/v2 v2.25.3/go.mod h1:GHupkWPMM0M/sj1a2b4wUrWBPzazNrIjouW6fmdJLxc= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673/go.mod h1:N3UwUGtsrSj3ccvlPHLoLsHnpR27oXr4ZE984MbSER8= diff --git a/main.go b/main.go new file mode 100644 index 0000000..79d7997 --- /dev/null +++ b/main.go @@ -0,0 +1,291 @@ +package main + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/urfave/cli/v2" +) + +func main() { + app := cli.App{ + Name: "git-semver", + Usage: "Manage semantic version tags", + Commands: []*cli.Command{ + &cmdGet, + &cmdMajor, + &cmdMinor, + &cmdPatch, + &cmdPreRelease, + &cmdBuild, + }, + } + app.Run(os.Args) +} + +var cmdPatch = cli.Command{ + Name: "patch", + Aliases: []string{"next"}, + Usage: "Generate a tag for the next patch version", + Flags: []cli.Flag{ + dryRunFlag(), + preReleaseFlag(false), + buildFlag(false), + }, + Before: readConfig, + Action: nextVersion("patch"), +} + +var cmdMinor = cli.Command{ + Name: "minor", + Usage: "Generate a tag for the next minor version", + Flags: []cli.Flag{ + dryRunFlag(), + preReleaseFlag(false), + buildFlag(false), + }, + Before: readConfig, + Action: nextVersion("minor"), +} + +var cmdMajor = cli.Command{ + Name: "major", + Usage: "Generate a tag for the next major version", + Flags: []cli.Flag{ + dryRunFlag(), + preReleaseFlag(false), + buildFlag(false), + }, + Before: readConfig, + Action: nextVersion("major"), +} + +var cmdPreRelease = cli.Command{ + Name: "pre-release", + Usage: "Generate a tag for the specified pre-release", + Flags: []cli.Flag{ + dryRunFlag(), + preReleaseFlag(true), + buildFlag(false), + }, + Before: readConfig, + Action: nextVersion(""), +} + +var cmdBuild = cli.Command{ + Name: "build", + Usage: "Generate a tag for the specified build", + Flags: []cli.Flag{ + dryRunFlag(), + buildFlag(true), + }, + Before: readConfig, + Action: nextVersion(""), +} + +var cmdGet = cli.Command{ + Name: "get", + Usage: "Gets the current version tag", + Before: readConfig, + Action: func(c *cli.Context) error { + conf := c.Context.Value(ctxKeyConfig).(*config) + v, err := getVersion(conf.versionPrefix) + if err != nil { + return cli.Exit(err.Error(), 2) + } + if v == nil { + return cli.Exit("no valid semver tags found", 2) + } + fmt.Println(conf.versionPrefix + v.String()) + return nil + }, +} + +func nextVersion(inc string) func(*cli.Context) error { + return func(c *cli.Context) error { + conf := c.Context.Value(ctxKeyConfig).(*config) + v, err := getVersion(conf.versionPrefix) + if err != nil { + return cli.Exit(err.Error(), 2) + } + var newVer semver.Version + if v == nil { + // If there is no semver tag, create version 0.1.0 + v = semver.New(0, 1, 0, "", "") + newVer = *v + } else { + // Otherwise, apply the specified increment to the existing version + switch inc { + case "patch": + newVer = v.IncPatch() + case "minor": + newVer = v.IncMinor() + case "major": + newVer = v.IncMajor() + } + } + if c.IsSet("pre-release") { + newVer, err = newVer.SetPrerelease(c.String("pre-release")) + if err != nil { + return cli.Exit(fmt.Sprintf("error setting pre-release %q: %v", c.String("pre-release"), err), 3) + } + } + if c.IsSet("build") { + newVer, err = newVer.SetMetadata(c.String("build")) + if err != nil { + return cli.Exit(fmt.Sprintf("error setting build %q: %v", c.String("build"), err), 3) + } + } + tagName := conf.versionPrefix + newVer.String() + fmt.Println(tagName) + if c.Bool("dryrun") { + return nil + } + if err = createTag(tagName, conf.sign); err != nil { + return cli.Exit(err.Error(), 3) + } + return nil + } +} + +func dryRunFlag() cli.Flag { + return &cli.BoolFlag{ + Name: "dryrun", + Aliases: []string{"d"}, + Usage: "Show version without creating a git tag", + } +} + +func preReleaseFlag(required bool) cli.Flag { + return &cli.StringFlag{ + Name: "pre-release", + Aliases: []string{"p"}, + Usage: "Sets the pre-release version component", + Required: required, + } +} + +func buildFlag(required bool) cli.Flag { + return &cli.StringFlag{ + Name: "build", + Aliases: []string{"b"}, + Usage: "Sets the build version component", + Required: required, + } +} + +type ctxKey int + +const ctxKeyConfig ctxKey = 0 + +type config struct { + versionPrefix string + sign bool +} + +func readConfig(c *cli.Context) error { + paths := []string{".git-semver"} + if p, ok := os.LookupEnv("XDG_CONFIG_HOME"); ok { + paths = append(paths, filepath.Join(p, "git-semver")) + } + if p, ok := os.LookupEnv("HOME"); ok { + paths = append(paths, filepath.Join(p, ".config", "git-semver"), filepath.Join(p, ".git-semver", "config")) + } + conf := config{} + for _, p := range paths { + f, err := os.Open(p) + if err != nil { + if os.IsNotExist(err) { + continue + } + return cli.Exit(err.Error(), 1) + } + defer f.Close() + conf, err = parseConfig(f) + if err != nil { + return cli.Exit(fmt.Sprintf("error parsing %s: %v", p, err), 1) + } + break + } + c.Context = context.WithValue(c.Context, ctxKeyConfig, &conf) + return nil +} + +func parseConfig(f io.Reader) (config, error) { + var conf config + s := bufio.NewScanner(f) + for s.Scan() { + t := strings.TrimSpace(s.Text()) + if len(t) == 0 { + continue + } + if strings.HasPrefix(t, "#") { + continue + } + k, v, ok := strings.Cut(t, "=") + if !ok { + return conf, fmt.Errorf("invalid configuration: %s", t) + } + k, v = strings.TrimSpace(k), strings.TrimSpace(v) + if len(v) >= 2 && strings.HasPrefix(v, "\"") && strings.HasSuffix(v, "\"") { + v = strings.Trim(v, "\"") + } + switch strings.ToUpper(k) { + case "VERSION_PREFIX": + conf.versionPrefix = v + case "GIT_SIGN": + b, err := strconv.ParseBool(v) + if err != nil { + return conf, fmt.Errorf("error parsing GIT_SIGN %q: %v", v, err) + } + conf.sign = b + default: + return conf, fmt.Errorf("unrecognized configuration variable: %s", k) + } + } + if err := s.Err(); err != nil { + return conf, err + } + return conf, nil +} + +func createTag(tagName string, sign bool) error { + signFlag := "-a" + if sign { + signFlag = "-s" + } + return exec.Command("git", "tag", signFlag, "-m", "Version "+tagName, tagName).Run() +} + +func getVersion(versionPrefix string) (*semver.Version, error) { + out, err := exec.Command("git", "tag").Output() + if err != nil { + return nil, err + } + var latest *semver.Version + s := bufio.NewScanner(bytes.NewReader(out)) + for s.Scan() { + tagName := s.Text() + if strings.HasPrefix(tagName, versionPrefix) { + v, err := semver.NewVersion(tagName) + if err != nil { + log.Printf("error parsing tag %q: %v", tagName, err) + continue + } + if latest == nil || v.GreaterThan(latest) { + latest = v + } + } + } + return latest, s.Err() +}