Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Go to the repository and run `get-next-version`. The tool will analyze the histo

```shell
$ get-next-version
1.2.3
```

Optionally, you may hand over the `--repository` (or short `-r`) flag to specify the path to the repository you want to analyze, if it is not in the current working directory.
Expand All @@ -35,21 +36,49 @@ $ get-next-version --repository <PATH>

If you need to prefix the version, you can use the `--prefix` (or short `-p`) flag. Note that the prefix must be a valid tag name on its own.

By default, output will be printed to the console in a human-readable format. If you want to print the output in a machine-readable format, you can use the `--target` (or short `-t`) flag:
By default, `get-next-version` prints nothing but the bare version string to stdout, which makes it easy to use in shell scripts. Use the `--target` (or short `-t`) flag to select a different output target:

```shell
# Print output in JSON format
# Print the bare version string to stdout (the default)
$ get-next-version --target version

# Print output in JSON format to stdout
$ get-next-version --target json

# Write output to the GITHUB_OUTPUT file in GitHub Action format (see https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#setting-an-output-parameter)
$ get-next-version --target github-action
```

Errors are always written to stderr, so they never interfere with the output on stdout.

## How the next version is determined

`get-next-version` walks the history backwards, starting at `HEAD`, until it finds a commit that is tagged with a version. That version is the base version, and the next version is the base version raised by the most significant change among the commits that came after it.

Two consequences are worth knowing about:

**The base version is the most recent release reachable from `HEAD`, not the highest tag in the repository.** On a branch that was created before the latest release, the result can therefore be lower than the latest release, and it may even be a version that already exists:

```
main: chore (1.0.0) → fix (1.0.1) → feat (1.1.0)
feature: fix: Correct a typo

$ get-next-version
1.0.1 # already exists as a tag, and is lower than 1.1.0
```

This is intended, since it is the correct behavior for maintenance branches, but if you release from branches that fork before the latest release, you may want to verify the result before using it.

**If no tagged commit is found at all, the base version is `0.0.0`.** This is what makes the first release work, but it also means that a repository whose tags have not been fetched silently yields a much lower version than expected.

Since both of these depend on the full history being available, `get-next-version` needs a complete clone including all tags. If the history is truncated, it will tell you so.

## Using the GitHub Action

For convenience, you may use the GitHub Action when running `get-next-version` inside a workflow on GitHub.

**⚠️ When cloning the repository, make sure to set the `fetch-depth` option to `0`, otherwise `get-next-version` will not be able to analyze the history of the repository!**
**⚠️ When cloning the repository, make sure to set the `fetch-depth` option to `0`, otherwise `get-next-version` will not be able to analyze the history of the repository!** This also fetches the tags, which are required to determine the base version. Without them, `get-next-version` either fails with an explicit error, or starts from `0.0.0` and returns a version that is much lower than expected.

**⚠️ The action uses the parameter `target=github-action` by default, which will not print any human-readable output, but only write the output to the GITHUB_OUTPUT file.**

Expand Down
43 changes: 23 additions & 20 deletions cli/root.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
package cli

