├── .dockerignore ├── .github └── workflows │ ├── release.yaml │ └── tests.yaml ├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── cli ├── common_opts │ └── opts.go ├── compare │ └── command.go ├── latest │ └── command.go ├── log │ └── command.go ├── main.go └── next │ └── command.go ├── conventional_commits ├── change_type.go ├── commit_message.go ├── commit_message_test.go ├── markdown.go ├── markdown_test.go ├── sort_by_change_type.go └── sort_by_change_type_test.go ├── git_utils ├── get_versions.go ├── hash_list_contains_tag.go ├── ref_is_on_current_branch.go ├── ref_to_commit_hash.go └── sort_commits_desc.go ├── go.mod ├── go.sum ├── goreleaser.dockerfile ├── integration_tests ├── .gitignore ├── pom.xml └── src │ └── test │ └── java │ └── de │ └── psanetra │ └── gitsemver │ ├── LatestCmdIncludingPreReleasesTests.java │ ├── LatestCmdTests.java │ ├── LogCmdTests.java │ ├── NextCmdTests.java │ └── containers │ ├── BaseImage.java │ └── GitSemverContainer.java ├── latest ├── latest.go └── latest_test.go ├── logger └── logger.go ├── next └── next.go ├── regex_utils ├── submatch_map.go └── submatch_map_test.go ├── semver ├── compare.go ├── compare_test.go ├── find_greatest_preceding.go ├── find_greatest_preceding_test.go ├── increment.go ├── increment_test.go ├── tostring.go ├── tostring_test.go ├── version.go └── version_test.go └── version_log └── version_log.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea 2 | /integration_tests 3 | dist/ 4 | 5 | .dockerignore 6 | .gitignore 7 | .goreleaser.yaml 8 | Dockerfile 9 | LICENSE 10 | README.md 11 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_run: 4 | workflows: ["Tests"] 5 | branches: ["master", "main"] 6 | types: 7 | - completed 8 | permissions: 9 | contents: write 10 | jobs: 11 | version: 12 | name: Gather version information 13 | runs-on: ubuntu-latest 14 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 15 | outputs: 16 | latest_version: ${{ steps.latest_version.outputs.version }} 17 | next_version: ${{ steps.next_version.outputs.version }} 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | fetch-depth: 0 22 | - name: Latest version 23 | id: latest_version 24 | uses: PSanetra/git-semver-actions/latest@master 25 | - name: Next version 26 | id: next_version 27 | uses: PSanetra/git-semver-actions/next@master 28 | release: 29 | name: Release 30 | needs: version 31 | if: ${{ needs.version.outputs.latest_version != needs.version.outputs.next_version }} 32 | runs-on: ubuntu-latest 33 | steps: 34 | - uses: actions/checkout@v4 35 | with: 36 | fetch-depth: 0 37 | - name: Generate Changelog 38 | id: generate_changelog 39 | uses: PSanetra/git-semver-actions/markdown-log@master 40 | - name: Create Release 41 | id: create_release 42 | uses: actions/create-release@v1 43 | env: 44 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | with: 46 | tag_name: v${{ needs.version.outputs.next_version }} 47 | release_name: Release ${{ needs.version.outputs.next_version }} 48 | body: | 49 | ${{ steps.generate_changelog.outputs.changelog }} 50 | draft: false # Tag must be published before gitreleaser is executed 51 | prerelease: false 52 | build_and_publish_artifacts: 53 | name: Build and publish artifacts 54 | needs: [version, release] 55 | if: ${{ needs.version.outputs.latest_version != needs.version.outputs.next_version }} 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@v4 59 | with: 60 | fetch-depth: 0 61 | - uses: docker/setup-qemu-action@v3 62 | - uses: docker/setup-buildx-action@v3 63 | - uses: docker/login-action@v3 64 | name: Login to Docker Hub 65 | with: 66 | username: ${{ vars.DOCKER_USERNAME }} 67 | password: ${{ secrets.DOCKER_PASSWORD }} 68 | - name: Run GoReleaser 69 | uses: goreleaser/goreleaser-action@v6 70 | with: 71 | distribution: goreleaser 72 | version: '~> v2' 73 | args: release --clean 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | on: 3 | push: 4 | branches: ["master", "main"] 5 | pull_request: 6 | branches: ["master", "main"] 7 | jobs: 8 | unit_tests: 9 | name: Unit Tests 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - uses: actions/setup-go@v5 14 | with: 15 | go-version: '^1.24.1' 16 | - run: go test ./... 17 | integration_tests: 18 | name: Integration Tests 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v4 22 | - name: Setup Java 23 | uses: actions/setup-java@v4 24 | with: 25 | java-version: '21' 26 | distribution: 'temurin' 27 | cache: 'maven' 28 | - name: Run Tests 29 | run: cd integration_tests && mvn verify 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea 2 | git-semver 3 | .testworkdir 4 | 5 | dist/ 6 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: git-semver 3 | builds: 4 | - main: ./cli/main.go 5 | goos: 6 | - linux 7 | - windows 8 | - darwin 9 | goarch: 10 | - amd64 11 | - arm64 12 | env: 13 | - CGO_ENABLED=0 14 | ignore: 15 | - goos: windows 16 | goarch: arm64 17 | snapshot: 18 | name_template: "{{ incpatch .Version }}-next" 19 | dockers: 20 | - image_templates: 21 | - "psanetra/git-semver:latest" 22 | - "psanetra/git-semver:{{ .Major }}.{{ .Minor }}.{{ .Patch }}" 23 | dockerfile: goreleaser.dockerfile 24 | build_flag_templates: 25 | - "--pull" 26 | - "--label=org.opencontainers.image.created={{ .Date }}" 27 | - "--label=org.opencontainers.image.title={{ .ProjectName }}" 28 | - "--label=org.opencontainers.image.revision={{ .FullCommit }}" 29 | - "--label=org.opencontainers.image.version={{ .Version }}" 30 | checksum: 31 | name_template: 'checksums.txt' 32 | changelog: 33 | disable: true 34 | release: 35 | github: 36 | owner: PSanetra 37 | name: git-semver 38 | mode: keep-existing 39 | # modelines, feel free to remove those if you don't want/use them: 40 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 41 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj 42 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.24-alpine as build 2 | 3 | WORKDIR /src 4 | 5 | COPY . /src 6 | 7 | RUN go test -v -vet=off ./... 8 | 9 | RUN GOOS=linux GARCH=amd64 CGO_ENABLED=0 go build -o git-semver -ldflags="-s -w" cli/main.go 10 | 11 | FROM alpine:3.21 12 | 13 | RUN apk --no-cache add git git-lfs openssh-client 14 | 15 | COPY --from=build /src/git-semver /usr/local/bin 16 | 17 | ENTRYPOINT ["git", "semver"] 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Philip Sanetra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-semver 2 | [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-yellow.svg)](https://conventionalcommits.org) [![Docker Image Pulls](https://img.shields.io/docker/pulls/psanetra/git-semver)](https://hub.docker.com/r/psanetra/git-semver) 3 | 4 | git-semver is a command line tool to calculate [semantic versions](https://semver.org/spec/v2.0.0.html) based on the git history and tags of a repository. 5 | 6 | git-semver assumes that the commit messages in the git history are wellformed according to the [conventional commit](https://www.conventionalcommits.org/en/v1.0.0-beta.4/) specification. 7 | 8 | ## Pull Docker Image 9 | 10 | ```bash 11 | $ docker pull psanetra/git-semver 12 | ``` 13 | 14 | ## Commands 15 | 16 | ### latest 17 | 18 | The `latest` command prints the latest semantic version in the current repository by comparing all git tags. Tag names may have a "v" prefix, but this commands prints the version always without that prefix. 19 | 20 | #### Examples 21 | 22 | Print latest semantic version (ignoring pre-releases). 23 | ```bash 24 | $ git-semver latest 25 | 1.2.3 26 | ``` 27 | 28 | Print latest semantic version including pre-releases. 29 | ```bash 30 | $ git-semver latest --include-pre-releases 31 | 1.2.3-beta 32 | ``` 33 | 34 | ### next 35 | 36 | The `next` command can be used to calculate the next semantic version based on the history of the current branch. It fails if the git tag of the latest semantic version is not reachable on the current branch or if the tagged commit is not reachable because the repository is shallow. 37 | 38 | #### Examples 39 | 40 | Calculate next semantic version. (Will print the latest version if there were no relevant changes.) 41 | ```bash 42 | $ git-semver next 43 | 1.2.3 44 | ``` 45 | 46 | Calculate next unstable semantic version. (Only if there is no stable version tag yet.) 47 | ```bash 48 | $ git-semver next --stable=false 49 | 0.1.2 50 | ``` 51 | 52 | Calculate next alpha pre-release version with an appended counter. 53 | ```bash 54 | $ git-semver next --pre-release-tag=alpha --pre-release-counter 55 | 1.2.3-alpha.1 56 | ``` 57 | 58 | ### log 59 | 60 | The `log` command prints the commit log of all commits, which were contained in a specified version or all commits since the latest version if no version is specified. 61 | 62 | #### Examples 63 | 64 | Print the commits, added in version 1.0.0. 65 | ```bash 66 | $ git-semver log v1.0.0 67 | commit 478bb9dfdca43216cda6cedcab27faf5c8fd68c0 68 | Author: John Doe 69 | Date: Wed Jun 03 20:17:23 2020 +0000 70 | 71 | fix(some_component): Add fix 72 | 73 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc bibendum vulputate sapien vel mattis. 74 | 75 | Vivamus faucibus leo id libero suscipit, varius tincidunt neque interdum. Mauris rutrum at velit vitae semper. 76 | 77 | Fixes: http://issues.example.com/123 78 | BREAKING CHANGE: This commit is breaking some API. 79 | 80 | commit f716712a4a26491533ba3b6d95e29f9beed85f47 81 | Author: John Doe 82 | Date: Wed Jun 03 20:17:23 2020 +0000 83 | 84 | Some non-conventional-commit 85 | 86 | commit d44f505f677d52ca23fb9a69de1f5bb6e6085a74 87 | Author: John Doe 88 | Date: Wed Jun 03 20:17:22 2020 +0000 89 | 90 | feat: Add feature 91 | ``` 92 | 93 | Print only conventional commits, formatted as JSON. 94 | ```bash 95 | $ git-semver log --conventional-commits v1.0.0 96 | [ 97 | { 98 | "type": "fix", 99 | "scope": "some_component", 100 | "breaking_change": true, 101 | "description": "Add fix", 102 | "body": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc bibendum vulputate sapien vel mattis.\n\nVivamus faucibus leo id libero suscipit, varius tincidunt neque interdum. Mauris rutrum at velit vitae semper.", 103 | "footers": { 104 | "BREAKING CHANGE": [ 105 | "This commit is breaking some API." 106 | ], 107 | "Fixes": [ 108 | "http://issues.example.com/123" 109 | ] 110 | } 111 | }, 112 | { 113 | "type": "feat", 114 | "description": "Add feature" 115 | } 116 | ] 117 | ``` 118 | 119 | Print changelog formatted as markdown. 120 | ```bash 121 | $ git-semver log --markdown v1.0.0 122 | ### BREAKING CHANGES 123 | 124 | * **some_component** This commit is breaking some API. 125 | 126 | ### Features 127 | 128 | * Add feature 129 | 130 | ### Bug Fixes 131 | 132 | * **some_component** Add fix 133 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc bibendum vulputate sapien vel mattis. 134 | 135 | Vivamus faucibus leo id libero suscipit, varius tincidunt neque interdum. Mauris rutrum at velit vitae semper. 136 | ``` 137 | 138 | ### compare 139 | 140 | The `compare` command is an utility command to compare two semantic versions. 141 | 142 | - Prints `=` if both provided versions are equals. 143 | - Prints `<` if the first provided version is lower than the second version. 144 | - Prints `>` if the first provided version is greater than the second version. 145 | 146 | #### Examples 147 | 148 | Compare the versions `1.2.3` and `1.2.3-beta` 149 | ```bash 150 | $ git-semver compare 1.2.3 1.2.3-beta 151 | > 152 | ``` 153 | 154 | Compare the versions `1.2.3-alpha` and `1.2.3-beta` 155 | ```bash 156 | $ git-semver compare 1.2.3-alpha 1.2.3-beta 157 | < 158 | ``` 159 | 160 | Compare the versions `1.2.3` and `1.2.3+build-2018-12-31` 161 | ```bash 162 | $ git-semver compare 1.2.3 1.2.3+build-2018-12-31 163 | = 164 | ``` 165 | 166 | ## Example GitLab Job Template 167 | 168 | ```yaml 169 | stages: 170 | - tag 171 | 172 | tag: 173 | image: 174 | name: psanetra/git-semver:latest 175 | entrypoint: 176 | - "/usr/bin/env" 177 | - "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" 178 | stage: tag 179 | variables: 180 | GIT_DEPTH: 0 181 | GIT_FETCH_EXTRA_FLAGS: "--prune --prune-tags --tags" 182 | before_script: 183 | - apk add --upgrade --no-cache curl 184 | script: | 185 | set -ex 186 | LATEST_VERSION="$(git semver latest)" 187 | NEXT_VERSION="$(git semver next)" 188 | if [ "${LATEST_VERSION}" != "${NEXT_VERSION}" ]; then 189 | NEXT_TAG="v${NEXT_VERSION}" 190 | git tag "${NEXT_TAG}" 191 | CHANGELOG="$(git semver log --markdown ${NEXT_TAG})" 192 | curl -X POST \ 193 | --header "JOB-TOKEN: ${CI_JOB_TOKEN}" \ 194 | --form "tag_name=v${NEXT_VERSION}" \ 195 | --form "ref=${CI_COMMIT_SHA}" \ 196 | --form "description=${CHANGELOG}" \ 197 | "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/releases" 198 | fi 199 | only: 200 | - main 201 | - master 202 | except: 203 | - tags 204 | - schedules 205 | ``` 206 | 207 | ## License 208 | 209 | MIT 210 | -------------------------------------------------------------------------------- /cli/common_opts/opts.go: -------------------------------------------------------------------------------- 1 | package common_opts 2 | 3 | var Workdir = "" 4 | -------------------------------------------------------------------------------- /cli/compare/command.go: -------------------------------------------------------------------------------- 1 | package compare 2 | 3 | import ( 4 | "github.com/psanetra/git-semver/logger" 5 | "github.com/psanetra/git-semver/semver" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var Command = cobra.Command{ 10 | Use: "compare ", 11 | Short: "compares two semantic versions", 12 | Long: `This command is an utility command to compare two semantic versions. 13 | 14 | - Prints "=" if both versions are equal 15 | - Prints "<" if the first version is less than the second version 16 | - Prints ">" if the first version is greater than the second version 17 | `, 18 | Run: func(cmd *cobra.Command, args []string) { 19 | 20 | if len(args) != 2 { 21 | logger.Logger.Fatalln("Did not expect", len(args), "arguments") 22 | } 23 | 24 | v1, err := semver.ParseVersion(args[0]) 25 | 26 | if err != nil { 27 | logger.Logger.Fatalln("Could not parse argument 1:", err) 28 | } 29 | 30 | v2, err := semver.ParseVersion(args[1]) 31 | 32 | if err != nil { 33 | logger.Logger.Fatalln("Could not parse argument 2:", err) 34 | } 35 | 36 | result := semver.CompareVersions(v1, v2) 37 | 38 | switch result { 39 | case 0: 40 | print("=") 41 | case 1: 42 | print(">") 43 | case -1: 44 | print("<") 45 | } 46 | }, 47 | } 48 | -------------------------------------------------------------------------------- /cli/latest/command.go: -------------------------------------------------------------------------------- 1 | package latest 2 | 3 | import ( 4 | "fmt" 5 | "github.com/psanetra/git-semver/cli/common_opts" 6 | "github.com/psanetra/git-semver/latest" 7 | "github.com/psanetra/git-semver/logger" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var includePreReleases bool 12 | var majorVersionFilter int 13 | 14 | var Command = cobra.Command{ 15 | Use: "latest", 16 | Short: "prints latest semantic version", 17 | Long: `This command prints the latest semantic version in the current repository by comparing all git tags. Tag names may have a "v" prefix, but this commands prints the version always without that prefix.`, 18 | Run: func(cmd *cobra.Command, args []string) { 19 | 20 | latestVersion, err := latest.Latest(latest.LatestOptions{ 21 | Workdir: common_opts.Workdir, 22 | IncludePreReleases: includePreReleases, 23 | MajorVersionFilter: majorVersionFilter, 24 | }) 25 | 26 | if err != nil { 27 | logger.Logger.Fatalln(err) 28 | } 29 | 30 | fmt.Print(latestVersion.ToString()) 31 | 32 | }, 33 | } 34 | 35 | func init() { 36 | Command.Flags().BoolVar(&includePreReleases, "include-pre-releases", false, "Also consider pre-releases as the latest version") 37 | Command.Flags().IntVar(&majorVersionFilter, "major-version", -1, "Search for the latest version with a specific major version") 38 | } 39 | -------------------------------------------------------------------------------- /cli/log/command.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/psanetra/git-semver/cli/common_opts" 7 | "github.com/psanetra/git-semver/conventional_commits" 8 | "github.com/psanetra/git-semver/logger" 9 | "github.com/psanetra/git-semver/semver" 10 | "github.com/psanetra/git-semver/version_log" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var excludePreReleases bool 15 | var outputAsConventionalCommits bool 16 | var markdownChangelog bool 17 | 18 | var Command = cobra.Command{ 19 | Use: "log []", 20 | Short: "prints the git log for the specified version", 21 | Long: "This command prints all commits, which were contained in a specified version or all commits since the latest version if no version is specified.", 22 | Args: cobra.MaximumNArgs(1), 23 | Run: func(cmd *cobra.Command, args []string) { 24 | 25 | var version *semver.Version 26 | var err error 27 | 28 | if len(args) > 0 { 29 | version, err = semver.ParseVersion(args[0]) 30 | if err != nil { 31 | logger.Logger.Fatalln("Could not parse version:", err) 32 | } 33 | } 34 | 35 | commits, err := version_log.VersionLog(version_log.VersionLogOptions{ 36 | Workdir: common_opts.Workdir, 37 | Version: version, 38 | ExcludePreReleaseCommits: excludePreReleases, 39 | }) 40 | 41 | if err != nil { 42 | logger.Logger.Fatalln(err) 43 | } 44 | 45 | if !outputAsConventionalCommits && !markdownChangelog { 46 | for _, commit := range commits { 47 | fmt.Print(commit) 48 | } 49 | } else if outputAsConventionalCommits && markdownChangelog { 50 | logger.Logger.Fatalln("Flags --conventional-commits and --markdown-changelog are mutual exclusive") 51 | } else { 52 | 53 | var conventionalCommits []*conventional_commits.ConventionalCommitMessage 54 | 55 | for _, commit := range commits { 56 | conventionalCommit, err := conventional_commits.ParseCommitMessage(commit.Message) 57 | 58 | if err != nil { 59 | logger.Logger.Debugln(err) 60 | continue 61 | } 62 | 63 | conventionalCommits = append(conventionalCommits, conventionalCommit) 64 | } 65 | 66 | if markdownChangelog { 67 | fmt.Print(conventional_commits.ToMarkdown(conventionalCommits)) 68 | } else if outputAsConventionalCommits { 69 | jsonResult, err := json.MarshalIndent(conventionalCommits, "", " ") 70 | 71 | if err != nil { 72 | logger.Logger.Fatalln("Could not marshal json:", err) 73 | } 74 | 75 | fmt.Println(string(jsonResult)) 76 | } 77 | } 78 | }, 79 | } 80 | 81 | func init() { 82 | Command.Flags().BoolVar(&excludePreReleases, "exclude-pre-releases", false, "Specifies if the log should exclude pre-release commits from the log.") 83 | Command.Flags().BoolVar(&outputAsConventionalCommits, "conventional-commits", false, "Print only conventional commits, formatted as JSON. Non-parsable commits are omitted.") 84 | Command.Flags().BoolVar(&markdownChangelog, "markdown", false, "Print changelog, formatted as markdown.") 85 | } 86 | -------------------------------------------------------------------------------- /cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/psanetra/git-semver/cli/common_opts" 5 | "github.com/psanetra/git-semver/cli/compare" 6 | "github.com/psanetra/git-semver/cli/latest" 7 | "github.com/psanetra/git-semver/cli/log" 8 | "github.com/psanetra/git-semver/cli/next" 9 | "github.com/psanetra/git-semver/logger" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var rootCmd = &cobra.Command{ 14 | Use: "git-semver", 15 | Short: "git-semver is a cli tool to apply semver conventions to git based projects.", 16 | // Long: `git-semver is a cli tool to apply semver conventions to git based projects.`, 17 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 18 | processLogLevelFlag(cmd) 19 | }, 20 | } 21 | 22 | func Execute() { 23 | if err := rootCmd.Execute(); err != nil { 24 | logger.Logger.Fatalln(err) 25 | } 26 | } 27 | 28 | func main() { 29 | 30 | rootCmd.AddCommand(&latest.Command) 31 | rootCmd.AddCommand(&next.Command) 32 | rootCmd.AddCommand(&log.Command) 33 | rootCmd.AddCommand(&compare.Command) 34 | err := rootCmd.Execute() 35 | 36 | if err != nil { 37 | logger.Logger.Fatalln(err) 38 | } 39 | 40 | } 41 | 42 | func init() { 43 | rootCmd.PersistentFlags().StringVarP(&common_opts.Workdir, "workdir", "w", ".", "Working directory to use") 44 | rootCmd.PersistentFlags().String("log-level", logger.DEFAULT_LOG_LEVEL.String(), "panic | fatal | error | warn | info | debug | trace") 45 | } 46 | 47 | func processLogLevelFlag(cmd *cobra.Command) { 48 | logLevel := cmd.Flag("log-level").Value.String() 49 | logger.SetLevel(logLevel) 50 | } 51 | -------------------------------------------------------------------------------- /cli/next/command.go: -------------------------------------------------------------------------------- 1 | package next 2 | 3 | import ( 4 | "fmt" 5 | "github.com/psanetra/git-semver/cli/common_opts" 6 | "github.com/psanetra/git-semver/logger" 7 | "github.com/psanetra/git-semver/next" 8 | "github.com/psanetra/git-semver/semver" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | var stable bool 13 | var majorVersionFilter int 14 | var preReleaseTag string 15 | var appendPreReleaseCounter bool 16 | 17 | var Command = cobra.Command{ 18 | Use: "next", 19 | Short: "prints version which should be used for the next release", 20 | Long: `This command can be used to calculate the next semantic version based on the history of the current branch. It fails if the git tag of the latest semantic version is not reachable on the current branch or if the tagged commit is not reachable because the repository is shallow.`, 21 | Run: func(cmd *cobra.Command, args []string) { 22 | 23 | nextVersion, err := next.Next(next.NextOptions{ 24 | Workdir: common_opts.Workdir, 25 | Stable: stable, 26 | MajorVersionFilter: majorVersionFilter, 27 | PreReleaseOptions: semver.PreReleaseOptions{ 28 | Label: preReleaseTag, 29 | AppendCounter: appendPreReleaseCounter, 30 | }, 31 | }) 32 | 33 | if err != nil { 34 | logger.Logger.Fatalln(err) 35 | } 36 | 37 | fmt.Print(nextVersion.ToString()) 38 | 39 | }, 40 | } 41 | 42 | func init() { 43 | Command.Flags().BoolVar(&stable, "stable", true, "Specifies if this project is considered stable. Setting this to false will cause the major version to be 0. This command will fail if there is already a major version greater than 0.") 44 | Command.Flags().IntVar(&majorVersionFilter, "major-version", -1, "Only consider tags with this specific major version.") 45 | Command.Flags().StringVar(&preReleaseTag, "pre-release-tag", "", "Specifies a pre-release tag which should be appended to the next version.") 46 | Command.Flags().BoolVar(&appendPreReleaseCounter, "pre-release-counter", false, "Specifies if there should be a counter appended to the pre-release tag. It will increase automatically depending on previous pre-releases for the same version.") 47 | } 48 | -------------------------------------------------------------------------------- /conventional_commits/change_type.go: -------------------------------------------------------------------------------- 1 | package conventional_commits 2 | 3 | type ChangeType string 4 | 5 | const ( 6 | FEATURE ChangeType = "feat" 7 | FIX ChangeType = "fix" 8 | CHORE ChangeType = "chore" 9 | PERF ChangeType = "perf" 10 | STYLE ChangeType = "style" 11 | DOCS ChangeType = "docs" 12 | REFACTOR ChangeType = "refactor" 13 | CI ChangeType = "ci" 14 | ) 15 | 16 | var ChangeTypePriorities = map[ChangeType]int{ 17 | FEATURE: 10, 18 | FIX: 9, 19 | PERF: 8, 20 | DOCS: 7, 21 | CHORE: 6, 22 | CI: 5, 23 | STYLE: 4, 24 | REFACTOR: 3, 25 | } 26 | -------------------------------------------------------------------------------- /conventional_commits/commit_message.go: -------------------------------------------------------------------------------- 1 | package conventional_commits 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/psanetra/git-semver/regex_utils" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | var messageRegex = regexp.MustCompile(`(?m)^(?P[a-zA-Z]+)(\((?P[^)]*)\))?(?P!)?:\s*(?P[^\n]([^\n]|\n[^\n])*)(?P\n{2,}(.|\n)*)?$`) 11 | 12 | const footerTokenRegexStr = `(?P[a-zA-Z_\-]+|BREAKING[ _\-]CHANGE(S)?)( \#|: )` 13 | 14 | var footerTokenRegex = regexp.MustCompile(`(?m)^` + footerTokenRegexStr) 15 | var footersBeginningRegex = regexp.MustCompile(`(?m)\n\n` + footerTokenRegexStr) 16 | 17 | var breakingChangeRegex = regexp.MustCompile("^BREAKING[ _\\-]CHANGE(S)?$") 18 | 19 | type ConventionalCommitMessage struct { 20 | ChangeType ChangeType `json:"type"` 21 | Scope string `json:"scope,omitempty"` 22 | ContainsBreakingChange bool `json:"breaking_change,omitempty"` 23 | Description string `json:"description"` 24 | Body string `json:"body,omitempty"` 25 | Footers map[string][]string `json:"footers,omitempty"` 26 | } 27 | 28 | // inspired by https://www.conventionalcommits.org 29 | func ParseCommitMessage(message string) (*ConventionalCommitMessage, error) { 30 | 31 | match := regex_utils.SubmatchMap(messageRegex, message) 32 | 33 | if match == nil { 34 | return nil, errors.New("Could not parse commit message \"" + message + "\"") 35 | } 36 | 37 | breakingChangeIndicator := match["BCIndicator"] 38 | 39 | bodyAndFooters := match["BodyAndFooters"] 40 | 41 | body := bodyAndFooters 42 | footers := make(map[string][]string) 43 | 44 | var footersBeginningIndex = footersBeginningRegex.FindStringIndex(bodyAndFooters) 45 | 46 | if len(footersBeginningIndex) > 0 { 47 | body = bodyAndFooters[:footersBeginningIndex[0]] 48 | footersStr := bodyAndFooters[footersBeginningIndex[0]:] 49 | 50 | submatches := footerTokenRegex.FindAllStringSubmatchIndex(footersStr, 100) 51 | 52 | for i, submatchIndices := range submatches { 53 | token := footersStr[submatchIndices[2*1]:submatchIndices[2*1+1]] 54 | tokenValueList := footers[token] 55 | 56 | nextTokenIndex := len(footersStr) 57 | 58 | if i < len(submatches) - 1 { 59 | nextTokenIndex = submatches[i+1][0] 60 | } 61 | 62 | value := trimWhitespace(footersStr[submatchIndices[1]:nextTokenIndex]) 63 | 64 | footers[token] = append(tokenValueList, value) 65 | } 66 | } else { 67 | body = bodyAndFooters 68 | } 69 | 70 | body = trimWhitespace(body) 71 | 72 | commitMessage := &ConventionalCommitMessage{ 73 | ChangeType: ChangeType(strings.ToLower(match["ChangeType"])), 74 | Scope: match["Scope"], 75 | ContainsBreakingChange: breakingChangeIndicator == "!", 76 | Description: match["Description"], 77 | Body: body, 78 | Footers: footers, 79 | } 80 | 81 | commitMessage.ContainsBreakingChange = commitMessage.ContainsBreakingChange || commitMessage.footerHasBreakingChange() 82 | 83 | return commitMessage, nil 84 | } 85 | 86 | func trimWhitespace(str string) string { 87 | return strings.Trim(str, " \t\r\n") 88 | } 89 | 90 | func (c *ConventionalCommitMessage) Compare(other *ConventionalCommitMessage) int { 91 | if c.ContainsBreakingChange { 92 | if other.ContainsBreakingChange { 93 | return 0 94 | } else { 95 | return 1 96 | } 97 | } else if other.ContainsBreakingChange { 98 | return -1 99 | } 100 | 101 | if c.ChangeType == FEATURE { 102 | if other.ChangeType == FEATURE { 103 | return 0 104 | } else { 105 | return 1 106 | } 107 | } else if other.ChangeType == FEATURE { 108 | return -1 109 | } 110 | 111 | if c.ChangeType == FIX { 112 | if other.ChangeType == FIX { 113 | return 0 114 | } else { 115 | return 1 116 | } 117 | } else if other.ChangeType == FIX { 118 | return -1 119 | } 120 | 121 | return 0 122 | } 123 | 124 | func (c *ConventionalCommitMessage) footerHasBreakingChange() bool { 125 | for key, _ := range c.Footers { 126 | if breakingChangeRegex.MatchString(key) { 127 | return true 128 | } 129 | } 130 | 131 | return false 132 | } 133 | 134 | func (c *ConventionalCommitMessage) breakingChangeDescriptions() []string { 135 | var ret []string 136 | 137 | for key, value := range c.Footers { 138 | if breakingChangeRegex.MatchString(key) { 139 | ret = append(ret, value...) 140 | } 141 | } 142 | 143 | return ret 144 | } 145 | -------------------------------------------------------------------------------- /conventional_commits/commit_message_test.go: -------------------------------------------------------------------------------- 1 | package conventional_commits 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestParseCommitMessage_ParsesSimpleCommitMessage(t *testing.T) { 9 | 10 | commitMessage, err := ParseCommitMessage(`feat: my description`) 11 | 12 | assert.Nil(t, err) 13 | 14 | assert.Equal( 15 | t, 16 | &ConventionalCommitMessage{ 17 | ChangeType: "feat", 18 | Description: "my description", 19 | ContainsBreakingChange: false, 20 | Footers: map[string][]string{}, 21 | }, 22 | commitMessage, 23 | ) 24 | 25 | } 26 | 27 | func TestParseCommitMessage_ParsesSimpleCommitMessageWithCaseInsensitiveType(t *testing.T) { 28 | 29 | commitMessage, err := ParseCommitMessage(`fEaT: my description`) 30 | 31 | assert.Nil(t, err) 32 | 33 | assert.Equal( 34 | t, 35 | &ConventionalCommitMessage{ 36 | ChangeType: "feat", 37 | Description: "my description", 38 | ContainsBreakingChange: false, 39 | Footers: map[string][]string{}, 40 | }, 41 | commitMessage, 42 | ) 43 | 44 | } 45 | 46 | func TestParseCommitMessage_ParsesCommitMessageWithBreakingChangeIndicator(t *testing.T) { 47 | 48 | commitMessage, err := ParseCommitMessage(`feat!: my description`) 49 | 50 | assert.Nil(t, err) 51 | 52 | assert.Equal( 53 | t, 54 | &ConventionalCommitMessage{ 55 | ChangeType: "feat", 56 | Description: "my description", 57 | ContainsBreakingChange: true, 58 | Footers: map[string][]string{}, 59 | }, 60 | commitMessage, 61 | ) 62 | 63 | } 64 | 65 | func TestParseCommitMessage_ParsesCommitMessageWithBreakingChangeIndicatorAfterScope(t *testing.T) { 66 | 67 | commitMessage, err := ParseCommitMessage(`feat(scope)!: my description`) 68 | 69 | assert.Nil(t, err) 70 | 71 | assert.Equal( 72 | t, 73 | &ConventionalCommitMessage{ 74 | ChangeType: "feat", 75 | Scope: "scope", 76 | Description: "my description", 77 | ContainsBreakingChange: true, 78 | Footers: map[string][]string{}, 79 | }, 80 | commitMessage, 81 | ) 82 | 83 | } 84 | 85 | func TestParseCommitMessage_ParsesSimpleCommitMessageWithLineBreak(t *testing.T) { 86 | 87 | commitMessage, err := ParseCommitMessage("feat: my description\nwith line break") 88 | 89 | assert.Nil(t, err) 90 | 91 | assert.Equal( 92 | t, 93 | &ConventionalCommitMessage{ 94 | ChangeType: "feat", 95 | Description: "my description\nwith line break", 96 | Footers: map[string][]string{}, 97 | }, 98 | commitMessage, 99 | ) 100 | 101 | } 102 | 103 | func TestParseCommitMessage_ParsesCommitMessageWithBody(t *testing.T) { 104 | 105 | commitMessage, err := ParseCommitMessage( 106 | ` 107 | feat: my description 108 | with line break 109 | 110 | and this is a body 111 | 112 | This is still the body 113 | `, 114 | ) 115 | 116 | assert.Nil(t, err) 117 | 118 | assert.Equal( 119 | t, 120 | &ConventionalCommitMessage{ 121 | ChangeType: "feat", 122 | Description: "my description\nwith line break", 123 | Body: "and this is a body\n\nThis is still the body", 124 | Footers: map[string][]string{}, 125 | }, 126 | commitMessage, 127 | ) 128 | 129 | } 130 | 131 | func TestParseCommitMessage_ParsesCommitMessageWithFooter(t *testing.T) { 132 | 133 | commitMessage, err := ParseCommitMessage( 134 | ` 135 | feat: my description 136 | with line break 137 | 138 | and this is a body 139 | with line break 140 | 141 | this is still the body 142 | 143 | Fix #123 144 | Fix: http://example.com/123 145 | Custom-Token: Custom-Token-Value 146 | `, 147 | ) 148 | 149 | assert.Nil(t, err) 150 | 151 | assert.Equal( 152 | t, 153 | &ConventionalCommitMessage{ 154 | ChangeType: "feat", 155 | Description: "my description\nwith line break", 156 | Body: "and this is a body\nwith line break\n\nthis is still the body", 157 | Footers: map[string][]string{ 158 | "Fix": {"123", "http://example.com/123"}, 159 | "Custom-Token": {"Custom-Token-Value"}, 160 | }, 161 | }, 162 | commitMessage, 163 | ) 164 | 165 | } 166 | 167 | func TestParseCommitMessage_SetsContainsBreakingChangeToFalseIfBodyContainsBreakingChangeInline(t *testing.T) { 168 | 169 | result, err := ParseCommitMessage(`feat: Some description 170 | 171 | Body without BREAKING CHANGE description // BREAKING CHANGE: not a breaking change description`) 172 | 173 | assert.Nil(t, err) 174 | assert.False(t, result.ContainsBreakingChange) 175 | 176 | } 177 | 178 | func TestParseCommitMessage_SetsContainsBreakingChangeToTrueIfBreakingChangeIndicatorExists(t *testing.T) { 179 | 180 | result, err := ParseCommitMessage(`feat!: Some description`) 181 | 182 | assert.Nil(t, err) 183 | assert.True(t, result.ContainsBreakingChange) 184 | } 185 | 186 | func TestParseCommitMessage_SetsContainsBreakingChangeToFalseIfBreakingChangeDescriptionInBody(t *testing.T) { 187 | 188 | result, err := ParseCommitMessage(`feat: Some description 189 | 190 | Body with breaking change description in second line. Should not match as footer as a blank line is missing: 191 | BREAKING CHANGE: commit breaks stuff`) 192 | 193 | assert.Nil(t, err) 194 | assert.NotEmpty(t, result.Body) 195 | assert.False(t, result.ContainsBreakingChange, "Should not indicate a breaking change.") 196 | 197 | } 198 | 199 | func TestParseCommitMessage_SetsContainsBreakingChangeToTrueIfBreakingChangeTokenExistsInFooter(t *testing.T) { 200 | 201 | testBodiesAndFooters := []string{ 202 | "BREAKING CHANGE: commit breaks stuff", 203 | "BREAKING CHANGES: commit breaks stuff", 204 | "BREAKING_CHANGE: commit breaks stuff", 205 | "BREAKING_CHANGES: commit breaks stuff", 206 | "BREAKING-CHANGE: commit breaks stuff", 207 | "BREAKING-CHANGES: commit breaks stuff", 208 | `This is the body: 209 | 210 | BREAKING CHANGE: commit breaks stuff`, 211 | } 212 | 213 | for _, bodyAndFooter := range testBodiesAndFooters { 214 | result, err := ParseCommitMessage("feat: Some description\n\n" + bodyAndFooter) 215 | 216 | assert.Nil(t, err) 217 | assert.True(t, result.ContainsBreakingChange, "Could not parse breaking change indication from: "+bodyAndFooter) 218 | } 219 | 220 | } 221 | 222 | func TestParseCommitMessage_ParsesMultilineFooter(t *testing.T) { 223 | 224 | commitMessage := `feat: Some feature 225 | 226 | This is the body. 227 | 228 | BREAKING CHANGE: commit breaks stuff 229 | 230 | This is still part of the breaking change description. 231 | 232 | This too 233 | Some-Tag: This is some other footer` 234 | 235 | result, err := ParseCommitMessage(commitMessage) 236 | 237 | assert.Nil(t, err) 238 | assert.Equal(t, "This is the body.", result.Body) 239 | assert.Equal(t, []string{"commit breaks stuff\n\nThis is still part of the breaking change description.\n\nThis too"}, result.Footers["BREAKING CHANGE"]) 240 | assert.Equal(t, []string{"This is some other footer"}, result.Footers["Some-Tag"]) 241 | assert.True(t, result.ContainsBreakingChange, "Could not parse breaking change indication") 242 | } 243 | 244 | func TestCommitMessage_Compare_should_return_0_if_left_is_breaking_change_and_right_too(t *testing.T) { 245 | 246 | assert.Equal( 247 | t, 248 | 0, 249 | (&ConventionalCommitMessage{ 250 | ContainsBreakingChange: true, 251 | }).Compare( 252 | &ConventionalCommitMessage{ 253 | ContainsBreakingChange: true, 254 | }, 255 | ), 256 | ) 257 | 258 | } 259 | 260 | func TestCommitMessage_Compare_should_return_1_if_left_is_breaking_change_and_right_is_not(t *testing.T) { 261 | 262 | assert.Equal( 263 | t, 264 | 1, 265 | (&ConventionalCommitMessage{ 266 | ContainsBreakingChange: true, 267 | }).Compare( 268 | &ConventionalCommitMessage{ 269 | ChangeType: FEATURE, 270 | }, 271 | ), 272 | ) 273 | 274 | assert.Equal( 275 | t, 276 | 1, 277 | (&ConventionalCommitMessage{ 278 | ContainsBreakingChange: true, 279 | }).Compare( 280 | &ConventionalCommitMessage{ 281 | ChangeType: FIX, 282 | }, 283 | ), 284 | ) 285 | 286 | assert.Equal( 287 | t, 288 | 1, 289 | (&ConventionalCommitMessage{ 290 | ContainsBreakingChange: true, 291 | }).Compare( 292 | &ConventionalCommitMessage{ 293 | ChangeType: CHORE, 294 | }, 295 | ), 296 | ) 297 | 298 | } 299 | 300 | func TestCommitMessage_Compare_should_return_1_if_left_is_not_breaking_change_but_right_is(t *testing.T) { 301 | 302 | assert.Equal( 303 | t, 304 | -1, 305 | (&ConventionalCommitMessage{ 306 | ChangeType: FEATURE, 307 | }).Compare( 308 | &ConventionalCommitMessage{ 309 | ContainsBreakingChange: true, 310 | }, 311 | ), 312 | ) 313 | 314 | assert.Equal( 315 | t, 316 | -1, 317 | (&ConventionalCommitMessage{ 318 | ChangeType: FIX, 319 | }).Compare( 320 | &ConventionalCommitMessage{ 321 | ContainsBreakingChange: true, 322 | }, 323 | ), 324 | ) 325 | 326 | assert.Equal( 327 | t, 328 | -1, 329 | (&ConventionalCommitMessage{ 330 | ChangeType: CHORE, 331 | }).Compare( 332 | &ConventionalCommitMessage{ 333 | ContainsBreakingChange: true, 334 | }, 335 | ), 336 | ) 337 | 338 | } 339 | 340 | func TestCommitMessage_Compare_should_return_0_if_left_is_feature_and_right_too(t *testing.T) { 341 | 342 | assert.Equal( 343 | t, 344 | 0, 345 | (&ConventionalCommitMessage{ 346 | ChangeType: FEATURE, 347 | }).Compare( 348 | &ConventionalCommitMessage{ 349 | ChangeType: FEATURE, 350 | }, 351 | ), 352 | ) 353 | 354 | } 355 | 356 | func TestCommitMessage_Compare_should_return_1_if_left_is_feature_and_right_is_not(t *testing.T) { 357 | 358 | assert.Equal( 359 | t, 360 | 1, 361 | (&ConventionalCommitMessage{ 362 | ChangeType: FEATURE, 363 | }).Compare( 364 | &ConventionalCommitMessage{ 365 | ChangeType: FIX, 366 | }, 367 | ), 368 | ) 369 | 370 | assert.Equal( 371 | t, 372 | 1, 373 | (&ConventionalCommitMessage{ 374 | ChangeType: FEATURE, 375 | }).Compare( 376 | &ConventionalCommitMessage{ 377 | ChangeType: CHORE, 378 | }, 379 | ), 380 | ) 381 | 382 | } 383 | 384 | func TestCommitMessage_Compare_should_return_1_if_left_is_not_feature_but_right_is(t *testing.T) { 385 | 386 | assert.Equal( 387 | t, 388 | -1, 389 | (&ConventionalCommitMessage{ 390 | ChangeType: FIX, 391 | }).Compare( 392 | &ConventionalCommitMessage{ 393 | ChangeType: FEATURE, 394 | }, 395 | ), 396 | ) 397 | 398 | assert.Equal( 399 | t, 400 | -1, 401 | (&ConventionalCommitMessage{ 402 | ChangeType: CHORE, 403 | }).Compare( 404 | &ConventionalCommitMessage{ 405 | ChangeType: FEATURE, 406 | }, 407 | ), 408 | ) 409 | 410 | } 411 | 412 | func TestCommitMessage_Compare_should_return_0_if_left_is_fix_and_right_too(t *testing.T) { 413 | 414 | assert.Equal( 415 | t, 416 | 0, 417 | (&ConventionalCommitMessage{ 418 | ChangeType: FIX, 419 | }).Compare( 420 | &ConventionalCommitMessage{ 421 | ChangeType: FIX, 422 | }, 423 | ), 424 | ) 425 | 426 | } 427 | 428 | func TestCommitMessage_Compare_should_return_1_if_left_is_fix_and_right_is_chore(t *testing.T) { 429 | 430 | assert.Equal( 431 | t, 432 | 1, 433 | (&ConventionalCommitMessage{ 434 | ChangeType: FIX, 435 | }).Compare( 436 | &ConventionalCommitMessage{ 437 | ChangeType: CHORE, 438 | }, 439 | ), 440 | ) 441 | 442 | } 443 | 444 | func TestCommitMessage_Compare_should_return_1_if_left_is_chore_and_right_is_fix(t *testing.T) { 445 | 446 | assert.Equal( 447 | t, 448 | -1, 449 | (&ConventionalCommitMessage{ 450 | ChangeType: CHORE, 451 | }).Compare( 452 | &ConventionalCommitMessage{ 453 | ChangeType: FIX, 454 | }, 455 | ), 456 | ) 457 | 458 | } 459 | 460 | func TestCommitMessage_Compare_should_return_0_if_left_is_chore_and_right_is_doc(t *testing.T) { 461 | 462 | assert.Equal( 463 | t, 464 | 0, 465 | (&ConventionalCommitMessage{ 466 | ChangeType: CHORE, 467 | }).Compare( 468 | &ConventionalCommitMessage{ 469 | ChangeType: DOCS, 470 | }, 471 | ), 472 | ) 473 | 474 | } 475 | -------------------------------------------------------------------------------- /conventional_commits/markdown.go: -------------------------------------------------------------------------------- 1 | package conventional_commits 2 | 3 | import ( 4 | "sort" 5 | "strings" 6 | ) 7 | 8 | func ToMarkdown(messages []*ConventionalCommitMessage) string { 9 | 10 | var markDownParts []string 11 | 12 | commitsContainingBreakingChanges := ByChangeTypeDesc(filterBreakingChanges(messages)) 13 | sort.Stable(commitsContainingBreakingChanges) 14 | 15 | if len(commitsContainingBreakingChanges) > 0 { 16 | markDownParts = append(markDownParts, markdownBreakingChanges(commitsContainingBreakingChanges)) 17 | } 18 | 19 | features := filterByNonBreakingChangeType(FEATURE, messages) 20 | 21 | if len(features) > 0 { 22 | featuresString := "### Features\n\n" 23 | featuresString += markdownSimpleChanges(features) 24 | markDownParts = append(markDownParts, featuresString) 25 | } 26 | 27 | fixes := filterByNonBreakingChangeType(FIX, messages) 28 | 29 | if len(fixes) > 0 { 30 | fixesString := "### Bug Fixes\n\n" 31 | fixesString += markdownSimpleChanges(fixes) 32 | markDownParts = append(markDownParts, fixesString) 33 | } 34 | 35 | return strings.Join(markDownParts, "\n") 36 | } 37 | 38 | func markdownBreakingChanges(commitsContainingBreakingChanges ByChangeTypeDesc) string { 39 | ret := "### BREAKING CHANGES\n\n" 40 | 41 | for _, change := range commitsContainingBreakingChanges { 42 | 43 | breakingChangeDescriptions := change.breakingChangeDescriptions() 44 | 45 | if len(breakingChangeDescriptions) == 0 { 46 | ret += "* " 47 | 48 | if change.Scope != "" { 49 | ret += "**" + change.Scope + "** " 50 | } 51 | 52 | ret += change.Description + "\n" 53 | 54 | if change.Body != "" { 55 | ret += "\n" + change.Body 56 | } 57 | } else { 58 | for _, description := range breakingChangeDescriptions { 59 | 60 | ret += "* " 61 | 62 | if change.Scope != "" { 63 | ret += "**" + change.Scope + "** " 64 | } 65 | 66 | ret += description + "\n" 67 | 68 | } 69 | } 70 | 71 | } 72 | 73 | return ret 74 | } 75 | 76 | func markdownSimpleChanges(changes []*ConventionalCommitMessage) string { 77 | ret := "" 78 | 79 | for _, change := range changes { 80 | // skip breaking changes without separate description, because they are listed in another section 81 | if change.ContainsBreakingChange && len(change.breakingChangeDescriptions()) == 0 { 82 | continue 83 | } 84 | 85 | ret += "* " 86 | 87 | if change.Scope != "" { 88 | ret += "**" + change.Scope + "** " 89 | } 90 | 91 | ret += change.Description + "\n" 92 | 93 | if change.Body != "" { 94 | ret += change.Body 95 | ret += "\n" 96 | } 97 | 98 | } 99 | 100 | return ret 101 | } 102 | 103 | func filterBreakingChanges(messages []*ConventionalCommitMessage) []*ConventionalCommitMessage { 104 | var ret []*ConventionalCommitMessage 105 | 106 | for _, c := range messages { 107 | if c.ContainsBreakingChange { 108 | ret = append(ret, c) 109 | } 110 | } 111 | 112 | return ret 113 | } 114 | 115 | func filterByNonBreakingChangeType(changeType ChangeType, messages []*ConventionalCommitMessage) []*ConventionalCommitMessage { 116 | var ret []*ConventionalCommitMessage 117 | 118 | for _, c := range messages { 119 | if c.ChangeType == changeType { 120 | ret = append(ret, c) 121 | } 122 | } 123 | 124 | return ret 125 | } 126 | -------------------------------------------------------------------------------- /conventional_commits/markdown_test.go: -------------------------------------------------------------------------------- 1 | package conventional_commits 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func Test_markdown(t *testing.T) { 9 | 10 | result := ToMarkdown([]*ConventionalCommitMessage{ 11 | { 12 | ChangeType: FEATURE, 13 | Scope: "some_component", 14 | ContainsBreakingChange: true, 15 | Description: "Add some feature", 16 | Body: "Lorem ipsum...", 17 | Footers: map[string][]string{ 18 | "BREAKING CHANGE": { 19 | `There is a breaking change in some API. 20 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque facilisis neque nec fermentum placerat. 21 | Integer placerat leo sed leo ullamcorper, nec fermentum tortor tincidunt. 22 | 23 | Pellentesque blandit justo quis mauris gravida, quis mollis nunc maximus. Nulla a massa vitae urna mollis tincidunt. 24 | Praesent condimentum pellentesque convallis. 25 | 26 | Mauris vitae risus vel lorem luctus rutrum. 27 | Phasellus neque nibh, posuere eu nibh nec, feugiat gravida sem. Aliquam posuere sit amet diam ut ultrices. 28 | Nunc tincidunt odio quis ipsum aliquam, ut posuere enim sollicitudin. Pellentesque eu erat id justo semper laoreet.`, 29 | }, 30 | }, 31 | }, 32 | { 33 | ChangeType: FIX, 34 | Scope: "some_component", 35 | ContainsBreakingChange: true, 36 | Description: "Fix some issue", 37 | Body: "Lorem ipsum...", 38 | Footers: map[string][]string{ 39 | "BREAKING CHANGE": { 40 | `There is another breaking change in some API. 41 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque facilisis neque nec fermentum placerat. 42 | Integer placerat leo sed leo ullamcorper, nec fermentum tortor tincidunt.`, 43 | }, 44 | }, 45 | }, 46 | { 47 | ChangeType: FIX, 48 | Scope: "some_component", 49 | Description: "Fix another issue", 50 | }, 51 | { 52 | ChangeType: FIX, 53 | Description: "Fix without scope", 54 | }, 55 | { 56 | ChangeType: FIX, 57 | Scope: "some_component", 58 | ContainsBreakingChange: true, 59 | Description: "Fix with breaking change, but without separate BREAKING CHANGE description.", 60 | Body: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque facilisis neque nec fermentum placerat.", 61 | }, 62 | { 63 | ChangeType: CHORE, 64 | Description: "Edit README.md", 65 | }, 66 | { 67 | ChangeType: PERF, 68 | Description: "Improve performance", 69 | }, 70 | { 71 | ChangeType: STYLE, 72 | Description: "go fmt", 73 | }, 74 | { 75 | ChangeType: REFACTOR, 76 | Description: "Refactor something", 77 | }, 78 | { 79 | ChangeType: CI, 80 | Description: "Fix some pipeline", 81 | }, 82 | { 83 | ChangeType: DOCS, 84 | Description: "Edit some docs", 85 | }, 86 | }) 87 | 88 | assert.Equal( 89 | t, 90 | `### BREAKING CHANGES 91 | 92 | * **some_component** There is a breaking change in some API. 93 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque facilisis neque nec fermentum placerat. 94 | Integer placerat leo sed leo ullamcorper, nec fermentum tortor tincidunt. 95 | 96 | Pellentesque blandit justo quis mauris gravida, quis mollis nunc maximus. Nulla a massa vitae urna mollis tincidunt. 97 | Praesent condimentum pellentesque convallis. 98 | 99 | Mauris vitae risus vel lorem luctus rutrum. 100 | Phasellus neque nibh, posuere eu nibh nec, feugiat gravida sem. Aliquam posuere sit amet diam ut ultrices. 101 | Nunc tincidunt odio quis ipsum aliquam, ut posuere enim sollicitudin. Pellentesque eu erat id justo semper laoreet. 102 | * **some_component** There is another breaking change in some API. 103 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque facilisis neque nec fermentum placerat. 104 | Integer placerat leo sed leo ullamcorper, nec fermentum tortor tincidunt. 105 | * **some_component** Fix with breaking change, but without separate BREAKING CHANGE description. 106 | 107 | Lorem ipsum dolor sit amet, consectetur adipiscing elit. Quisque facilisis neque nec fermentum placerat. 108 | ### Features 109 | 110 | * **some_component** Add some feature 111 | Lorem ipsum... 112 | 113 | ### Bug Fixes 114 | 115 | * **some_component** Fix some issue 116 | Lorem ipsum... 117 | * **some_component** Fix another issue 118 | * Fix without scope 119 | `, 120 | result, 121 | ) 122 | 123 | } 124 | -------------------------------------------------------------------------------- /conventional_commits/sort_by_change_type.go: -------------------------------------------------------------------------------- 1 | package conventional_commits 2 | 3 | type ByChangeTypeDesc []*ConventionalCommitMessage 4 | 5 | func (a ByChangeTypeDesc) Len() int { return len(a) } 6 | func (a ByChangeTypeDesc) Less(i, j int) bool { 7 | iPriority := ChangeTypePriorities[a[i].ChangeType] 8 | jPriority := ChangeTypePriorities[a[j].ChangeType] 9 | 10 | return (jPriority - iPriority) < 0 11 | } 12 | func (a ByChangeTypeDesc) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 13 | -------------------------------------------------------------------------------- /conventional_commits/sort_by_change_type_test.go: -------------------------------------------------------------------------------- 1 | package conventional_commits 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "sort" 6 | "testing" 7 | ) 8 | 9 | func TestSort_ByChangeTypeDesc(t *testing.T) { 10 | 11 | messages := ByChangeTypeDesc{ 12 | {ChangeType: FIX}, 13 | {ChangeType: PERF}, 14 | {ChangeType: FEATURE}, 15 | } 16 | 17 | sort.Sort(messages) 18 | 19 | assert.Equal(t, FEATURE, messages[0].ChangeType) 20 | assert.Equal(t, FIX, messages[1].ChangeType) 21 | assert.Equal(t, PERF, messages[2].ChangeType) 22 | 23 | } 24 | -------------------------------------------------------------------------------- /git_utils/get_versions.go: -------------------------------------------------------------------------------- 1 | package git_utils 2 | 3 | import ( 4 | "github.com/go-git/go-git/v5" 5 | "github.com/psanetra/git-semver/semver" 6 | "io" 7 | ) 8 | 9 | func GetVersions(repo *git.Repository) ([]*semver.Version, error) { 10 | 11 | tagIter, err := repo.Tags() 12 | 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | defer tagIter.Close() 18 | 19 | var ret []*semver.Version 20 | 21 | for tag, err := tagIter.Next(); err != io.EOF; tag, err = tagIter.Next() { 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | tagName := tag.Name().Short() 27 | 28 | version, err := semver.ParseVersion(tagName) 29 | 30 | if err != nil { 31 | continue 32 | } 33 | 34 | ret = append(ret, version) 35 | } 36 | 37 | return ret, nil 38 | } 39 | -------------------------------------------------------------------------------- /git_utils/hash_list_contains_tag.go: -------------------------------------------------------------------------------- 1 | package git_utils 2 | 3 | import ( 4 | "github.com/go-git/go-git/v5/plumbing" 5 | ) 6 | 7 | func HashListContains(hashList []plumbing.Hash, hash plumbing.Hash) bool { 8 | 9 | for _, h := range hashList { 10 | if h == hash { 11 | return true 12 | } 13 | } 14 | 15 | return false 16 | } 17 | -------------------------------------------------------------------------------- /git_utils/ref_is_on_current_branch.go: -------------------------------------------------------------------------------- 1 | package git_utils 2 | 3 | import ( 4 | "github.com/go-git/go-git/v5" 5 | "github.com/go-git/go-git/v5/plumbing" 6 | "github.com/go-git/go-git/v5/plumbing/revlist" 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | func AssertRefIsReachable(repo *git.Repository, precedingRef *plumbing.Reference, headRef *plumbing.Reference, message string) error { 11 | toRefList, err := revlist.Objects( 12 | repo.Storer, 13 | []plumbing.Hash{ 14 | headRef.Hash(), 15 | }, 16 | []plumbing.Hash{}, 17 | ) 18 | 19 | if err != nil { 20 | return err 21 | } 22 | 23 | refCommitHash := RefToCommitHash(repo.Storer, precedingRef) 24 | 25 | if !HashListContains(toRefList, refCommitHash) { 26 | return errors.Errorf(message+" (tag: %s; commit: %s)", precedingRef.Name().String(), refCommitHash.String()) 27 | } 28 | 29 | return nil 30 | } 31 | -------------------------------------------------------------------------------- /git_utils/ref_to_commit_hash.go: -------------------------------------------------------------------------------- 1 | package git_utils 2 | 3 | import ( 4 | "github.com/go-git/go-git/v5/plumbing" 5 | "github.com/go-git/go-git/v5/plumbing/object" 6 | "github.com/go-git/go-git/v5/plumbing/storer" 7 | "github.com/psanetra/git-semver/logger" 8 | ) 9 | 10 | func RefToCommitHash(storer storer.EncodedObjectStorer, tagRef *plumbing.Reference) plumbing.Hash { 11 | o, err := object.GetObject(storer, tagRef.Hash()) 12 | 13 | if err != nil { 14 | logger.Logger.Fatalln("Error on resolving tag hash (", tagRef.Hash().String(), "): ", err) 15 | } 16 | 17 | switch o := o.(type) { 18 | case *object.Commit: 19 | return o.Hash 20 | case *object.Tag: 21 | return o.Target 22 | default: 23 | logger.Logger.Fatalln("Error on resolving tag hash (", tagRef.Hash().String(), "): ", err) 24 | return plumbing.Hash{} 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /git_utils/sort_commits_desc.go: -------------------------------------------------------------------------------- 1 | package git_utils 2 | 3 | import "github.com/go-git/go-git/v5/plumbing/object" 4 | 5 | type ByHistoryDesc []*object.Commit 6 | 7 | func (a ByHistoryDesc) Len() int { return len(a) } 8 | func (a ByHistoryDesc) Less(i, j int) bool { 9 | // Swap i and j as we want to sort descanding 10 | isAncestor, err := a[j].IsAncestor(a[i]) 11 | 12 | if err != nil { 13 | return a[j].Committer.When.Before(a[i].Committer.When) 14 | } 15 | 16 | return isAncestor 17 | } 18 | func (a ByHistoryDesc) Swap(i, j int) { a[i], a[j] = a[j], a[i] } 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/psanetra/git-semver 2 | 3 | require ( 4 | github.com/go-git/go-git/v5 v5.16.0 5 | github.com/pkg/errors v0.9.1 6 | github.com/sirupsen/logrus v1.9.3 7 | github.com/spf13/cobra v1.9.1 8 | github.com/stretchr/testify v1.10.0 9 | ) 10 | 11 | require ( 12 | dario.cat/mergo v1.0.1 // indirect 13 | github.com/Microsoft/go-winio v0.6.2 // indirect 14 | github.com/ProtonMail/go-crypto v1.2.0 // indirect 15 | github.com/cloudflare/circl v1.6.1 // indirect 16 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/emirpasic/gods v1.18.1 // indirect 19 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 20 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 21 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 22 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 23 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 24 | github.com/kevinburke/ssh_config v1.2.0 // indirect 25 | github.com/pjbgf/sha1cd v0.3.2 // indirect 26 | github.com/pmezard/go-difflib v1.0.0 // indirect 27 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect 28 | github.com/skeema/knownhosts v1.3.1 // indirect 29 | github.com/spf13/pflag v1.0.6 // indirect 30 | github.com/xanzy/ssh-agent v0.3.3 // indirect 31 | golang.org/x/crypto v0.37.0 // indirect 32 | golang.org/x/net v0.39.0 // indirect 33 | golang.org/x/sys v0.32.0 // indirect 34 | gopkg.in/warnings.v0 v0.1.2 // indirect 35 | gopkg.in/yaml.v3 v3.0.1 // indirect 36 | ) 37 | 38 | go 1.24 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 5 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 | github.com/ProtonMail/go-crypto v1.2.0 h1:+PhXXn4SPGd+qk76TlEePBfOfivE0zkWFenhGhFLzWs= 7 | github.com/ProtonMail/go-crypto v1.2.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 8 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 10 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 12 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 13 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 14 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 15 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 16 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 17 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 19 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 20 | github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 21 | github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 22 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 23 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 24 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 25 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 26 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 27 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 28 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 29 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 30 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 31 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 32 | github.com/go-git/go-git/v5 v5.16.0 h1:k3kuOEpkc0DeY7xlL6NaaNg39xdgQbtH5mwCafHO9AQ= 33 | github.com/go-git/go-git/v5 v5.16.0/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 34 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 35 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 36 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 37 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 38 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 39 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 40 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 41 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 42 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 43 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 44 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 45 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 46 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 47 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 48 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 49 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 50 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 51 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 52 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 53 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 54 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 55 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 56 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 57 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 58 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 59 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 60 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 61 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 62 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 63 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 64 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 65 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 66 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 67 | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 68 | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 69 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 70 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 71 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 72 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 73 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 74 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 75 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 76 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 77 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 78 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 79 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 80 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 81 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 82 | golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= 83 | golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 84 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 85 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 86 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 87 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 88 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 89 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 90 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 91 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 92 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 93 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 95 | golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 96 | golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 97 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 98 | golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 99 | golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= 100 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 101 | golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= 102 | golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 103 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 104 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 105 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 106 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 107 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 108 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 109 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 110 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 111 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 112 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 113 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 114 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 115 | -------------------------------------------------------------------------------- /goreleaser.dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.21 2 | 3 | RUN apk --no-cache add git git-lfs openssh-client 4 | 5 | COPY ./git-semver /usr/local/bin/git-semver 6 | 7 | ENTRYPOINT ["git", "semver"] 8 | -------------------------------------------------------------------------------- /integration_tests/.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | target 3 | *.iml 4 | -------------------------------------------------------------------------------- /integration_tests/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | de.psanetra.git.semver.tests 8 | git-semver-tests 9 | 1.0-SNAPSHOT 10 | 11 | 12 | UTF-8 13 | 21 14 | ${java.version} 15 | ${java.version} 16 | ${java.version} 17 | 5.12.1 18 | 19 | 20 | 21 | 22 | org.projectlombok 23 | lombok 24 | 1.18.34 25 | provided 26 | 27 | 28 | org.junit.jupiter 29 | junit-jupiter 30 | ${junit.jupiter.version} 31 | test 32 | 33 | 34 | org.testcontainers 35 | junit-jupiter 36 | 1.20.2 37 | test 38 | 39 | 40 | org.assertj 41 | assertj-core 42 | 3.27.3 43 | test 44 | 45 | 46 | org.slf4j 47 | slf4j-simple 48 | 2.0.17 49 | 50 | 51 | com.google.guava 52 | guava 53 | 33.4.7-jre 54 | 55 | 56 | 57 | 58 | 59 | 60 | maven-compiler-plugin 61 | 3.14.0 62 | 63 | 64 | 65 | org.projectlombok 66 | lombok 67 | 1.18.34 68 | 69 | 70 | 71 | 72 | 73 | maven-surefire-plugin 74 | 3.5.3 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /integration_tests/src/test/java/de/psanetra/gitsemver/LatestCmdIncludingPreReleasesTests.java: -------------------------------------------------------------------------------- 1 | package de.psanetra.gitsemver; 2 | 3 | import de.psanetra.gitsemver.containers.GitSemverContainer; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import static org.assertj.core.api.Assertions.assertThat; 7 | 8 | public class LatestCmdIncludingPreReleasesTests { 9 | 10 | @Test 11 | public void shouldReturnPrerelease() { 12 | 13 | try (var container = new GitSemverContainer()) { 14 | container.start(); 15 | 16 | container.addNewFileToGit("file.txt"); 17 | container.gitCommit("feat: Add feature"); 18 | container.gitTag("v1.2.3"); 19 | container.addNewFileToGit("file2.txt"); 20 | container.gitCommit("feat: Add feature 2"); 21 | container.gitTag("v1.3.0-beta"); 22 | 23 | assertThat(container.exec("git", "semver", "latest", "--include-pre-releases")).isEqualTo("1.3.0-beta"); 24 | } 25 | 26 | } 27 | 28 | @Test 29 | public void shouldReturnRegularReleaseIfLaterThanAnyPreRelease() { 30 | 31 | try (var container = new GitSemverContainer()) { 32 | container.start(); 33 | 34 | container.addNewFileToGit("file.txt"); 35 | container.gitCommit("feat: Add feature"); 36 | container.gitTag("v1.2.3"); 37 | container.addNewFileToGit("file2.txt"); 38 | container.gitCommit("feat: Add feature 2"); 39 | container.gitTag("v1.3.0-beta"); 40 | container.addNewFileToGit("file3.txt"); 41 | container.gitCommit("feat: Add feature 3"); 42 | container.gitTag("v1.3.0"); 43 | 44 | assertThat(container.exec("git", "semver", "latest", "--include-pre-releases")).isEqualTo("1.3.0"); 45 | } 46 | 47 | } 48 | 49 | @Test 50 | public void shouldReturnVersionsNotReachableFromHEAD() { 51 | 52 | try (var container = new GitSemverContainer()) { 53 | container.start(); 54 | 55 | container.gitCheckoutNewBranch("master"); 56 | container.addNewFileToGit("file.txt"); 57 | container.gitCommit("feat: Master commit"); 58 | container.gitCheckoutNewBranch("v1"); 59 | container.addNewFileToGit("file2.txt"); 60 | container.gitCommit("feat: v1 commit"); 61 | container.gitTag("v1.2.3-beta"); 62 | container.gitCheckout("master"); 63 | 64 | assertThat(container.exec("git", "semver", "latest", "--include-pre-releases")).isEqualTo("1.2.3-beta"); 65 | } 66 | 67 | } 68 | 69 | @Test 70 | public void shouldReturnEmptyVersionOnRepoWithoutTags() { 71 | 72 | try (var container = new GitSemverContainer()) { 73 | container.start(); 74 | 75 | assertThat(container.exec("git", "semver", "latest", "--include-pre-releases")).isEqualTo("0.0.0"); 76 | } 77 | 78 | } 79 | 80 | @Test 81 | public void shouldReturnLatestForSpecificMajorVersion() { 82 | 83 | try (var container = new GitSemverContainer()) { 84 | container.start(); 85 | 86 | container.addNewFileToGit("file.txt"); 87 | container.gitCommit("feat: Add feature"); 88 | container.gitTag("v1.2.3-beta"); 89 | container.addNewFileToGit("file2.txt"); 90 | container.gitCommit("feat: Add feature 2"); 91 | container.gitTag("v2.3.4-beta"); 92 | 93 | assertThat(container.exec("git", "semver", "latest", "--include-pre-releases", "--major-version", "1")).isEqualTo("1.2.3-beta"); 94 | } 95 | 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /integration_tests/src/test/java/de/psanetra/gitsemver/LatestCmdTests.java: -------------------------------------------------------------------------------- 1 | package de.psanetra.gitsemver; 2 | 3 | import de.psanetra.gitsemver.containers.GitSemverContainer; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import static org.assertj.core.api.Assertions.assertThat; 7 | 8 | public class LatestCmdTests { 9 | @Test 10 | public void shouldIgnorePrereleasesByDefault() { 11 | 12 | try (var container = new GitSemverContainer()) { 13 | container.start(); 14 | 15 | container.addNewFileToGit("file.txt"); 16 | container.gitCommit("feat: Add feature"); 17 | container.gitTag("v1.2.3"); 18 | container.addNewFileToGit("file2.txt"); 19 | container.gitCommit("feat: Add feature 2"); 20 | container.gitTag("v1.3.0-beta"); 21 | 22 | assertThat(container.exec("git", "semver", "latest")).isEqualTo("1.2.3"); 23 | } 24 | 25 | } 26 | 27 | @Test 28 | public void shouldReturnVersionsNotReachableFromHEAD() { 29 | 30 | try (var container = new GitSemverContainer()) { 31 | container.start(); 32 | 33 | container.gitCheckoutNewBranch("master"); 34 | container.addNewFileToGit("file.txt"); 35 | container.gitCommit("feat: Master commit"); 36 | container.gitCheckoutNewBranch("v1"); 37 | container.addNewFileToGit("file2.txt"); 38 | container.gitCommit("feat: v1 commit"); 39 | container.gitTag("v1.2.3"); 40 | container.gitCheckout("master"); 41 | 42 | assertThat(container.exec("git", "semver", "latest")).isEqualTo("1.2.3"); 43 | } 44 | 45 | } 46 | 47 | @Test 48 | public void shouldReturnEmptyVersionOnRepoWithoutTags() { 49 | 50 | try (var container = new GitSemverContainer()) { 51 | container.start(); 52 | 53 | assertThat(container.exec("git", "semver", "latest")).isEqualTo("0.0.0"); 54 | } 55 | 56 | } 57 | 58 | @Test 59 | public void shouldReturnLatestForSpecificMajorVersion() { 60 | 61 | try (var container = new GitSemverContainer()) { 62 | container.start(); 63 | 64 | container.addNewFileToGit("file.txt"); 65 | container.gitCommit("feat: Add feature"); 66 | container.gitTag("v1.2.3"); 67 | container.addNewFileToGit("file2.txt"); 68 | container.gitCommit("feat: Add feature 2"); 69 | container.gitTag("v2.3.4"); 70 | 71 | assertThat(container.exec("git", "semver", "latest", "--major-version", "1")).isEqualTo("1.2.3"); 72 | } 73 | 74 | } 75 | 76 | } 77 | -------------------------------------------------------------------------------- /integration_tests/src/test/java/de/psanetra/gitsemver/LogCmdTests.java: -------------------------------------------------------------------------------- 1 | package de.psanetra.gitsemver; 2 | 3 | import de.psanetra.gitsemver.containers.GitSemverContainer; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.io.IOException; 7 | 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | import static org.assertj.core.api.Assertions.assertThatCode; 10 | 11 | public class LogCmdTests { 12 | 13 | @Test 14 | public void shouldPrintLogFormattedAsUsual() { 15 | 16 | try (var container = new GitSemverContainer()) { 17 | container.start(); 18 | 19 | container.addNewFileToGit("file.txt"); 20 | container.gitCommit("feat: Add feature"); 21 | 22 | assertThat(container.exec("git", "semver", "log")) 23 | .contains("Author: testuser "); 24 | } 25 | 26 | } 27 | 28 | @Test 29 | public void shouldPrintLogWithNoTags() { 30 | 31 | try (var container = new GitSemverContainer()) { 32 | container.start(); 33 | 34 | container.addNewFileToGit("file.txt"); 35 | container.gitCommit("feat: Add feature"); 36 | container.addNewFileToGit("file2.txt"); 37 | container.gitCommit("fix: Add fix"); 38 | 39 | assertThat(container.exec("git", "semver", "log")) 40 | .contains("feat: Add feature") 41 | .contains("fix: Add fix"); 42 | } 43 | 44 | } 45 | 46 | @Test 47 | public void shouldPrintNoLogIfLatestCommitIsTagged() { 48 | 49 | try (var container = new GitSemverContainer()) { 50 | container.start(); 51 | 52 | container.addNewFileToGit("file.txt"); 53 | container.gitCommit("feat: Add feature"); 54 | container.addNewFileToGit("file2.txt"); 55 | container.gitCommit("fix: Add fix"); 56 | container.gitTag("v1.0.0"); 57 | 58 | assertThat(container.exec("git", "semver", "log")) 59 | .doesNotContain("feat: Add feature") 60 | .doesNotContain("fix: Add fix"); 61 | } 62 | 63 | } 64 | 65 | @Test 66 | public void shouldPrintLogForSpecificVersion() { 67 | 68 | try (var container = new GitSemverContainer()) { 69 | container.start(); 70 | 71 | container.addNewFileToGit("file.txt"); 72 | container.gitCommit("feat: Add feature"); 73 | container.gitTag("v0.1.0"); 74 | container.addNewFileToGit("file2.txt"); 75 | container.gitCommit("fix: Add fix"); 76 | container.addNewFileToGit("file3.txt"); 77 | container.gitCommit("fix: Add another fix"); 78 | container.gitTag("v1.0.0"); 79 | 80 | assertThat(container.exec("git", "semver", "log", "v1.0.0")) 81 | .doesNotContain("feat: Add feature") 82 | .contains("fix: Add fix") 83 | .contains("fix: Add another fix"); 84 | } 85 | 86 | } 87 | 88 | @Test 89 | public void shouldPrintLogWithExcludingSimplyTaggedCommitHistory() { 90 | 91 | try (var container = new GitSemverContainer()) { 92 | container.start(); 93 | 94 | container.addNewFileToGit("file.txt"); 95 | container.gitCommit("feat: Add feature"); 96 | container.gitTag("v1.0.0"); 97 | container.addNewFileToGit("file2.txt"); 98 | container.gitCommit("fix: Add fix"); 99 | 100 | assertThat(container.exec("git", "semver", "log")) 101 | .doesNotContain("feat: Add feature") 102 | .contains("fix: Add fix"); 103 | } 104 | 105 | } 106 | 107 | @Test 108 | public void shouldPrintLogWithExcludingAnnotatedTaggedCommitHistory() { 109 | 110 | try (var container = new GitSemverContainer()) { 111 | container.start(); 112 | 113 | container.addNewFileToGit("file.txt"); 114 | container.gitCommit("feat: Add feature"); 115 | container.gitAnnotatedTag("v1.0.0", "Release 1"); 116 | container.addNewFileToGit("file2.txt"); 117 | container.gitCommit("fix: Add fix"); 118 | 119 | assertThat(container.exec("git", "semver", "log")) 120 | .doesNotContain("feat: Add feature") 121 | .contains("fix: Add fix"); 122 | } 123 | 124 | } 125 | 126 | @Test 127 | public void shouldPrintLogUpToSimpleTag() { 128 | 129 | try (var container = new GitSemverContainer()) { 130 | container.start(); 131 | 132 | container.addNewFileToGit("file.txt"); 133 | container.gitCommit("feat: Add feature"); 134 | container.gitTag("v1.0.0"); 135 | container.addNewFileToGit("file2.txt"); 136 | container.gitCommit("fix: Add fix"); 137 | 138 | assertThat(container.exec("git", "semver", "log", "v1.0.0")) 139 | .contains("feat: Add feature") 140 | .doesNotContain("fix: Add fix"); 141 | } 142 | 143 | } 144 | 145 | @Test 146 | public void shouldPrintLogUpToAnnotatedTagged() { 147 | 148 | try (var container = new GitSemverContainer()) { 149 | container.start(); 150 | 151 | container.addNewFileToGit("file.txt"); 152 | container.gitCommit("feat: Add feature"); 153 | container.gitAnnotatedTag("v1.0.0", "Release 1"); 154 | container.addNewFileToGit("file2.txt"); 155 | container.gitCommit("fix: Add fix"); 156 | 157 | assertThat(container.exec("git", "semver", "log", "v1.0.0")) 158 | .contains("feat: Add feature") 159 | .doesNotContain("fix: Add fix"); 160 | } 161 | 162 | } 163 | 164 | @Test 165 | public void shouldPrintLogWithPreReleaseCommitsInclusive() { 166 | 167 | try (var container = new GitSemverContainer()) { 168 | container.start(); 169 | 170 | container.addNewFileToGit("file.txt"); 171 | container.gitCommit("feat: Add feature"); 172 | container.gitTag("v1.0.0-alpha"); 173 | container.addNewFileToGit("file2.txt"); 174 | container.gitCommit("fix: Add fix"); 175 | 176 | assertThat(container.exec("git", "semver", "log")) 177 | .contains("feat: Add feature") 178 | .contains("fix: Add fix"); 179 | } 180 | 181 | } 182 | 183 | @Test 184 | public void shouldPrintLogWithPreReleaseCommitsExclusive() { 185 | 186 | try (var container = new GitSemverContainer()) { 187 | container.start(); 188 | 189 | container.addNewFileToGit("file.txt"); 190 | container.gitCommit("feat: Add feature"); 191 | container.gitTag("v1.0.0-alpha"); 192 | container.addNewFileToGit("file2.txt"); 193 | container.gitCommit("fix: Add fix"); 194 | 195 | assertThat(container.exec("git", "semver", "log", "--exclude-pre-releases")) 196 | .doesNotContain("feat: Add feature") 197 | .contains("fix: Add fix"); 198 | } 199 | 200 | } 201 | 202 | @Test 203 | public void shouldPrintLogWithLatestPrecedingVersionNotReachableFromHEAD() { 204 | 205 | try (var container = new GitSemverContainer()) { 206 | container.start(); 207 | 208 | container.addNewFileToGit("file.txt"); 209 | container.gitCommit("feat: Initial feature, which is contained in v1.0.0"); 210 | container.gitCheckoutNewBranch("v1"); 211 | container.addNewFileToGit("file2.txt"); 212 | container.gitCommit("feat: v1"); 213 | container.gitTag("v1.0.0"); 214 | container.gitCheckout("master"); 215 | container.addNewFileToGit("file2.txt"); 216 | container.gitCommit("fix: Commit which is only on v2.0.0"); 217 | container.gitTag("v2.0.0"); 218 | 219 | assertThat(container.exec("git", "semver", "log", "v2.0.0")) 220 | .doesNotContain("feat: Initial feature, which is contained in v1.0.0") 221 | .doesNotContain("feat: v1") 222 | .contains("fix: Commit which is only on v2.0.0"); 223 | } 224 | 225 | } 226 | 227 | @Test 228 | public void shouldPrintLogAsConventionalCommits() { 229 | 230 | try (var container = new GitSemverContainer()) { 231 | container.start(); 232 | 233 | container.addNewFileToGit("file.txt"); 234 | container.gitCommit("feat: Add feature"); 235 | container.addNewFileToGit("file2.txt"); 236 | container.gitCommit("Some non-conventional-commit"); 237 | container.addNewFileToGit("file3.txt"); 238 | container.gitCommit("fix(some_component): Add fix\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc bibendum vulputate sapien vel mattis.\n\nVivamus faucibus leo id libero suscipit, varius tincidunt neque interdum. Mauris rutrum at velit vitae semper.\n\nFixes: http://issues.example.com/123\nBREAKING CHANGE: This commit is breaking some API."); 239 | 240 | assertThat(container.exec("git", "semver", "log", "--conventional-commits")) 241 | .isEqualTo("[\n" 242 | + " {\n" 243 | + " \"type\": \"fix\",\n" 244 | + " \"scope\": \"some_component\",\n" 245 | + " \"breaking_change\": true,\n" 246 | + " \"description\": \"Add fix\",\n" 247 | + " \"body\": \"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc bibendum vulputate sapien vel mattis.\\n\\nVivamus faucibus leo id libero suscipit, varius tincidunt neque interdum. Mauris rutrum at velit vitae semper.\",\n" 248 | + " \"footers\": {\n" 249 | + " \"BREAKING CHANGE\": [\n" 250 | + " \"This commit is breaking some API.\"\n" 251 | + " ],\n" 252 | + " \"Fixes\": [\n" 253 | + " \"http://issues.example.com/123\"\n" 254 | + " ]\n" 255 | + " }\n" 256 | + " },\n" 257 | + " {\n" 258 | + " \"type\": \"feat\",\n" 259 | + " \"description\": \"Add feature\"\n" 260 | + " }\n" 261 | + "]\n" 262 | ); 263 | } 264 | 265 | } 266 | 267 | @Test 268 | public void shouldPrintLogAsMarkdown() { 269 | 270 | try (var container = new GitSemverContainer()) { 271 | container.start(); 272 | 273 | container.addNewFileToGit("file.txt"); 274 | container.gitCommit("feat: Add feature"); 275 | container.addNewFileToGit("file2.txt"); 276 | container.gitCommit("Some non-conventional-commit"); 277 | container.addNewFileToGit("file3.txt"); 278 | container.gitCommit("fix(some_component): Add fix\n\nLorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc bibendum vulputate sapien vel mattis.\n\nVivamus faucibus leo id libero suscipit, varius tincidunt neque interdum. Mauris rutrum at velit vitae semper.\n\nFixes: http://issues.example.com/123\nBREAKING CHANGE: This commit is breaking some API."); 279 | 280 | assertThat(container.exec("git", "semver", "log", "--markdown")) 281 | .isEqualTo("### BREAKING CHANGES\n" 282 | + "\n" 283 | + "* **some_component** This commit is breaking some API.\n" 284 | + "\n" 285 | + "### Features\n" 286 | + "\n" 287 | + "* Add feature\n" 288 | + "\n" 289 | + "### Bug Fixes\n" 290 | + "\n" 291 | + "* **some_component** Add fix\n" 292 | + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc bibendum vulputate sapien vel mattis.\n" 293 | + "\n" 294 | + "Vivamus faucibus leo id libero suscipit, varius tincidunt neque interdum. Mauris rutrum at velit vitae semper.\n" 295 | ); 296 | } 297 | 298 | } 299 | 300 | } 301 | -------------------------------------------------------------------------------- /integration_tests/src/test/java/de/psanetra/gitsemver/NextCmdTests.java: -------------------------------------------------------------------------------- 1 | package de.psanetra.gitsemver; 2 | 3 | import de.psanetra.gitsemver.containers.GitSemverContainer; 4 | import org.junit.jupiter.api.Test; 5 | 6 | import java.io.IOException; 7 | 8 | import static org.assertj.core.api.Assertions.assertThat; 9 | import static org.assertj.core.api.Assertions.assertThatCode; 10 | 11 | public class NextCmdTests { 12 | 13 | @Test 14 | public void shouldIncrementVersionRelativeToSimpleTag() { 15 | 16 | try (var container = new GitSemverContainer()) { 17 | container.start(); 18 | 19 | container.addNewFileToGit("file.txt"); 20 | container.gitCommit("feat: Add feature"); 21 | container.gitTag("v1.0.0"); 22 | container.addNewFileToGit("file2.txt"); 23 | container.gitCommit("fix: Add fix"); 24 | 25 | assertThat(container.exec("git", "semver", "next")).isEqualTo("1.0.1"); 26 | } 27 | 28 | } 29 | 30 | @Test 31 | public void shouldIncrementVersionRelativeToAnnotatedTag() { 32 | 33 | try (var container = new GitSemverContainer()) { 34 | container.start(); 35 | 36 | container.addNewFileToGit("file.txt"); 37 | container.gitCommit("feat: Add feature"); 38 | container.gitAnnotatedTag("v1.0.0", "First Version"); 39 | container.addNewFileToGit("file2.txt"); 40 | container.gitCommit("fix: Add fix"); 41 | 42 | assertThat(container.exec("git", "semver", "next")).isEqualTo("1.0.1"); 43 | } 44 | 45 | } 46 | 47 | @Test 48 | public void shouldNotIncrementVersionAfterOnlyChoreCommitRelativeToSimpleTag() { 49 | 50 | try (var container = new GitSemverContainer()) { 51 | container.start(); 52 | 53 | container.addNewFileToGit("file.txt"); 54 | container.gitCommit("feat: Add feature"); 55 | container.gitTag("v1.0.0"); 56 | container.addNewFileToGit("file2.txt"); 57 | container.gitCommit("chore: Some maintenance"); 58 | 59 | assertThat(container.exec("git", "semver", "next")).isEqualTo("1.0.0"); 60 | } 61 | 62 | } 63 | 64 | @Test 65 | public void shouldNotIncrementVersionAfterOnlyChoreCommitRelativeToAnnotatedTag() { 66 | 67 | try (var container = new GitSemverContainer()) { 68 | container.start(); 69 | 70 | container.addNewFileToGit("file.txt"); 71 | container.gitCommit("feat: Add feature"); 72 | container.gitAnnotatedTag("v1.0.0", "First Version"); 73 | container.addNewFileToGit("file2.txt"); 74 | container.gitCommit("chore: Some maintenance"); 75 | 76 | assertThat(container.exec("git", "semver", "next")).isEqualTo("1.0.0"); 77 | } 78 | 79 | } 80 | 81 | @Test 82 | public void shouldConvertPrereleaseToRelease() { 83 | 84 | try (var container = new GitSemverContainer()) { 85 | container.start(); 86 | 87 | container.addNewFileToGit("file.txt"); 88 | container.gitCommit("feat: Add feature"); 89 | container.gitTag("v1.2.3"); 90 | container.addNewFileToGit("file2.txt"); 91 | container.gitCommit("feat: Add feature 2"); 92 | container.gitTag("v1.3.0-beta"); 93 | 94 | assertThat(container.exec("git", "semver", "next")).isEqualTo("1.3.0"); 95 | } 96 | 97 | } 98 | 99 | /** 100 | * In this case the next command can not calculate the next version based on the commits since the latest release. 101 | */ 102 | @Test 103 | public void shouldReturnErrorCodeIfLatestVersionNotReachableFromHEAD() throws IOException, InterruptedException { 104 | 105 | try (var container = new GitSemverContainer()) { 106 | container.start(); 107 | 108 | container.gitCheckoutNewBranch("master"); 109 | container.addNewFileToGit("file.txt"); 110 | container.gitCommit("feat: Master commit"); 111 | container.gitCheckoutNewBranch("v1"); 112 | container.addNewFileToGit("file2.txt"); 113 | container.gitCommit("feat: v1 commit"); 114 | container.gitTag("v1.2.3"); 115 | container.gitCheckout("master"); 116 | 117 | var result = container.execInContainer("git", "semver", "next"); 118 | 119 | assertThat(result.getExitCode()).isNotEqualTo(0); 120 | assertThat(result.getStderr()).contains( 121 | "Latest tag is not on HEAD. This is necessary as the next version is calculated based on the commits since the latest version tag."); 122 | } 123 | 124 | } 125 | 126 | @Test 127 | public void shouldNotReturnErrorCodeIfLatestVersionReachableFromDetachedHEAD() throws IOException, InterruptedException { 128 | 129 | try (var container = new GitSemverContainer()) { 130 | container.start(); 131 | 132 | container.gitCheckoutNewBranch("master"); 133 | container.addNewFileToGit("file.txt"); 134 | container.gitCommit("feat: commit 1"); 135 | container.gitTag("v1.2.3"); 136 | container.addNewFileToGit("file_2.txt"); 137 | container.gitCommit("feat: commit 2"); 138 | container.addNewFileToGit("file_3.txt"); 139 | container.gitCommit("feat: commit 3"); 140 | container.gitCheckout("HEAD~1"); 141 | 142 | var result = container.execInContainer("git", "log", "-1"); 143 | assertThat(result.getStdout()).contains("feat: commit 2"); 144 | 145 | result = container.execInContainer("git", "semver", "next"); 146 | 147 | assertThat(result.getExitCode()).isEqualTo(0); 148 | assertThat(result.getStdout()).contains("1.3.0"); 149 | } 150 | 151 | } 152 | 153 | @Test 154 | public void shouldReturnFirstVersionOnRepoWithoutTags() { 155 | 156 | try (var container = new GitSemverContainer()) { 157 | container.start(); 158 | 159 | container.addNewFileToGit("file.txt"); 160 | container.gitCommit("Initial commit"); 161 | 162 | assertThat(container.exec("git", "semver", "next")).isEqualTo("1.0.0"); 163 | } 164 | 165 | } 166 | 167 | @Test 168 | public void shouldReturnNextForSpecificMajorVersion() { 169 | 170 | try (var container = new GitSemverContainer()) { 171 | container.start(); 172 | 173 | container.gitCheckoutNewBranch("v1"); 174 | container.addNewFileToGit("file.txt"); 175 | container.gitCommit("feat: Add feature"); 176 | container.gitTag("v1.0.0"); 177 | container.gitCheckoutNewBranch("v2"); 178 | container.addNewFileToGit("file2.txt"); 179 | container.gitCommit("feat: Add feature 2\nBREAKING CHANGE: some breaking change"); 180 | container.gitTag("v2.0.0"); 181 | container.gitCheckout("v1"); 182 | container.addNewFileToGit("file3.txt"); 183 | container.gitCommit("fix: Fix something in v1"); 184 | 185 | assertThat(container.exec("git", "semver", "next", "--major-version", "1")).isEqualTo("1.0.1"); 186 | } 187 | 188 | } 189 | 190 | @Test 191 | public void shouldReturnLatestAfterMergeWithChoreBranchPrallelToRelease() { 192 | 193 | try (var container = new GitSemverContainer()) { 194 | container.start(); 195 | 196 | /* 197 | Produce history: 198 | * 193c028 - Merge branch 'branch-with-chore-commit' 199 | |\ 200 | * | 21c43b3 - feat: More features in master (tag: v1.0.0) 201 | | * 17c1f5a - chore: some maintenance (branch-with-chore-commit) 202 | |/ 203 | * 1688566 - feat: First commit 204 | */ 205 | container.gitCheckoutNewBranch("master"); 206 | container.addNewFileToGit("file.txt"); 207 | container.gitCommit("feat: First commit"); 208 | container.gitCheckoutNewBranch("branch-with-chore-commit"); 209 | container.addNewFileToGit("file2.txt"); 210 | container.gitCommit("chore: some maintenance"); 211 | container.gitCheckout("master"); 212 | container.addNewFileToGit("file3.txt"); 213 | container.gitCommit("feat: More features in master"); 214 | container.gitTag("v1.2.3"); 215 | container.gitMerge("branch-with-chore-commit"); 216 | 217 | assertThat(container.exec("git", "semver", "next")).isEqualTo("1.2.3"); 218 | } 219 | 220 | } 221 | 222 | @Test 223 | public void shouldReturnNewVersionAfterMergeWithFeatureBranchPrallelToRelease() { 224 | 225 | try (var container = new GitSemverContainer()) { 226 | container.start(); 227 | 228 | /* 229 | Produce history: 230 | * 193c028 - Merge branch 'branch-with-feature-commit' 231 | |\ 232 | * | 21c43b3 - feat: More features in master (tag: v1.0.0) 233 | | * 17c1f5a - feat: Add feature in branch (branch-with-feature-commit) 234 | |/ 235 | * 1688566 - feat: Add feature 236 | */ 237 | container.gitCheckoutNewBranch("master"); 238 | container.addNewFileToGit("file.txt"); 239 | container.gitCommit("feat: Add feature"); 240 | container.gitCheckoutNewBranch("branch-with-feature-commit"); 241 | container.addNewFileToGit("file2.txt"); 242 | container.gitCommit("feat: Add feature in branch"); 243 | container.gitCheckout("master"); 244 | container.addNewFileToGit("file3.txt"); 245 | container.gitCommit("feat: More features in master"); 246 | container.gitTag("v1.0.0"); 247 | container.gitMerge("branch-with-feature-commit"); 248 | 249 | assertThat(container.exec("git", "semver", "next")).isEqualTo("1.1.0"); 250 | } 251 | 252 | } 253 | 254 | @Test 255 | public void shouldPanicIfCommitIsMissingOnShallowClone() { 256 | 257 | try (var container = new GitSemverContainer()) { 258 | container.start(); 259 | 260 | container.gitCheckoutNewBranch("master"); 261 | container.addNewFileToGit("file.txt"); 262 | container.gitCommit("Initial Commit"); 263 | container.addNewFileToGit("file2.txt"); 264 | container.gitCommit("feat: First Version"); 265 | container.gitTag("v1.0.0"); 266 | container.addNewFileToGit("file3.txt"); 267 | container.gitCommit("feat: Missing commit"); 268 | container.addNewFileToGit("file4.txt"); 269 | container.gitCommit("feat: Latest commit"); 270 | container.exec("sh", "-c", "mv " + GitSemverContainer.WORKDIR + " /remote && mkdir " + GitSemverContainer.WORKDIR); 271 | container.exec("git", "clone", "--depth", "1", "file:///remote", "."); 272 | container.exec("git", "fetch", "--prune", "--prune-tags", "--tags"); 273 | 274 | assertThat(container.exec("git", "log")) 275 | .contains("feat: Latest commit") 276 | .doesNotContain("feat: Missing commit") 277 | .doesNotContain("Initial Commit"); 278 | 279 | assertThatCode(() -> container.exec("git", "semver", "next")).hasMessageContaining("level=fatal msg=\"object not found\""); 280 | } 281 | 282 | } 283 | 284 | @Test 285 | public void shouldReturnNewVersionOnShallowRepositoryWithAllNecessaryCommits() { 286 | 287 | try (var container = new GitSemverContainer()) { 288 | container.start(); 289 | 290 | container.gitCheckoutNewBranch("master"); 291 | container.addNewFileToGit("file.txt"); 292 | container.gitCommit("Initial Commit"); 293 | container.addNewFileToGit("file2.txt"); 294 | container.gitCommit("feat: First Version"); 295 | container.gitTag("v1.0.0"); 296 | container.addNewFileToGit("file3.txt"); 297 | container.gitCommit("feat: More features"); 298 | container.exec("sh", "-c", "mv " + GitSemverContainer.WORKDIR + " /remote && mkdir " + GitSemverContainer.WORKDIR); 299 | container.exec("git", "clone", "--depth", "1", "file:///remote", "."); 300 | container.exec("git", "fetch", "--prune", "--prune-tags", "--tags"); 301 | container.exec("git", "fetch", "--shallow-exclude=v1.0.0"); 302 | 303 | assertThat(container.exec("git", "log")) 304 | .contains("feat: More features") 305 | // .contains("feat: First Version") 306 | .doesNotContain("Initial Commit"); 307 | 308 | assertThat(container.exec("git", "semver", "next")).isEqualTo("1.1.0"); 309 | } 310 | 311 | } 312 | 313 | @Test 314 | public void shouldReturnNewVersionOnShallowRepositoryWithAllNecessaryCommitsIncludingMergedBranch() { 315 | 316 | try (var container = new GitSemverContainer()) { 317 | container.start(); 318 | 319 | container.gitCheckoutNewBranch("master"); 320 | container.addNewFileToGit("file.txt"); 321 | container.gitCommit("Initial Commit"); 322 | container.gitCheckoutNewBranch("some-feature-branch"); 323 | container.addNewFileToGit("feature-file.txt"); 324 | container.gitCommit("feat: Feature branch commit"); 325 | container.gitCheckout("master"); 326 | container.addNewFileToGit("file2.txt"); 327 | container.gitCommit("feat: First Version"); 328 | container.gitTag("v1.0.0"); 329 | container.addNewFileToGit("file3.txt"); 330 | container.gitCommit("fix: Some fix in master"); 331 | container.gitMerge("some-feature-branch"); 332 | container.exec("sh", "-c", "mv " + GitSemverContainer.WORKDIR + " /remote && mkdir " + GitSemverContainer.WORKDIR); 333 | container.exec("git", "clone", "--depth", "1", "file:///remote", "."); 334 | container.exec("git", "fetch", "--prune", "--prune-tags", "--tags"); 335 | container.exec("git", "fetch", "--shallow-exclude=v1.0.0"); 336 | 337 | assertThat(container.exec("git", "log")) 338 | .contains("Merge branch 'some-feature-branch'") 339 | .contains("fix: Some fix in master") 340 | // .contains("feat: First Version") 341 | .contains("feat: Feature branch commit") 342 | .doesNotContain("Initial Commit"); 343 | 344 | assertThat(container.exec("git", "semver", "next")).isEqualTo("1.1.0"); 345 | } 346 | 347 | } 348 | 349 | } 350 | -------------------------------------------------------------------------------- /integration_tests/src/test/java/de/psanetra/gitsemver/containers/BaseImage.java: -------------------------------------------------------------------------------- 1 | package de.psanetra.gitsemver.containers; 2 | 3 | import org.testcontainers.images.builder.ImageFromDockerfile; 4 | 5 | import java.nio.file.Paths; 6 | 7 | public class BaseImage { 8 | public static final ImageFromDockerfile IMAGE = new ImageFromDockerfile() 9 | .withFileFromPath(".", Paths.get("..")); 10 | 11 | public static String getImageName() { 12 | return IMAGE.get(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /integration_tests/src/test/java/de/psanetra/gitsemver/containers/GitSemverContainer.java: -------------------------------------------------------------------------------- 1 | package de.psanetra.gitsemver.containers; 2 | 3 | import lombok.NonNull; 4 | import lombok.extern.slf4j.Slf4j; 5 | import org.testcontainers.containers.GenericContainer; 6 | import org.testcontainers.images.builder.ImageFromDockerfile; 7 | 8 | import java.io.IOException; 9 | import java.util.concurrent.Future; 10 | 11 | @Slf4j 12 | public class GitSemverContainer extends GenericContainer { 13 | public static final String WORKDIR = "/workdir"; 14 | 15 | private static ImageFromDockerfile createImage() { 16 | var image = new ImageFromDockerfile() 17 | .withDockerfileFromBuilder(dockerfileBuilder -> { 18 | dockerfileBuilder 19 | .from(BaseImage.getImageName()) 20 | .workDir(WORKDIR) 21 | .entryPoint("sleep", "190"); 22 | }); 23 | 24 | return image; 25 | } 26 | 27 | public GitSemverContainer() { 28 | this(createImage()); 29 | } 30 | 31 | private GitSemverContainer(@NonNull Future image) { 32 | super(image); 33 | } 34 | 35 | /** 36 | * @return stdout 37 | */ 38 | public String exec(String... command) { 39 | ExecResult result; 40 | 41 | try { 42 | result = execInContainer(command); 43 | 44 | if (result.getExitCode() != 0) { 45 | throw new RuntimeException( 46 | "Command exited with " + result.getExitCode() + ":" + 47 | String.join(" ", command) + 48 | "\nstdout:\n" + result.getStdout() + 49 | "\nstderr:\n" + result.getStderr()); 50 | } 51 | } catch (IOException | InterruptedException e) { 52 | throw new RuntimeException(e); 53 | } 54 | 55 | return result.getStdout(); 56 | } 57 | 58 | @Override 59 | public void start() { 60 | super.start(); 61 | exec("git", "init"); 62 | exec("git", "config", "user.email", "test@example.com"); 63 | exec("git", "config", "user.name", "testuser"); 64 | } 65 | 66 | public void touch(String filename) { 67 | exec("touch", filename); 68 | } 69 | 70 | public void gitAddAll() { 71 | exec("git", "add", "-A"); 72 | } 73 | 74 | public void gitAdd(String filename) { 75 | exec("git", "add", filename); 76 | } 77 | 78 | public void addNewFileToGit(String filename) { 79 | touch(filename); 80 | gitAdd(filename); 81 | } 82 | 83 | public void gitCheckoutNewBranch(String newBranchName) { 84 | exec("git", "checkout", "-b", newBranchName); 85 | } 86 | 87 | public void gitCheckout(String branchName) { 88 | exec("git", "checkout", branchName); 89 | } 90 | 91 | public void gitCommit(String message) { 92 | exec("git", "commit", "-m", message); 93 | } 94 | 95 | public void gitTag(String tag) { 96 | exec("git", "tag", tag); 97 | } 98 | 99 | public void gitAnnotatedTag(String tag, String message) { 100 | exec("git", "tag", "-a", tag, "-m", message); 101 | } 102 | 103 | public void gitMerge(String branchName) { 104 | exec("git", "merge", branchName, "--no-edit"); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /latest/latest.go: -------------------------------------------------------------------------------- 1 | package latest 2 | 3 | import ( 4 | "github.com/go-git/go-git/v5" 5 | "github.com/go-git/go-git/v5/plumbing" 6 | "github.com/pkg/errors" 7 | "github.com/psanetra/git-semver/logger" 8 | "github.com/psanetra/git-semver/semver" 9 | "io" 10 | ) 11 | 12 | type LatestOptions struct { 13 | Workdir string 14 | IncludePreReleases bool 15 | MajorVersionFilter int 16 | } 17 | 18 | func Latest(options LatestOptions) (*semver.Version, error) { 19 | 20 | repo, err := git.PlainOpenWithOptions(options.Workdir, &git.PlainOpenOptions{ 21 | DetectDotGit: true, 22 | }) 23 | 24 | if err != nil { 25 | return nil, errors.WithMessage(err, "Could not open git repository") 26 | } 27 | 28 | latestReleaseVersion, _, err := FindLatestVersion(repo, options.MajorVersionFilter, options.IncludePreReleases) 29 | 30 | if latestReleaseVersion == nil { 31 | latestReleaseVersion = &semver.EmptyVersion 32 | } 33 | 34 | return latestReleaseVersion, err 35 | 36 | } 37 | 38 | func FindLatestVersion(repo *git.Repository, majorVersionFilter int, preRelease bool) (*semver.Version, *plumbing.Reference, error) { 39 | latestVersionTag, err := findLatestVersionTag(repo, majorVersionFilter, preRelease) 40 | 41 | if err != nil { 42 | return nil, nil, err 43 | } 44 | 45 | if latestVersionTag == nil { 46 | return nil, nil, nil 47 | } 48 | 49 | return tagNameToVersion(latestVersionTag.Name().Short()), latestVersionTag, nil 50 | } 51 | 52 | func findLatestVersionTag(repo *git.Repository, majorVersionFilter int, includePreReleases bool) (*plumbing.Reference, error) { 53 | 54 | tagIter, err := repo.Tags() 55 | 56 | if err != nil { 57 | return nil, err 58 | } 59 | 60 | defer tagIter.Close() 61 | 62 | var maxVersionTag *plumbing.Reference 63 | var maxVersion = &semver.EmptyVersion 64 | 65 | for tag, err := tagIter.Next(); err != io.EOF; tag, err = tagIter.Next() { 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | version := tagNameToVersion(tag.Name().Short()) 71 | 72 | if version == nil || !includePreReleases && len(version.PreReleaseTag) > 0 { 73 | continue 74 | } 75 | 76 | if (majorVersionFilter < 0 || majorVersionFilter == version.Major) && semver.CompareVersions(version, maxVersion) > 0 { 77 | maxVersion = version 78 | maxVersionTag = tag 79 | } 80 | } 81 | 82 | return maxVersionTag, nil 83 | } 84 | 85 | func tagNameToVersion(tagName string) *semver.Version { 86 | 87 | version, err := semver.ParseVersion(tagName) 88 | 89 | if err != nil { 90 | logger.Logger.Debug(err, ": Tag: ", tagName) 91 | return nil 92 | } 93 | 94 | return version 95 | } 96 | -------------------------------------------------------------------------------- /latest/latest_test.go: -------------------------------------------------------------------------------- 1 | package latest 2 | 3 | import ( 4 | "github.com/psanetra/git-semver/semver" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func Test_tagNameToVersion_should_return_version(t *testing.T) { 10 | version := tagNameToVersion("1.2.3") 11 | 12 | assert.Equal( 13 | t, 14 | &semver.Version{ 15 | Major: 1, 16 | Minor: 2, 17 | Patch: 3, 18 | PreReleaseTag: []interface{}{}, 19 | }, 20 | version, 21 | ) 22 | } 23 | 24 | func Test_tagNameToVersion_should_return_version_if_tag_has_v_prefix(t *testing.T) { 25 | version := tagNameToVersion("v1.2.3") 26 | 27 | assert.Equal( 28 | t, 29 | &semver.Version{ 30 | Major: 1, 31 | Minor: 2, 32 | Patch: 3, 33 | PreReleaseTag: []interface{}{}, 34 | }, 35 | version, 36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "github.com/sirupsen/logrus" 5 | ) 6 | 7 | const DEFAULT_LOG_LEVEL = logrus.InfoLevel 8 | 9 | var Logger logrus.FieldLogger = logrus.StandardLogger() 10 | 11 | func SetLevel(level string) { 12 | loggerLevel, err := logrus.ParseLevel(level) 13 | 14 | if err != nil { 15 | Logger.Fatalln("Could not parse log-level: ", err) 16 | } 17 | 18 | logrus.SetLevel(loggerLevel) 19 | } 20 | -------------------------------------------------------------------------------- /next/next.go: -------------------------------------------------------------------------------- 1 | package next 2 | 3 | import ( 4 | "github.com/go-git/go-git/v5" 5 | "github.com/go-git/go-git/v5/plumbing" 6 | "github.com/go-git/go-git/v5/plumbing/revlist" 7 | "github.com/pkg/errors" 8 | "github.com/psanetra/git-semver/conventional_commits" 9 | "github.com/psanetra/git-semver/git_utils" 10 | "github.com/psanetra/git-semver/latest" 11 | "github.com/psanetra/git-semver/logger" 12 | "github.com/psanetra/git-semver/semver" 13 | ) 14 | 15 | type NextOptions struct { 16 | Workdir string 17 | Stable bool 18 | MajorVersionFilter int 19 | PreReleaseOptions semver.PreReleaseOptions 20 | } 21 | 22 | func Next(options NextOptions) (*semver.Version, error) { 23 | 24 | repo, err := git.PlainOpenWithOptions(options.Workdir, &git.PlainOpenOptions{ 25 | DetectDotGit: true, 26 | }) 27 | 28 | if err != nil { 29 | return nil, errors.WithMessage(err, "Could not open git repository") 30 | } 31 | 32 | headRef, err := repo.Head() 33 | 34 | if err != nil { 35 | return nil, errors.WithMessage(err, "Could not find HEAD") 36 | } 37 | 38 | latestReleaseVersion, latestReleaseVersionTag, err := latest.FindLatestVersion(repo, options.MajorVersionFilter, false) 39 | 40 | if err != nil { 41 | return nil, errors.WithMessage(err, "Error while trying to find latest release version tag") 42 | } 43 | 44 | if latestReleaseVersionTag != nil { 45 | if err = git_utils.AssertRefIsReachable(repo, latestReleaseVersionTag, headRef, "Latest tag is not on HEAD. This is necessary as the next version is calculated based on the commits since the latest version tag."); err != nil { 46 | return nil, err 47 | } 48 | } 49 | 50 | if latestReleaseVersion == nil { 51 | latestReleaseVersion = &semver.EmptyVersion 52 | } 53 | 54 | var latestPreReleaseVersion *semver.Version 55 | var latestPreReleaseVersionTag *plumbing.Reference 56 | 57 | if options.PreReleaseOptions.ShouldBePreRelease() { 58 | latestPreReleaseVersion, latestPreReleaseVersionTag, err = latest.FindLatestVersion(repo, options.MajorVersionFilter, true) 59 | } 60 | 61 | if err != nil { 62 | return nil, errors.WithMessage(err, "Error while trying to find latest pre-release version tag") 63 | } 64 | 65 | if latestPreReleaseVersionTag != nil { 66 | if err = git_utils.AssertRefIsReachable(repo, latestPreReleaseVersionTag, headRef, "Latest tag is not on HEAD. This is necessary as the next version is calculated based on the commits since the latest version tag."); err != nil { 67 | return nil, err 68 | } 69 | } 70 | 71 | excludedCommits := make([]plumbing.Hash, 0, 1) 72 | 73 | if latestReleaseVersionTag != nil { 74 | excludedCommits = append(excludedCommits, latestReleaseVersionTag.Hash()) 75 | } 76 | 77 | // historyDiff also contains other hashes than commit hashes (e.g. blob or tree hashes) 78 | historyDiff, err := revlist.Objects( 79 | repo.Storer, 80 | []plumbing.Hash{ 81 | headRef.Hash(), 82 | }, 83 | excludedCommits, 84 | ) 85 | 86 | if err != nil { 87 | objectInfo := " (HEAD: " + headRef.Hash().String() 88 | 89 | if latestReleaseVersionTag != nil { 90 | objectInfo = ", Latest Version: {" + latestReleaseVersionTag.Name().Short() + ", " + latestReleaseVersionTag.Hash().String() + "}" 91 | } 92 | 93 | objectInfo += ")" 94 | 95 | return nil, errors.WithMessage(err, "Could not find commits since latest version"+objectInfo) 96 | } 97 | 98 | var nextVersion semver.Version 99 | 100 | maxPrioCommitMessage := &conventional_commits.ConventionalCommitMessage{} 101 | 102 | for _, hash := range historyDiff { 103 | commit, err := repo.CommitObject(hash) 104 | 105 | if err == plumbing.ErrObjectNotFound { 106 | // hash is not a commit object 107 | continue 108 | } 109 | 110 | if err != nil { 111 | return nil, errors.WithMessage(err, "Could not read commit "+hash.String()) 112 | } 113 | 114 | message, err := conventional_commits.ParseCommitMessage(commit.Message) 115 | 116 | if err != nil { 117 | logger.Logger.Debug(err) 118 | continue 119 | } 120 | 121 | if message.Compare(maxPrioCommitMessage) <= 0 { 122 | continue 123 | } 124 | 125 | maxPrioCommitMessage = message 126 | 127 | if message.ContainsBreakingChange { 128 | break 129 | } 130 | } 131 | 132 | nextVersion, err = semver.Increment( 133 | *latestReleaseVersion, 134 | latestPreReleaseVersion, 135 | options.Stable, 136 | commitMessageToSemverChange(maxPrioCommitMessage), 137 | &options.PreReleaseOptions, 138 | ) 139 | 140 | if err != nil { 141 | return nil, errors.WithMessage(err, "Could not increment version") 142 | } 143 | 144 | return &nextVersion, nil 145 | 146 | } 147 | 148 | func commitMessageToSemverChange(msg *conventional_commits.ConventionalCommitMessage) semver.Change { 149 | 150 | var semverChange semver.Change 151 | 152 | if msg == nil { 153 | return semverChange 154 | } else if msg.ContainsBreakingChange { 155 | semverChange = semver.BREAKING 156 | } else if msg.ChangeType == conventional_commits.FEATURE { 157 | semverChange = semver.NEW_FEATURE 158 | } else if msg.ChangeType == conventional_commits.FIX { 159 | semverChange = semver.FIX 160 | } 161 | 162 | return semverChange 163 | } 164 | -------------------------------------------------------------------------------- /regex_utils/submatch_map.go: -------------------------------------------------------------------------------- 1 | package regex_utils 2 | 3 | import ( 4 | "regexp" 5 | ) 6 | 7 | func SubmatchMap(regexp *regexp.Regexp, str string) map[string]string { 8 | 9 | submatches := regexp.FindStringSubmatch(str) 10 | 11 | if submatches == nil { 12 | return nil 13 | } 14 | 15 | return SubmatchMapFromSubmatches(regexp, submatches) 16 | } 17 | 18 | func SubmatchMapFromSubmatches(regexp *regexp.Regexp, submatches []string) map[string]string { 19 | groupNames := regexp.SubexpNames() 20 | 21 | submatchMap := make(map[string]string, len(groupNames)) 22 | 23 | for i, groupName := range groupNames { 24 | submatchMap[groupName] = submatches[i] 25 | } 26 | 27 | return submatchMap 28 | 29 | } 30 | -------------------------------------------------------------------------------- /regex_utils/submatch_map_test.go: -------------------------------------------------------------------------------- 1 | package regex_utils 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "regexp" 6 | "testing" 7 | ) 8 | 9 | func TestSubmatchMapShouldReturnMapWhichMapsGroupNamesToMatchedStrings(t *testing.T) { 10 | 11 | regex := regexp.MustCompile(`abc(?Pdef(?Pghi))(?Pjkl)`) 12 | 13 | matches := SubmatchMap(regex, "abcdefghijkl") 14 | 15 | assert.Equal(t, "defghi", matches["group1"]) 16 | assert.Equal(t, "ghi", matches["nestedGroup"]) 17 | assert.Equal(t, "jkl", matches["group2"]) 18 | 19 | } 20 | 21 | func TestSubmatchMapShouldReturnNilMapIfRegexDidNotMatch(t *testing.T) { 22 | 23 | regex := regexp.MustCompile("xyz") 24 | 25 | matches := SubmatchMap(regex, "abc") 26 | 27 | assert.Nil(t, matches) 28 | 29 | } 30 | -------------------------------------------------------------------------------- /semver/compare.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "github.com/psanetra/git-semver/logger" 5 | ) 6 | 7 | func CompareVersions(v1 *Version, v2 *Version) int { 8 | 9 | if v1 == nil && v2 == nil { 10 | return 0 11 | } else if v1 == nil { 12 | return -1 13 | } else if v2 == nil { 14 | return 1 15 | } 16 | 17 | if v1.Major != v2.Major { 18 | return v1.Major - v2.Major 19 | } 20 | 21 | if v1.Minor != v2.Minor { 22 | return v1.Minor - v2.Minor 23 | } 24 | 25 | if v1.Patch != v2.Patch { 26 | return v1.Patch - v2.Patch 27 | } 28 | 29 | for i := 0; i < len(v1.PreReleaseTag) && i < len(v2.PreReleaseTag); i++ { 30 | v1TagId := v1.PreReleaseTag[i] 31 | v2TagId := v2.PreReleaseTag[i] 32 | 33 | comparisonresult := ComparePreReleaseTagIds(v1TagId, v2TagId) 34 | 35 | if comparisonresult != 0 { 36 | return comparisonresult 37 | } 38 | } 39 | 40 | if len(v1.PreReleaseTag) == len(v2.PreReleaseTag) { 41 | return 0 42 | } else if len(v1.PreReleaseTag) == 0 { 43 | return 1 44 | } else if len(v2.PreReleaseTag) == 0 { 45 | return -1 46 | } 47 | 48 | return len(v1.PreReleaseTag) - len(v2.PreReleaseTag) 49 | } 50 | 51 | func ComparePreReleaseTagIds(tagId1 interface{}, tagId2 interface{}) int { 52 | 53 | numericV1TagId, v1TagIdIsNumeric := tagId1.(int64) 54 | 55 | numericV2TagId, v2TagIdIsNumeric := tagId2.(int64) 56 | 57 | if v1TagIdIsNumeric != v2TagIdIsNumeric { 58 | if v1TagIdIsNumeric { 59 | return -1 60 | } else { 61 | return 1 62 | } 63 | } 64 | 65 | if v1TagIdIsNumeric { 66 | 67 | if numericV1TagId > numericV2TagId { 68 | return 1 69 | } else if numericV2TagId > numericV1TagId { 70 | return -1 71 | } 72 | 73 | } else { 74 | 75 | stringV1TagId, ok := tagId1.(string) 76 | 77 | if !ok { 78 | logger.Logger.Fatalln("Unknown pre-release tag id type") 79 | } 80 | 81 | stringV2TagId, ok := tagId2.(string) 82 | 83 | if !ok { 84 | logger.Logger.Fatalln("Unknown pre-release tag id type") 85 | } 86 | 87 | if stringV1TagId > stringV2TagId { 88 | return 1 89 | } else if stringV2TagId > stringV1TagId { 90 | return -1 91 | } 92 | 93 | } 94 | 95 | return 0 96 | } 97 | -------------------------------------------------------------------------------- /semver/compare_test.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestCompareVersionsReturns0IfV1AndV2AreNil(t *testing.T) { 9 | 10 | result := CompareVersions(nil, nil) 11 | 12 | assert.Equal(t, 0, result) 13 | assert.True(t, result == 0) 14 | 15 | } 16 | 17 | func TestCompareVersionsReturnsGreater0IfOnlyV2IsNil(t *testing.T) { 18 | 19 | result := CompareVersions( 20 | &Version{ 21 | 0, 22 | 0, 23 | 0, 24 | []interface{}{}, 25 | }, 26 | nil, 27 | ) 28 | 29 | assert.True(t, result > 0) 30 | 31 | } 32 | 33 | func TestCompareVersionsReturnsLess0IfOnlyV1IsNil(t *testing.T) { 34 | 35 | result := CompareVersions( 36 | nil, 37 | &Version{ 38 | 0, 39 | 0, 40 | 0, 41 | []interface{}{}, 42 | }, 43 | ) 44 | 45 | assert.True(t, result < 0) 46 | 47 | } 48 | 49 | func TestCompareVersionsReturnsGreaterThan0IfV1HasGreaterMajorVersion(t *testing.T) { 50 | 51 | result := CompareVersions( 52 | &Version{ 53 | 2, 54 | 3, 55 | 4, 56 | []interface{}{}, 57 | }, 58 | &Version{ 59 | 1, 60 | 5, 61 | 6, 62 | []interface{}{}, 63 | }, 64 | ) 65 | 66 | assert.True(t, result > 0) 67 | 68 | } 69 | 70 | func TestCompareVersionsReturnsLessThan0IfV2HasGreaterMajorVersion(t *testing.T) { 71 | 72 | result := CompareVersions( 73 | &Version{ 74 | 1, 75 | 5, 76 | 6, 77 | []interface{}{}, 78 | }, 79 | &Version{ 80 | 2, 81 | 3, 82 | 4, 83 | []interface{}{}, 84 | }, 85 | ) 86 | 87 | assert.True(t, result < 0) 88 | 89 | } 90 | 91 | func TestCompareVersionsReturnsGreaterThan0IfV1HasGreaterMinorVersion(t *testing.T) { 92 | 93 | result := CompareVersions( 94 | &Version{ 95 | 1, 96 | 2, 97 | 3, 98 | []interface{}{}, 99 | }, 100 | &Version{ 101 | 1, 102 | 1, 103 | 4, 104 | []interface{}{}, 105 | }, 106 | ) 107 | 108 | assert.True(t, result > 0) 109 | 110 | } 111 | 112 | func TestCompareVersionsReturnsLessThan0IfV2HasGreaterMinorVersion(t *testing.T) { 113 | 114 | result := CompareVersions( 115 | &Version{ 116 | 1, 117 | 1, 118 | 4, 119 | []interface{}{}, 120 | }, 121 | &Version{ 122 | 1, 123 | 2, 124 | 3, 125 | []interface{}{}, 126 | }, 127 | ) 128 | 129 | assert.True(t, result < 0) 130 | 131 | } 132 | 133 | func TestCompareVersionsReturnsGreaterThan0IfV1HasGreaterPatchVersion(t *testing.T) { 134 | 135 | result := CompareVersions( 136 | &Version{ 137 | 1, 138 | 1, 139 | 2, 140 | []interface{}{}, 141 | }, 142 | &Version{ 143 | 1, 144 | 1, 145 | 1, 146 | []interface{}{}, 147 | }, 148 | ) 149 | 150 | assert.True(t, result > 0) 151 | 152 | } 153 | 154 | func TestCompareVersionsReturnsLessThan0IfV2HasGreaterPatchVersion(t *testing.T) { 155 | 156 | result := CompareVersions( 157 | &Version{ 158 | 1, 159 | 1, 160 | 1, 161 | []interface{}{}, 162 | }, 163 | &Version{ 164 | 1, 165 | 1, 166 | 2, 167 | []interface{}{}, 168 | }, 169 | ) 170 | 171 | assert.True(t, result < 0) 172 | 173 | } 174 | 175 | func TestCompareVersionsReturnsGreaterThan0IfV1HasPreReleaseTagWithHigherPrecedence(t *testing.T) { 176 | 177 | result := CompareVersions( 178 | &Version{ 179 | 1, 180 | 1, 181 | 1, 182 | []interface{}{ 183 | "beta", 184 | int64(1), 185 | }, 186 | }, 187 | &Version{ 188 | 1, 189 | 1, 190 | 1, 191 | []interface{}{ 192 | "alpha", 193 | int64(99), 194 | }, 195 | }, 196 | ) 197 | 198 | assert.True(t, result > 0) 199 | 200 | } 201 | 202 | func TestCompareVersionsReturnsLessThan0IfV2HasPreReleaseTagWithHigherPrecedence(t *testing.T) { 203 | 204 | result := CompareVersions( 205 | &Version{ 206 | 1, 207 | 1, 208 | 1, 209 | []interface{}{ 210 | "alpha", 211 | int64(99), 212 | }, 213 | }, 214 | &Version{ 215 | 1, 216 | 1, 217 | 1, 218 | []interface{}{ 219 | "beta", 220 | int64(1), 221 | }, 222 | }, 223 | ) 224 | 225 | assert.True(t, result < 0) 226 | 227 | } 228 | 229 | func TestCompareVersionsReleaseVersionsHaveHigherPrecedenceThanPreReleaseVersions(t *testing.T) { 230 | 231 | result := CompareVersions( 232 | &Version{ 233 | 1, 234 | 1, 235 | 1, 236 | []interface{}{}, 237 | }, 238 | &Version{ 239 | 1, 240 | 1, 241 | 1, 242 | []interface{}{ 243 | "alpha", 244 | }, 245 | }, 246 | ) 247 | 248 | assert.Equal(t, 1, result) 249 | 250 | result = CompareVersions( 251 | &Version{ 252 | 1, 253 | 1, 254 | 1, 255 | []interface{}{ 256 | "alpha", 257 | }, 258 | }, 259 | &Version{ 260 | 1, 261 | 1, 262 | 1, 263 | []interface{}{}, 264 | }, 265 | ) 266 | 267 | assert.Equal(t, -1, result) 268 | 269 | } 270 | 271 | // https://semver.org/#spec-item-11 272 | func TestCompareVersionsLongerPreReleaseTagsHaveHigherPrecedence(t *testing.T) { 273 | 274 | result := CompareVersions( 275 | &Version{ 276 | 1, 277 | 1, 278 | 1, 279 | []interface{}{ 280 | "alpha", 281 | }, 282 | }, 283 | &Version{ 284 | 1, 285 | 1, 286 | 1, 287 | []interface{}{ 288 | "alpha", 289 | 1, 290 | }, 291 | }, 292 | ) 293 | 294 | assert.Equal(t, -1, result) 295 | 296 | result = CompareVersions( 297 | &Version{ 298 | 1, 299 | 1, 300 | 1, 301 | []interface{}{ 302 | "alpha", 303 | 1, 304 | }, 305 | }, 306 | &Version{ 307 | 1, 308 | 1, 309 | 1, 310 | []interface{}{ 311 | "alpha", 312 | }, 313 | }, 314 | ) 315 | 316 | assert.Equal(t, 1, result) 317 | 318 | } 319 | 320 | func TestCompareVersionsReturns0IfV1AndV2AreEqual(t *testing.T) { 321 | 322 | result := CompareVersions( 323 | &Version{ 324 | 1, 325 | 2, 326 | 3, 327 | []interface{}{ 328 | "alpha", 329 | int64(1), 330 | }, 331 | }, 332 | &Version{ 333 | 1, 334 | 2, 335 | 3, 336 | []interface{}{ 337 | "alpha", 338 | int64(1), 339 | }, 340 | }, 341 | ) 342 | 343 | assert.Equal(t, result, 0) 344 | 345 | } 346 | 347 | func TestComparePreReleaseTagIdsReturns0IfEqual(t *testing.T) { 348 | 349 | assert.Equal(t, ComparePreReleaseTagIds(int64(1), int64(1)), 0) 350 | assert.Equal(t, ComparePreReleaseTagIds("abc", "abc"), 0) 351 | 352 | } 353 | 354 | func TestComparePreReleaseTagIdsReturnsGreaterThan0IfTag1IsGreater(t *testing.T) { 355 | 356 | assert.True(t, ComparePreReleaseTagIds(int64(2), int64(1)) > 0) 357 | assert.True(t, ComparePreReleaseTagIds("xyz", "abc") > 0) 358 | 359 | } 360 | 361 | func TestComparePreReleaseTagIdsReturnsLessThan0IfTag2IsGreater(t *testing.T) { 362 | 363 | assert.True(t, ComparePreReleaseTagIds(int64(1), int64(2)) < 0) 364 | assert.True(t, ComparePreReleaseTagIds("abc", "xyz") < 0) 365 | 366 | } 367 | 368 | // https://semver.org/#spec-item-11 369 | func TestComparePreReleaseTagIdsReturnsGreaterThan0IfTag1IsStringAndTag2IsInt64(t *testing.T) { 370 | 371 | assert.True(t, ComparePreReleaseTagIds("abc", int64(9999)) > 0) 372 | 373 | } 374 | 375 | // https://semver.org/#spec-item-11 376 | func TestComparePreReleaseTagIdsReturnsLessThan0IfTag2IsStringAndTag1IsInt64(t *testing.T) { 377 | 378 | assert.True(t, ComparePreReleaseTagIds(int64(9999), "abc") < 0) 379 | 380 | } 381 | -------------------------------------------------------------------------------- /semver/find_greatest_preceding.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | func FindGreatestPreceding(version *Version, versions []*Version, ignorePreReleases bool) *Version { 4 | var ret *Version 5 | 6 | for _, v := range versions { 7 | if v == nil || 8 | ignorePreReleases && v.IsPreRelease() || 9 | version != nil && CompareVersions(v, version) >= 0 { 10 | continue 11 | } 12 | 13 | if CompareVersions(v, ret) > 0 { 14 | ret = v 15 | } 16 | } 17 | 18 | return ret 19 | } 20 | -------------------------------------------------------------------------------- /semver/find_greatest_preceding_test.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestFindPrecedingShouldReturnNilArgsAreNil(t *testing.T) { 9 | 10 | result := FindGreatestPreceding(nil, nil, false) 11 | 12 | assert.Nil(t, result) 13 | 14 | } 15 | 16 | func TestFindPrecedingShouldReturnGreatestIfVersionIsNil(t *testing.T) { 17 | 18 | result := FindGreatestPreceding( 19 | nil, 20 | []*Version{ 21 | {1, 2, 3, nil}, 22 | {3, 2, 1, nil}, 23 | {2, 1, 3, nil}, 24 | }, 25 | false, 26 | ) 27 | 28 | assert.Equal(t, 3, result.Major) 29 | } 30 | 31 | func TestFindPrecedingShouldReturnNilIfListIsNil(t *testing.T) { 32 | 33 | result := FindGreatestPreceding(&Version{1, 2, 3, nil}, nil, false) 34 | 35 | assert.Nil(t, result) 36 | } 37 | 38 | func TestFindPrecedingShouldReturnGreatestPrecedingOfVersion(t *testing.T) { 39 | 40 | result := FindGreatestPreceding( 41 | &Version{2, 1, 3, nil}, 42 | []*Version{ 43 | {1, 2, 3, nil}, 44 | {3, 2, 1, nil}, 45 | {2, 1, 3, nil}, 46 | }, 47 | false, 48 | ) 49 | 50 | assert.Equal(t, 1, result.Major) 51 | } 52 | 53 | func TestFindPrecedingWithoutIgnoringPreReleasesShouldReturnGreatestPrecedingPreReleaseOfVersion(t *testing.T) { 54 | 55 | result := FindGreatestPreceding( 56 | &Version{3, 2, 1, nil}, 57 | []*Version{ 58 | {1, 2, 3, nil}, 59 | {3, 2, 1, nil}, 60 | {2, 1, 3, nil}, 61 | {3, 2, 1, []interface{}{"alpha"}}, 62 | }, 63 | false, 64 | ) 65 | 66 | assert.Equal(t, 3, result.Major) 67 | assert.Equal(t, "alpha", result.PreReleaseTag[0]) 68 | } 69 | 70 | func TestFindPrecedingWithIgnoringPreReleasesShouldReturnGreatestPrecedingNonPreReleaseOfVersion(t *testing.T) { 71 | 72 | result := FindGreatestPreceding( 73 | &Version{3, 2, 1, nil}, 74 | []*Version{ 75 | {1, 2, 3, nil}, 76 | {3, 2, 1, nil}, 77 | {2, 1, 3, nil}, 78 | {3, 2, 1, []interface{}{"alpha"}}, 79 | }, 80 | true, 81 | ) 82 | 83 | assert.Equal(t, 2, result.Major) 84 | } 85 | -------------------------------------------------------------------------------- /semver/increment.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import "github.com/pkg/errors" 4 | 5 | var VersionAlreadyStableError = errors.New("There is already a stable version of this project.") 6 | 7 | type Change int 8 | 9 | const ( 10 | FIX Change = 1 11 | NEW_FEATURE Change = 2 12 | BREAKING Change = 3 13 | ) 14 | 15 | type PreReleaseOptions struct { 16 | Label string 17 | AppendCounter bool 18 | } 19 | 20 | func (o *PreReleaseOptions) ShouldBePreRelease() bool { 21 | return o != nil && (o.Label != "" || o.AppendCounter) 22 | } 23 | 24 | // Increments a semantic version 25 | func Increment( 26 | latestRelease Version, 27 | latestPreRelease *Version, 28 | shouldBeStable bool, 29 | highestPriorityChange Change, 30 | preReleaseOpts *PreReleaseOptions) (Version, error) { 31 | 32 | if latestRelease.IsStable() && !shouldBeStable { 33 | return Version{}, VersionAlreadyStableError 34 | } 35 | 36 | newVersion := latestRelease 37 | newVersion.PreReleaseTag = []interface{}{} 38 | 39 | if shouldBeStable && newVersion.Major < 1 { 40 | highestPriorityChange = BREAKING 41 | } 42 | 43 | switch highestPriorityChange { 44 | case BREAKING: 45 | newVersion.incrementOnBreakingChange(shouldBeStable) 46 | break 47 | case NEW_FEATURE: 48 | newVersion.incrementMinor() 49 | break 50 | case FIX: 51 | newVersion.incrementPatch() 52 | break 53 | } 54 | 55 | if !preReleaseOpts.ShouldBePreRelease() { 56 | return newVersion, nil 57 | } 58 | 59 | newPreReleaseTag, err := parsePreReleaseTag(preReleaseOpts.Label) 60 | 61 | if err != nil { 62 | return Version{}, errors.WithMessage(err, "Could not parse pre-release tag") 63 | } 64 | 65 | if preReleaseOpts.AppendCounter { 66 | newPreReleaseTag = append(newPreReleaseTag, int64(1)) 67 | } 68 | 69 | if latestPreRelease == nil || 70 | newVersion.Major != latestPreRelease.Major || 71 | newVersion.Minor != latestPreRelease.Minor || 72 | newVersion.Patch != latestPreRelease.Patch { 73 | 74 | newVersion.PreReleaseTag = newPreReleaseTag 75 | return newVersion, nil 76 | } 77 | 78 | if preReleaseOpts.AppendCounter && preReleaseTagsWithCounterAreSimilar(newPreReleaseTag, latestPreRelease.PreReleaseTag) { 79 | newPreReleaseTag = incrementPreReleaseTagCounter(latestPreRelease.PreReleaseTag) 80 | } 81 | 82 | newVersion.PreReleaseTag = newPreReleaseTag 83 | return newVersion, nil 84 | } 85 | 86 | func (v *Version) incrementOnBreakingChange(shouldBeStable bool) { 87 | if shouldBeStable { 88 | v.Major += 1 89 | v.Minor = 0 90 | v.Patch = 0 91 | } else { 92 | v.incrementMinor() 93 | } 94 | } 95 | 96 | func (v *Version) incrementMinor() { 97 | v.Minor += 1 98 | v.Patch = 0 99 | } 100 | 101 | func (v *Version) incrementPatch() { 102 | v.Patch += 1 103 | } 104 | 105 | // Checks if two PreRelease Tags are equal. Will not compare the counter. 106 | func preReleaseTagsWithCounterAreSimilar(tag1 []interface{}, tag2 []interface{}) bool { 107 | if len(tag1) != len(tag2) { 108 | return false 109 | } 110 | 111 | for i := 0; i < (len(tag1) - 1); i++ { 112 | if tag1[i] != tag2[i] { 113 | return false 114 | } 115 | } 116 | 117 | _, t1eIsInt64 := tag1[len(tag1)-1].(int64) 118 | _, t2eIsInt64 := tag2[len(tag1)-1].(int64) 119 | return t1eIsInt64 && t2eIsInt64 120 | } 121 | 122 | func incrementPreReleaseTagCounter(tag []interface{}) []interface{} { 123 | newTag := make([]interface{}, 0, len(tag)) 124 | newTag = append(newTag, tag...) 125 | 126 | counter, _ := newTag[len(newTag)-1].(int64) 127 | 128 | newTag[len(newTag)-1] = counter + 1 129 | 130 | return newTag 131 | } 132 | -------------------------------------------------------------------------------- /semver/increment_test.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestIncrementShouldSetMajorVersionTo1IfVersionShouldBeStableEvenIfThereWasNoBreakingChange(t *testing.T) { 9 | 10 | newVersion, err := Increment( 11 | Version{ 12 | Major: 0, 13 | Minor: 0, 14 | Patch: 0, 15 | }, 16 | nil, 17 | true, 18 | FIX, 19 | nil, 20 | ) 21 | 22 | assert.Nil(t, err) 23 | assert.Equal( 24 | t, 25 | Version{ 26 | Major: 1, 27 | Minor: 0, 28 | Patch: 0, 29 | PreReleaseTag: []interface{}{}, 30 | }, 31 | newVersion, 32 | ) 33 | 34 | } 35 | 36 | func TestIncrementShouldIncrementMajorVersionOnBreakingChange(t *testing.T) { 37 | 38 | newVersion, err := Increment( 39 | Version{ 40 | Major: 1, 41 | Minor: 1, 42 | Patch: 1, 43 | }, 44 | nil, 45 | true, 46 | BREAKING, 47 | nil, 48 | ) 49 | 50 | assert.Nil(t, err) 51 | assert.Equal( 52 | t, 53 | Version{ 54 | Major: 2, 55 | Minor: 0, 56 | Patch: 0, 57 | PreReleaseTag: []interface{}{}, 58 | }, 59 | newVersion, 60 | ) 61 | 62 | } 63 | 64 | func TestIncrementShouldIncrementMinorVersionOnBreakingChangeIfVersionShouldBeUnstable(t *testing.T) { 65 | 66 | newVersion, err := Increment( 67 | Version{ 68 | Major: 0, 69 | Minor: 1, 70 | Patch: 1, 71 | }, 72 | nil, 73 | false, 74 | BREAKING, 75 | nil, 76 | ) 77 | 78 | assert.Nil(t, err) 79 | assert.Equal( 80 | t, 81 | Version{ 82 | Major: 0, 83 | Minor: 2, 84 | Patch: 0, 85 | PreReleaseTag: []interface{}{}, 86 | }, 87 | newVersion, 88 | ) 89 | 90 | } 91 | 92 | func TestIncrementShouldReturnErrorIfVersionShouldBeUnstableButLatestVersionIsAlreadyStable(t *testing.T) { 93 | 94 | _, err := Increment( 95 | Version{ 96 | Major: 1, 97 | }, 98 | nil, 99 | false, 100 | BREAKING, 101 | nil, 102 | ) 103 | 104 | assert.Equal(t, VersionAlreadyStableError, err) 105 | 106 | } 107 | 108 | func TestIncrementShouldIncrementMinorVersionOnNewFeature(t *testing.T) { 109 | 110 | newVersion, err := Increment( 111 | Version{ 112 | Major: 1, 113 | Minor: 1, 114 | Patch: 1, 115 | }, 116 | nil, 117 | true, 118 | NEW_FEATURE, 119 | nil, 120 | ) 121 | 122 | assert.Nil(t, err) 123 | assert.Equal( 124 | t, 125 | Version{ 126 | Major: 1, 127 | Minor: 2, 128 | Patch: 0, 129 | PreReleaseTag: []interface{}{}, 130 | }, 131 | newVersion, 132 | ) 133 | 134 | } 135 | 136 | func TestIncrementShouldIncrementPatchVersionOnFix(t *testing.T) { 137 | 138 | newVersion, err := Increment( 139 | Version{ 140 | Major: 1, 141 | Minor: 1, 142 | Patch: 1, 143 | }, 144 | nil, 145 | true, 146 | FIX, 147 | nil, 148 | ) 149 | 150 | assert.Nil(t, err) 151 | assert.Equal( 152 | t, 153 | Version{ 154 | Major: 1, 155 | Minor: 1, 156 | Patch: 2, 157 | PreReleaseTag: []interface{}{}, 158 | }, 159 | newVersion, 160 | ) 161 | 162 | } 163 | 164 | func TestIncrementShouldIncrementVersionAndApplyLabelOnPreRelease(t *testing.T) { 165 | 166 | newVersion, err := Increment( 167 | Version{}, 168 | nil, 169 | true, 170 | BREAKING, 171 | &PreReleaseOptions{ 172 | Label: "alpha.2018-12-31", 173 | }, 174 | ) 175 | 176 | assert.Nil(t, err) 177 | assert.Equal( 178 | t, 179 | Version{ 180 | Major: 1, 181 | PreReleaseTag: []interface{}{"alpha", "2018-12-31"}, 182 | }, 183 | newVersion, 184 | ) 185 | 186 | } 187 | 188 | func TestIncrementShouldIncrementVersionAndApplyLabelAndAppendCounterOnPreRelease(t *testing.T) { 189 | 190 | newVersion, err := Increment( 191 | Version{}, 192 | nil, 193 | true, 194 | BREAKING, 195 | &PreReleaseOptions{ 196 | Label: "alpha.2018-12-31", 197 | AppendCounter: true, 198 | }, 199 | ) 200 | 201 | assert.Nil(t, err) 202 | assert.Equal( 203 | t, 204 | Version{ 205 | Major: 1, 206 | PreReleaseTag: []interface{}{"alpha", "2018-12-31", int64(1)}, 207 | }, 208 | newVersion, 209 | ) 210 | 211 | } 212 | 213 | func TestIncrementShouldIncrementVersionAndApplyLabelAndIncrementCounterOnExistingPreRelease(t *testing.T) { 214 | 215 | newVersion, err := Increment( 216 | Version{}, 217 | &Version{ 218 | Major: 1, 219 | PreReleaseTag: []interface{}{"alpha", "2018-12-31", int64(99)}, 220 | }, 221 | true, 222 | BREAKING, 223 | &PreReleaseOptions{ 224 | Label: "alpha.2018-12-31", 225 | AppendCounter: true, 226 | }, 227 | ) 228 | 229 | assert.Nil(t, err) 230 | assert.Equal( 231 | t, 232 | Version{ 233 | Major: 1, 234 | PreReleaseTag: []interface{}{"alpha", "2018-12-31", int64(100)}, 235 | }, 236 | newVersion, 237 | ) 238 | 239 | } 240 | 241 | func TestIncrementShouldCompareLengthsOfPreReleaseTagsIfExistingPreReleaseTagIsLongerThanNewPreReleaseTagAndCounterShouldBeAppended(t *testing.T) { 242 | 243 | newVersion, err := Increment( 244 | Version{}, 245 | &Version{ 246 | Major: 1, 247 | PreReleaseTag: []interface{}{"alpha", int64(1), int64(1)}, 248 | }, 249 | true, 250 | BREAKING, 251 | &PreReleaseOptions{ 252 | Label: "alpha", 253 | AppendCounter: true, 254 | }, 255 | ) 256 | 257 | assert.Nil(t, err) 258 | assert.Equal( 259 | t, 260 | Version{ 261 | Major: 1, 262 | PreReleaseTag: []interface{}{"alpha", int64(1)}, 263 | }, 264 | newVersion, 265 | ) 266 | 267 | } 268 | 269 | func TestIncrementShouldCompareLengthsOfPreReleaseTagsIfExistingPreReleaseTagIsShorterThanNewPreReleaseTagAndCounterShouldBeAppended(t *testing.T) { 270 | 271 | newVersion, err := Increment( 272 | Version{}, 273 | &Version{ 274 | Major: 1, 275 | PreReleaseTag: []interface{}{"alpha", int64(1)}, 276 | }, 277 | true, 278 | BREAKING, 279 | &PreReleaseOptions{ 280 | Label: "alpha.1", 281 | AppendCounter: true, 282 | }, 283 | ) 284 | 285 | assert.Nil(t, err) 286 | assert.Equal( 287 | t, 288 | Version{ 289 | Major: 1, 290 | PreReleaseTag: []interface{}{"alpha", int64(1), int64(1)}, 291 | }, 292 | newVersion, 293 | ) 294 | 295 | } 296 | 297 | func TestIncrementShouldIncrementVersionAndIgnoreExistingPreReleaseIfPreReleaseTagsDoNotMatch(t *testing.T) { 298 | 299 | newVersion, err := Increment( 300 | Version{}, 301 | &Version{ 302 | Major: 1, 303 | PreReleaseTag: []interface{}{"alpha", "2018-12-31", int64(99)}, 304 | }, 305 | true, 306 | BREAKING, 307 | &PreReleaseOptions{ 308 | Label: "alpha.2019-01-01", 309 | AppendCounter: true, 310 | }, 311 | ) 312 | 313 | assert.Nil(t, err) 314 | assert.Equal( 315 | t, 316 | Version{ 317 | Major: 1, 318 | PreReleaseTag: []interface{}{"alpha", "2019-01-01", int64(1)}, 319 | }, 320 | newVersion, 321 | ) 322 | 323 | } 324 | 325 | func TestIncrementShouldIncrementVersionAndIgnoreExistingPreReleaseIfExistingPreReleaseHasDifferentMajorVersion(t *testing.T) { 326 | 327 | newVersion, err := Increment( 328 | Version{}, 329 | &Version{ 330 | Major: 0, 331 | Minor: 1, 332 | Patch: 0, 333 | PreReleaseTag: []interface{}{"alpha", "2018-12-31", int64(99)}, 334 | }, 335 | true, 336 | BREAKING, 337 | &PreReleaseOptions{ 338 | Label: "alpha.2018-12-31", 339 | AppendCounter: true, 340 | }, 341 | ) 342 | 343 | assert.Nil(t, err) 344 | assert.Equal( 345 | t, 346 | Version{ 347 | Major: 1, 348 | Minor: 0, 349 | Patch: 0, 350 | PreReleaseTag: []interface{}{"alpha", "2018-12-31", int64(1)}, 351 | }, 352 | newVersion, 353 | ) 354 | 355 | } 356 | 357 | func TestIncrementShouldIncrementVersionAndIgnoreExistingPreReleaseIfExistingPreReleaseHasDifferentMinorVersion(t *testing.T) { 358 | 359 | newVersion, err := Increment( 360 | Version{}, 361 | &Version{ 362 | Major: 0, 363 | Minor: 0, 364 | Patch: 1, 365 | PreReleaseTag: []interface{}{"alpha", "2018-12-31", int64(99)}, 366 | }, 367 | false, 368 | NEW_FEATURE, 369 | &PreReleaseOptions{ 370 | Label: "alpha.2018-12-31", 371 | AppendCounter: true, 372 | }, 373 | ) 374 | 375 | assert.Nil(t, err) 376 | assert.Equal( 377 | t, 378 | Version{ 379 | Major: 0, 380 | Minor: 1, 381 | Patch: 0, 382 | PreReleaseTag: []interface{}{"alpha", "2018-12-31", int64(1)}, 383 | }, 384 | newVersion, 385 | ) 386 | 387 | } 388 | 389 | func TestIncrementShouldIncrementVersionAndIgnoreExistingPreReleaseIfExistingPreReleaseHasDifferentPatchVersion(t *testing.T) { 390 | 391 | newVersion, err := Increment( 392 | Version{}, 393 | &Version{ 394 | Major: 0, 395 | Minor: 0, 396 | Patch: 0, 397 | PreReleaseTag: []interface{}{"alpha", "2018-12-31", int64(99)}, 398 | }, 399 | false, 400 | FIX, 401 | &PreReleaseOptions{ 402 | Label: "alpha.2018-12-31", 403 | AppendCounter: true, 404 | }, 405 | ) 406 | 407 | assert.Nil(t, err) 408 | assert.Equal( 409 | t, 410 | Version{ 411 | Major: 0, 412 | Minor: 0, 413 | Patch: 1, 414 | PreReleaseTag: []interface{}{"alpha", "2018-12-31", int64(1)}, 415 | }, 416 | newVersion, 417 | ) 418 | 419 | } 420 | -------------------------------------------------------------------------------- /semver/tostring.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func (v *Version) ToString() string { 9 | releaseVersion := fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch) 10 | 11 | if len(v.PreReleaseTag) > 0 { 12 | stringTagElements := make([]string, 0, len(v.PreReleaseTag)) 13 | 14 | for _, tagElement := range v.PreReleaseTag { 15 | stringTagElements = append(stringTagElements, fmt.Sprintf("%v", tagElement)) 16 | } 17 | 18 | return fmt.Sprintf("%s-%s", releaseVersion, strings.Join(stringTagElements, ".")) 19 | } 20 | 21 | return releaseVersion 22 | } 23 | -------------------------------------------------------------------------------- /semver/tostring_test.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "testing" 6 | ) 7 | 8 | func TestVersionToStringShouldReturnWellFormedSemanticVersionStringWithoutPreReleaseTag(t *testing.T) { 9 | 10 | v := &Version{ 11 | Major: 1, 12 | Minor: 2, 13 | Patch: 3, 14 | } 15 | 16 | assert.Equal(t, "1.2.3", v.ToString()) 17 | 18 | } 19 | 20 | func TestVersionToStringShouldReturnWellFormedSemanticVersionStringWithPreReleaseTag(t *testing.T) { 21 | 22 | v := &Version{ 23 | Major: 1, 24 | Minor: 2, 25 | Patch: 3, 26 | PreReleaseTag: []interface{}{"alpha", int64(123)}, 27 | } 28 | 29 | assert.Equal(t, "1.2.3-alpha.123", v.ToString()) 30 | 31 | } 32 | -------------------------------------------------------------------------------- /semver/version.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "github.com/pkg/errors" 5 | "github.com/psanetra/git-semver/regex_utils" 6 | "regexp" 7 | "strconv" 8 | "strings" 9 | ) 10 | 11 | // source: https://github.com/semver/semver/issues/232#issuecomment-430840155 12 | var VersionRegex = regexp.MustCompile("^v?(?P0|[1-9]\\d*)\\.(?P0|[1-9]\\d*)\\.(?P0|[1-9]\\d*)(?P-(?P(0|[1-9]\\d*|\\d*[A-Za-z-][\\dA-Za-z-]*)(\\.(0|[1-9]\\d*|\\d*[A-Za-z-][\\dA-Za-z-]*))*))?(?P\\+(?P[\\dA-Za-z-]+(\\.[\\dA-Za-z-]*)*))?$") 13 | 14 | var VersionParsingError = errors.New("Could not parse version") 15 | 16 | var EmptyVersion = Version{} 17 | 18 | type Version struct { 19 | Major int 20 | Minor int 21 | Patch int 22 | // PreReleaseTag array can contain strings and int64s 23 | PreReleaseTag []interface{} 24 | } 25 | 26 | func ParseVersion(str string) (*Version, error) { 27 | 28 | submatches := regex_utils.SubmatchMap(VersionRegex, str) 29 | 30 | if submatches == nil { 31 | return nil, VersionParsingError 32 | } 33 | 34 | major, err := strconv.Atoi(submatches["Major"]) 35 | 36 | if err != nil { 37 | return nil, err 38 | } 39 | 40 | minor, err := strconv.Atoi(submatches["Minor"]) 41 | 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | patch, err := strconv.Atoi(submatches["Patch"]) 47 | 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | preReleaseTagStr := submatches["PreReleaseTag"] 53 | 54 | preReleaseTag, err := parsePreReleaseTag(preReleaseTagStr) 55 | 56 | if err != nil { 57 | return nil, errors.WithMessage(err, "Could not parse pre-release tag") 58 | } 59 | 60 | return &Version{ 61 | Major: major, 62 | Minor: minor, 63 | Patch: patch, 64 | PreReleaseTag: preReleaseTag, 65 | }, nil 66 | 67 | } 68 | 69 | func parsePreReleaseTag(str string) ([]interface{}, error) { 70 | 71 | if len(str) == 0 { 72 | return []interface{}{}, nil 73 | } 74 | 75 | parts := strings.Split(str, ".") 76 | 77 | preReleaseTag := make([]interface{}, 0, len(parts)+1) 78 | 79 | for _, part := range parts { 80 | 81 | parsedPart, err := strconv.ParseInt(part, 10, 64) 82 | 83 | if err != nil { 84 | numErr, ok := err.(*strconv.NumError) 85 | 86 | if !ok || numErr.Err != strconv.ErrSyntax { 87 | return nil, errors.WithMessage(err, "Could not parse part '"+part+"' as int64") 88 | } 89 | 90 | preReleaseTag = append(preReleaseTag, part) 91 | } else { 92 | preReleaseTag = append(preReleaseTag, parsedPart) 93 | } 94 | 95 | } 96 | 97 | return preReleaseTag, nil 98 | } 99 | 100 | func (v *Version) IsStable() bool { 101 | return v.Major != 0 102 | } 103 | 104 | func (v *Version) IsPreRelease() bool { 105 | return len(v.PreReleaseTag) > 0 106 | } 107 | -------------------------------------------------------------------------------- /semver/version_test.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "fmt" 5 | "github.com/stretchr/testify/assert" 6 | "testing" 7 | ) 8 | 9 | func TestParseVersion(t *testing.T) { 10 | 11 | version, err := ParseVersion("1.2.3") 12 | 13 | assert.NoError(t, err) 14 | assert.Equal(t, version, &Version{ 15 | Major: 1, 16 | Minor: 2, 17 | Patch: 3, 18 | PreReleaseTag: []interface{}{}, 19 | }) 20 | 21 | } 22 | 23 | func TestParseVersionWithVPrefix(t *testing.T) { 24 | 25 | version, err := ParseVersion("v1.2.3") 26 | 27 | assert.NoError(t, err) 28 | assert.Equal(t, version, &Version{ 29 | Major: 1, 30 | Minor: 2, 31 | Patch: 3, 32 | PreReleaseTag: []interface{}{}, 33 | }) 34 | 35 | } 36 | 37 | func TestParseVersionWithPreReleaseTag(t *testing.T) { 38 | 39 | version, err := ParseVersion("1.2.3-alpha") 40 | 41 | assert.NoError(t, err) 42 | assert.Equal(t, version, &Version{ 43 | Major: 1, 44 | Minor: 2, 45 | Patch: 3, 46 | PreReleaseTag: []interface{}{ 47 | "alpha", 48 | }, 49 | }) 50 | 51 | } 52 | 53 | func TestParseVersionWithNumericIdInPreReleaseTag(t *testing.T) { 54 | 55 | version, err := ParseVersion("1.2.3-alpha.4") 56 | 57 | assert.NoError(t, err) 58 | assert.Equal(t, version, &Version{ 59 | Major: 1, 60 | Minor: 2, 61 | Patch: 3, 62 | PreReleaseTag: []interface{}{ 63 | "alpha", 64 | int64(4), 65 | }, 66 | }) 67 | 68 | } 69 | 70 | func TestParseVersionIgnoresBuildMetadata(t *testing.T) { 71 | 72 | version, err := ParseVersion("1.2.3+mymetadata") 73 | 74 | assert.NoError(t, err) 75 | assert.Equal(t, version, &Version{ 76 | Major: 1, 77 | Minor: 2, 78 | Patch: 3, 79 | PreReleaseTag: []interface{}{}, 80 | }) 81 | 82 | } 83 | 84 | func TestParseVersionReturnsErrorOnInvalidSyntax(t *testing.T) { 85 | 86 | version, err := ParseVersion("1.2.3Invalid") 87 | 88 | assert.Error(t, err) 89 | assert.Nil(t, version) 90 | 91 | } 92 | 93 | func TestVersionRegexOnValidStrings(t *testing.T) { 94 | 95 | // source: https://github.com/semver/semver/issues/232#issuecomment-430813095 96 | validStrings := []string{ 97 | "0.0.4", 98 | "1.2.3", 99 | "10.20.30", 100 | "1.1.2-prerelease+meta", 101 | "1.1.2+meta", 102 | "1.1.2+meta-valid", 103 | "1.0.0-alpha", 104 | "1.0.0-beta", 105 | "1.0.0-alpha.beta", 106 | "1.0.0-alpha.beta.1", 107 | "1.0.0-alpha.1", 108 | "1.0.0-alpha0.valid", 109 | "1.0.0-alpha.0valid", 110 | "1.0.0-alpha-a.b-c-somethinglong+build.1-aef.1-its-okay", 111 | "1.0.0-rc.1+build.1", 112 | "2.0.0-rc.1+build.123", 113 | "1.2.3-beta", 114 | "10.2.3-DEV-SNAPSHOT", 115 | "1.2.3-SNAPSHOT-123", 116 | "1.0.0", 117 | "2.0.0", 118 | "1.1.7", 119 | "2.0.0+build.1848", 120 | "2.0.1-alpha.1227", 121 | "1.0.0-alpha+beta", 122 | "1.2.3----RC-SNAPSHOT.12.9.1--.12+788", 123 | "1.2.3----R-S.12.9.1--.12+meta", 124 | "1.2.3----RC-SNAPSHOT.12.9.1--.12", 125 | "1.0.0+0.build.1-rc.10000aaa-kk-0.1", 126 | "99999999999999999999999.999999999999999999.99999999999999999", 127 | "1.0.0-0A.is.legal", 128 | } 129 | 130 | for _, str := range validStrings { 131 | 132 | assert.True(t, VersionRegex.Match([]byte(str)), fmt.Sprintf("\"%s\" did not match", str)) 133 | 134 | } 135 | 136 | } 137 | 138 | func TestVersionRegexOnInvalidStrings(t *testing.T) { 139 | 140 | // source: https://github.com/semver/semver/issues/232#issuecomment-430813095 141 | invalidStrings := []string{ 142 | "1", 143 | "1.2", 144 | "1.2.3-0123", 145 | "1.2.3-0123.0123", 146 | "1.1.2+.123", 147 | "+invalid", 148 | "-invalid", 149 | "-invalid+invalid", 150 | "-invalid.01", 151 | "alpha", 152 | "alpha.beta", 153 | "alpha.beta.1", 154 | "alpha.1", 155 | "alpha+beta", 156 | "alpha_beta", 157 | "alpha.", 158 | "alpha..", 159 | "beta", 160 | "1.0.0-alpha_beta", 161 | "-alpha.", 162 | "1.0.0-alpha..", 163 | "1.0.0-alpha..1", 164 | "1.0.0-alpha...1", 165 | "1.0.0-alpha....1", 166 | "1.0.0-alpha.....1", 167 | "1.0.0-alpha......1", 168 | "1.0.0-alpha.......1", 169 | "01.1.1", 170 | "1.01.1", 171 | "1.1.01", 172 | "1.2", 173 | "1.2.3.DEV", 174 | "1.2-SNAPSHOT", 175 | "1.2.31.2.3----RC-SNAPSHOT.12.09.1--..12+788", 176 | "1.2-RC-SNAPSHOT", 177 | "-1.0.3-gamma+b7718", 178 | "+justmeta", 179 | "9.8.7+meta+meta", 180 | "9.8.7-whatever+meta+meta", 181 | "99999999999999999999999.999999999999999999.99999999999999999----RC-SNAPSHOT.12.09.1--------------------------------..12", 182 | } 183 | 184 | for _, str := range invalidStrings { 185 | 186 | assert.False(t, VersionRegex.Match([]byte(str)), fmt.Sprintf("\"%s\" did match", str)) 187 | 188 | } 189 | 190 | } 191 | 192 | func TestVersionIsStableIfMajorVersionIsNot0(t *testing.T) { 193 | 194 | v := &Version{ 195 | Major: 1, 196 | } 197 | 198 | assert.True( 199 | t, 200 | v.IsStable(), 201 | ) 202 | 203 | } 204 | 205 | func TestVersionIsUnstableIfMajorVersionIs0(t *testing.T) { 206 | 207 | v := &Version{ 208 | Major: 0, 209 | } 210 | 211 | assert.False( 212 | t, 213 | v.IsStable(), 214 | ) 215 | 216 | } 217 | -------------------------------------------------------------------------------- /version_log/version_log.go: -------------------------------------------------------------------------------- 1 | package version_log 2 | 3 | import ( 4 | "github.com/go-git/go-git/v5" 5 | "github.com/go-git/go-git/v5/plumbing" 6 | "github.com/go-git/go-git/v5/plumbing/object" 7 | "github.com/go-git/go-git/v5/plumbing/revlist" 8 | "github.com/pkg/errors" 9 | "github.com/psanetra/git-semver/git_utils" 10 | "github.com/psanetra/git-semver/latest" 11 | "github.com/psanetra/git-semver/logger" 12 | "github.com/psanetra/git-semver/semver" 13 | "sort" 14 | ) 15 | 16 | type VersionLogOptions struct { 17 | Workdir string 18 | Version *semver.Version 19 | ExcludePreReleaseCommits bool 20 | } 21 | 22 | // Returns all commits since the preceding version to options.Version 23 | func VersionLog(options VersionLogOptions) ([]*object.Commit, error) { 24 | 25 | repo, err := git.PlainOpenWithOptions(options.Workdir, &git.PlainOpenOptions{ 26 | DetectDotGit: true, 27 | }) 28 | 29 | if err != nil { 30 | return nil, errors.WithMessage(err, "Could not open git repository") 31 | } 32 | 33 | versions, err := git_utils.GetVersions(repo) 34 | 35 | if err != nil { 36 | return nil, errors.WithMessage(err, "Could not find Tags") 37 | } 38 | 39 | var targetVersionRef *plumbing.Reference 40 | 41 | if options.Version == nil { 42 | headRef, err := repo.Head() 43 | 44 | if err != nil { 45 | return nil, errors.WithMessage(err, "Could not find HEAD") 46 | } 47 | 48 | targetVersionRef = headRef 49 | } else { 50 | targetVersionRef, err = findTagForVersion(repo, options.Version.ToString()) 51 | 52 | if err != nil { 53 | return nil, err 54 | } 55 | } 56 | 57 | greatestPreceding := semver.FindGreatestPreceding(options.Version, versions, !options.ExcludePreReleaseCommits) 58 | 59 | var fromVersionTag *plumbing.Reference 60 | 61 | if greatestPreceding == nil { 62 | if targetVersionRef == nil { 63 | _, fromVersionTag, err = latest.FindLatestVersion(repo, -1, options.ExcludePreReleaseCommits) 64 | 65 | if err != nil { 66 | return nil, errors.WithMessage(err, "Could not find latest version") 67 | } 68 | } 69 | } else { 70 | fromVersionTag, err = findTagForVersion(repo, greatestPreceding.ToString()) 71 | 72 | if err != nil { 73 | return nil, err 74 | } 75 | } 76 | 77 | excludedCommits := make([]plumbing.Hash, 0, 1) 78 | 79 | // fromVersionTag may be null if there is no latest version 80 | if fromVersionTag != nil { 81 | excludedCommits = append(excludedCommits, fromVersionTag.Hash()) 82 | } 83 | 84 | // historyRange also contains other hashes than commit hashes (e.g. blob or tree hashes) 85 | historyRange, err := revlist.Objects( 86 | repo.Storer, 87 | []plumbing.Hash{ 88 | targetVersionRef.Hash(), 89 | }, 90 | excludedCommits, 91 | ) 92 | 93 | if err != nil { 94 | return nil, errors.WithMessage(err, "Could not find commits.") 95 | } 96 | 97 | commits := make([]*object.Commit, 0, len(historyRange)) 98 | 99 | for _, hash := range historyRange { 100 | commit, err := repo.CommitObject(hash) 101 | 102 | if err == plumbing.ErrObjectNotFound { 103 | // hash is not a commit object 104 | continue 105 | } 106 | 107 | if err != nil { 108 | return nil, errors.WithMessage(err, "Could not read commit "+hash.String()) 109 | } 110 | 111 | commits = append(commits, commit) 112 | } 113 | 114 | sort.Sort(git_utils.ByHistoryDesc(commits)) 115 | 116 | // Return most recent commits first 117 | return commits, nil 118 | } 119 | 120 | func findTagForVersion(repo *git.Repository, version string) (*plumbing.Reference, error) { 121 | tag, err := repo.Tag("v" + version) 122 | 123 | if err != nil { 124 | logger.Logger.Debugln("Could not find tag v"+version+":", err) 125 | 126 | tag, err = repo.Tag(version) 127 | 128 | if err != nil { 129 | return nil, errors.WithMessage(err, "Could not find tag "+version+" or v"+version) 130 | } 131 | } 132 | 133 | return tag, err 134 | } 135 | --------------------------------------------------------------------------------