import (
"fmt"
"strings"

"github.com/Masterminds/semver"

gogit "github.com/go-git/go-git/v5"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
"github.com/thenativeweb/get-next-version/conventionalcommits"
"github.com/thenativeweb/get-next-version/git"
Expand Down Expand Up @@ -37,73 +36,77 @@ var RootCommand = &cobra.Command{
Use: "get-next-version",
Short: "Get the next version according for semantic versioning",
Long: "Get the next version according for semantic versioning.",
Run: func(_ *cobra.Command, _ []string) {
RunE: func(command *cobra.Command, _ []string) error {
// From here on, errors are caused by the repository or the environment,
// not by incorrect usage, so printing the usage would only add noise.
command.SilenceUsage = true

validTargets := []string{
"github-action",
"json",
"version",
}

if isValid, prefixValidationError := util.IsValidVersionPrefix(rootPrefixFlag); !isValid {
log.Fatal().Msgf("invalid version prefix %+q", prefixValidationError)
return fmt.Errorf("invalid version prefix %+q", prefixValidationError)
}

if !slices.Contains(validTargets, rootTargetFlag) {
log.Fatal().Msg("invalid target")
return fmt.Errorf("invalid target %q, must be one of %s", rootTargetFlag, strings.Join(validTargets, ", "))
}

classifier := createTypeClassifier()

repository, err := gogit.PlainOpen(rootRepositoryFlag)
if err != nil {
log.Fatal().Msg(err.Error())
return fmt.Errorf("could not open repository: %w", err)
}

var nextVersion semver.Version
var hasNextVersion bool
result, err := git.GetConventionalCommitTypesSinceLastRelease(repository, classifier)
if err != nil {
log.Fatal().Msg(err.Error())
} else {
nextVersion, hasNextVersion = versioning.CalculateNextVersion(result.LatestReleaseVersion, result.ConventionalCommitTypes)
return err
}

err = target.WriteOutput(nextVersion, hasNextVersion, rootTargetFlag, rootPrefixFlag)
if err != nil {
log.Fatal().Err(err).Msg("could not write output")
nextVersion, hasNextVersion := versioning.CalculateNextVersion(result.LatestReleaseVersion, result.ConventionalCommitTypes)

if err := target.WriteOutput(nextVersion, hasNextVersion, rootTargetFlag, rootPrefixFlag); err != nil {
return fmt.Errorf("could not write output: %w", err)
}

return nil
},
}

func createTypeClassifier() *conventionalcommits.TypeClassifier {
var choreTypes, fixTypes, featureTypes []string

if rootChorePrefixesFlag != "" {
choreTypes = parseCommaSeparatedPrefixes(rootChorePrefixesFlag)
}

if rootFixPrefixesFlag != "" {
fixTypes = parseCommaSeparatedPrefixes(rootFixPrefixesFlag)
}

if rootFeaturePrefixesFlag != "" {
featureTypes = parseCommaSeparatedPrefixes(rootFeaturePrefixesFlag)
}

return conventionalcommits.NewTypeClassifierWithCustomPrefixes(choreTypes, fixTypes, featureTypes)
}

func parseCommaSeparatedPrefixes(input string) []string {
if input == "" {
return nil
}

var result []string
for _, prefix := range strings.Split(input, ",") {
trimmed := strings.TrimSpace(prefix)
if trimmed != "" {
result = append(result, trimmed)
}
}

return result
}
12 changes: 12 additions & 0 deletions git/commits.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ type ConventionalCommitTypesResult struct {

var ErrNoCommitsFound = errors.New("no commits found")

var ErrShallowRepository = errors.New(
"repository is a shallow clone and does not contain enough history; " +
"run `git fetch --unshallow`, or set `fetch-depth: 0` if you are using actions/checkout",
)

func GetConventionalCommitTypesSinceLastRelease(repository *git.Repository, classifier *conventionalcommits.TypeClassifier) (ConventionalCommitTypesResult, error) {
tags, err := GetAllSemVerTags(repository)
if err != nil {
Expand Down Expand Up @@ -58,6 +63,13 @@ func GetConventionalCommitTypesSinceLastRelease(repository *git.Repository, clas
currentCommit, currentCommitErr = commitIterator.Next()
}

if latestReleaseVersion == nil {
isShallow, shallowErr := IsShallow(repository)
if shallowErr == nil && isShallow {
return ConventionalCommitTypesResult{}, ErrShallowRepository
}
}

if currentCommitErr != nil {
if currentCommitErr != io.EOF {
return ConventionalCommitTypesResult{}, currentCommitErr
Expand Down
84 changes: 84 additions & 0 deletions git/commits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (

"github.com/Masterminds/semver"
gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thenativeweb/get-next-version/conventionalcommits"
Expand Down Expand Up @@ -168,3 +170,85 @@ func TestGetConventionalCommitTypesSinceLatestRelease(t *testing.T) {
assert.ElementsMatch(t, test.expectedConventionalCommitTypes, actual.ConventionalCommitTypes)
}
}

func TestGetConventionalCommitTypesSinceLatestReleaseInShallowRepository(t *testing.T) {
setUpShallowRepository := func(t *testing.T, commitHistory []commit) *gogit.Repository {
repository, err := testutil.SetUpInMemoryRepository()
require.NoError(t, err)

worktree, err := repository.Worktree()
require.NoError(t, err)

for _, currentCommit := range commitHistory {
_, err := worktree.Commit(currentCommit.message, testutil.CreateCommitOptions())
require.NoError(t, err)

if currentCommit.tag == "" {
continue
}

head, err := repository.Head()
require.NoError(t, err)

_, err = repository.CreateTag(currentCommit.tag, head.Hash(), nil)
require.NoError(t, err)
}

// Simulate the truncated history of a shallow clone by marking the
// oldest known commit as a shallow boundary.
commits, err := repository.Log(&gogit.LogOptions{Order: gogit.LogOrderCommitterTime})
require.NoError(t, err)

var oldestCommitHash plumbing.Hash
require.NoError(t, commits.ForEach(func(currentCommit *object.Commit) error {
oldestCommitHash = currentCommit.Hash
return nil
}))

require.NoError(t, repository.Storer.SetShallow([]plumbing.Hash{oldestCommitHash}))

return repository
}

classifier := conventionalcommits.NewTypeClassifier()

t.Run("returns an error if no release tag is within the truncated history", func(t *testing.T) {
repository := setUpShallowRepository(t, []commit{
{message: "chore: Do something", tag: ""},
{message: "feat: Do something else", tag: ""},
})

_, err := git.GetConventionalCommitTypesSinceLastRelease(repository, classifier)

assert.ErrorIs(t, err, git.ErrShallowRepository)
})

t.Run("returns the result if a release tag is within the truncated history", func(t *testing.T) {
repository := setUpShallowRepository(t, []commit{
{message: "chore: Last release", tag: "1.0.0"},
{message: "feat: Do something", tag: ""},
})

actual, err := git.GetConventionalCommitTypesSinceLastRelease(repository, classifier)

require.NoError(t, err)
assert.True(t, semver.MustParse("1.0.0").Equal(actual.LatestReleaseVersion))
assert.ElementsMatch(t, []conventionalcommits.Type{conventionalcommits.Feature}, actual.ConventionalCommitTypes)
})

t.Run("does not report a complete repository without any release tags as shallow", func(t *testing.T) {
repository, err := testutil.SetUpInMemoryRepository()
require.NoError(t, err)

worktree, err := repository.Worktree()
require.NoError(t, err)

_, err = worktree.Commit("feat: Do something", testutil.CreateCommitOptions())
require.NoError(t, err)

actual, err := git.GetConventionalCommitTypesSinceLastRelease(repository, classifier)

require.NoError(t, err)
assert.True(t, semver.MustParse("0.0.0").Equal(actual.LatestReleaseVersion))
})
}
14 changes: 14 additions & 0 deletions git/shallow.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package git

import (
"github.com/go-git/go-git/v5"
)

func IsShallow(repository *git.Repository) (bool, error) {
shallowCommits, err := repository.Storer.Shallow()
if err != nil {
return false, err
}

return len(shallowCommits) > 0, nil
}
43 changes: 43 additions & 0 deletions git/shallow_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package git_test

import (
"testing"

"github.com/go-git/go-git/v5/plumbing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thenativeweb/get-next-version/git"
"github.com/thenativeweb/get-next-version/testutil"
)

func TestIsShallow(t *testing.T) {
t.Run("returns false for a repository with a complete history", func(t *testing.T) {
repository, err := testutil.SetUpInMemoryRepository()
require.NoError(t, err)

isShallow, err := git.IsShallow(repository)
assert.NoError(t, err)
assert.False(t, isShallow)
})

t.Run("returns true for a repository with a truncated history", func(t *testing.T) {
repository, err := testutil.SetUpInMemoryRepository()
require.NoError(t, err)

worktree, err := repository.Worktree()
require.NoError(t, err)

_, err = worktree.Commit("chore: Do something", testutil.CreateCommitOptions())
require.NoError(t, err)

head, err := repository.Head()
require.NoError(t, err)

err = repository.Storer.SetShallow([]plumbing.Hash{head.Hash()})
require.NoError(t, err)

isShallow, err := git.IsShallow(repository)
assert.NoError(t, err)
assert.True(t, isShallow)
})
}
3 changes: 0 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ require (
github.com/Masterminds/semver v1.5.0
github.com/go-git/go-billy/v5 v5.9.1
github.com/go-git/go-git/v5 v5.19.2
github.com/mattn/go-isatty v0.0.24
github.com/rs/zerolog v1.35.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.12.0
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f
Expand All @@ -26,7 +24,6 @@ require (
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
Expand Down
6 changes: 0 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
Expand All @@ -64,8 +60,6 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
Expand Down
Loading