From e40c532bf26d62fc7c1dbced41ff50a57bd39a5c Mon Sep 17 00:00:00 2001 From: Nathan Klick Date: Tue, 1 Sep 2026 12:49:19 -0500 Subject: [PATCH 1/2] fix: return no version instead of an error when no tag matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findLatestVersionTag` turned "no matching tags" into a fatal error, which made `latest` and `next` unusable on two ordinary repositories: * a repository which has not been released yet (no tags at all), and * a repository whose only tags are pre-releases, because the release lookup filters pre-releases out. `next` is affected in both cases even when a pre-release is requested, since `Next` always resolves the latest *release* tag before it looks at `--pre-release-tag`: $ git-semver next --stable=false --pre-release-tag=alpha --pre-release-counter level=fatal msg="Error while trying to find latest release version tag: no matching semantic version tags found" Both callers already treat a nil tag as "no previous version" and fall back to the empty version (0.0.0) — `version_log` even documents it — so return nil instead of an error, restoring the upstream behavior. The three integration tests covering repositories without tags were disabled for this regression; re-enable them. The two remaining disabled tests cover the reachability assertion this fork intentionally removed, so they stay disabled with an accurate reason. Signed-off-by: Nathan Klick Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016kVCwjXgKkUCtQz7n1Yitd --- .../LatestCmdIncludingPreReleasesTests.java | 2 - .../de/psanetra/gitsemver/LatestCmdTests.java | 2 - .../de/psanetra/gitsemver/NextCmdTests.java | 5 +- latest/latest.go | 8 +- latest/latest_test.go | 92 ++++++++++++++++ next/next_test.go | 103 ++++++++++++++++++ 6 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 next/next_test.go diff --git a/integration_tests/src/test/java/de/psanetra/gitsemver/LatestCmdIncludingPreReleasesTests.java b/integration_tests/src/test/java/de/psanetra/gitsemver/LatestCmdIncludingPreReleasesTests.java index 9c67c14..ffbfeec 100644 --- a/integration_tests/src/test/java/de/psanetra/gitsemver/LatestCmdIncludingPreReleasesTests.java +++ b/integration_tests/src/test/java/de/psanetra/gitsemver/LatestCmdIncludingPreReleasesTests.java @@ -2,7 +2,6 @@ import de.psanetra.gitsemver.containers.GitSemverContainer; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Disabled; import static org.assertj.core.api.Assertions.assertThat; @@ -68,7 +67,6 @@ public void shouldReturnVersionsNotReachableFromHEAD() { } @Test - @Disabled("Skipping as it requires a specific version from fork source") public void shouldReturnEmptyVersionOnRepoWithoutTags() { try (var container = new GitSemverContainer()) { container.start(); diff --git a/integration_tests/src/test/java/de/psanetra/gitsemver/LatestCmdTests.java b/integration_tests/src/test/java/de/psanetra/gitsemver/LatestCmdTests.java index 51eec5e..98ac459 100644 --- a/integration_tests/src/test/java/de/psanetra/gitsemver/LatestCmdTests.java +++ b/integration_tests/src/test/java/de/psanetra/gitsemver/LatestCmdTests.java @@ -2,7 +2,6 @@ import de.psanetra.gitsemver.containers.GitSemverContainer; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Disabled; import static org.assertj.core.api.Assertions.assertThat; @@ -47,7 +46,6 @@ public void shouldReturnVersionsNotReachableFromHEAD() { @Test - @Disabled("Skipping as it requires a specific version from fork source") public void shouldReturnEmptyVersionOnRepoWithoutTags() { try (var container = new GitSemverContainer()) { container.start(); diff --git a/integration_tests/src/test/java/de/psanetra/gitsemver/NextCmdTests.java b/integration_tests/src/test/java/de/psanetra/gitsemver/NextCmdTests.java index e531fbb..9aaff78 100644 --- a/integration_tests/src/test/java/de/psanetra/gitsemver/NextCmdTests.java +++ b/integration_tests/src/test/java/de/psanetra/gitsemver/NextCmdTests.java @@ -100,7 +100,7 @@ public void shouldConvertPrereleaseToRelease() { /** * In this case the next command can not calculate the next version based on the commits since the latest release. */ - @Disabled("Skipping as it requires a specific version from fork source") + @Disabled("This fork intentionally does not assert that the latest version tag is reachable from HEAD") @Test public void shouldReturnErrorCodeIfLatestVersionNotReachableFromHEAD() throws IOException, InterruptedException { try (var container = new GitSemverContainer()) { @@ -151,7 +151,6 @@ public void shouldNotReturnErrorCodeIfLatestVersionReachableFromDetachedHEAD() t } - @Disabled("Skipping as it requires a specific version from fork source") @Test public void shouldReturnFirstVersionOnRepoWithoutTags() { try (var container = new GitSemverContainer()) { @@ -252,7 +251,7 @@ public void shouldReturnNewVersionAfterMergeWithFeatureBranchPrallelToRelease() } - @Disabled("Skipping as it requires a specific version from fork source") + @Disabled("This fork intentionally does not assert that the latest version tag is reachable from HEAD") @Test public void shouldPanicIfCommitIsMissingOnShallowClone() { try (var container = new GitSemverContainer()) { diff --git a/latest/latest.go b/latest/latest.go index 023efff..ce6fd00 100644 --- a/latest/latest.go +++ b/latest/latest.go @@ -103,9 +103,13 @@ func findLatestVersionTag(repo *git.Repository, majorVersionFilter int, includeP foundTags = append(foundTags, tag) } - // If no valid tags were found + // If no valid tags were found the repository simply has no matching version yet. + // This is not an error: callers interpret a nil tag as "no previous version" and + // fall back to the empty version (0.0.0). Returning an error here would break + // repositories which have not been released yet, as well as repositories whose + // only tags are pre-releases when pre-releases are excluded. if len(foundTags) == 0 { - return nil, errors.New("no matching semantic version tags found") + return nil, nil } // Find the highest version tag diff --git a/latest/latest_test.go b/latest/latest_test.go index 41568f2..227a06a 100644 --- a/latest/latest_test.go +++ b/latest/latest_test.go @@ -2,8 +2,12 @@ package latest import ( "github.com/PandasWhoCode/git-semver/semver" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing/object" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "testing" + "time" ) func Test_tagNameToVersion_should_return_version(t *testing.T) { @@ -35,3 +39,91 @@ func Test_tagNameToVersion_should_return_version_if_tag_has_v_prefix(t *testing. version, ) } + +func Test_FindLatestVersion_should_return_no_version_if_repository_has_no_tags(t *testing.T) { + repo, _ := initRepo(t) + + version, tag, err := FindLatestVersion(repo, -1, false) + + require.NoError(t, err) + assert.Nil(t, version) + assert.Nil(t, tag) +} + +func Test_FindLatestVersion_should_return_no_version_if_repository_only_has_pre_release_tags(t *testing.T) { + repo, _ := initRepo(t, "v0.1.0-alpha.1", "v0.1.0-alpha.2") + + version, tag, err := FindLatestVersion(repo, -1, false) + + require.NoError(t, err) + assert.Nil(t, version) + assert.Nil(t, tag) +} + +func Test_FindLatestVersion_should_return_latest_pre_release_if_pre_releases_are_included(t *testing.T) { + repo, _ := initRepo(t, "v0.1.0-alpha.1", "v0.1.0-alpha.2") + + version, tag, err := FindLatestVersion(repo, -1, true) + + require.NoError(t, err) + assert.Equal(t, "0.1.0-alpha.2", version.ToString()) + assert.Equal(t, "v0.1.0-alpha.2", tag.Name().Short()) +} + +func Test_FindLatestVersion_should_ignore_pre_release_tags_if_a_release_tag_exists(t *testing.T) { + repo, _ := initRepo(t, "v0.1.0", "v0.2.0-alpha.1") + + version, tag, err := FindLatestVersion(repo, -1, false) + + require.NoError(t, err) + assert.Equal(t, "0.1.0", version.ToString()) + assert.Equal(t, "v0.1.0", tag.Name().Short()) +} + +func Test_Latest_should_return_empty_version_if_repository_has_no_tags(t *testing.T) { + _, workdir := initRepo(t) + + version, err := Latest(LatestOptions{Workdir: workdir}) + + require.NoError(t, err) + assert.Equal(t, "0.0.0", version.ToString()) +} + +func Test_Latest_should_return_empty_version_if_repository_only_has_pre_release_tags(t *testing.T) { + _, workdir := initRepo(t, "v0.1.0-alpha.1") + + version, err := Latest(LatestOptions{Workdir: workdir}) + + require.NoError(t, err) + assert.Equal(t, "0.0.0", version.ToString()) +} + +// initRepo creates a repository with a single commit which is tagged with all given tag names. +func initRepo(t *testing.T, tagNames ...string) (*git.Repository, string) { + t.Helper() + + workdir := t.TempDir() + + repo, err := git.PlainInit(workdir, false) + assert.NoError(t, err) + + worktree, err := repo.Worktree() + assert.NoError(t, err) + + commit, err := worktree.Commit("feat: initial commit", &git.CommitOptions{ + Author: &object.Signature{ + Name: "git-semver", + Email: "git-semver@example.com", + When: time.Now(), + }, + AllowEmptyCommits: true, + }) + assert.NoError(t, err) + + for _, tagName := range tagNames { + _, err = repo.CreateTag(tagName, commit, nil) + assert.NoError(t, err) + } + + return repo, workdir +} diff --git a/next/next_test.go b/next/next_test.go new file mode 100644 index 0000000..9e43d77 --- /dev/null +++ b/next/next_test.go @@ -0,0 +1,103 @@ +package next + +import ( + "github.com/PandasWhoCode/git-semver/semver" + "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" + "testing" + "time" +) + +func Test_Next_should_return_first_version_if_repository_has_no_tags(t *testing.T) { + workdir := initRepo(t, commit{message: "feat: initial commit"}) + + version, err := Next(NextOptions{ + Workdir: workdir, + Stable: true, + MajorVersionFilter: -1, + }) + + require.NoError(t, err) + assert.Equal(t, "1.0.0", version.ToString()) +} + +func Test_Next_should_return_next_version_if_repository_only_has_pre_release_tags(t *testing.T) { + workdir := initRepo( + t, + commit{message: "feat: initial commit", tags: []string{"v0.1.0-alpha.1"}}, + commit{message: "fix: a fix"}, + ) + + version, err := Next(NextOptions{ + Workdir: workdir, + Stable: false, + MajorVersionFilter: -1, + }) + + require.NoError(t, err) + assert.Equal(t, "0.1.0", version.ToString()) +} + +func Test_Next_should_increment_pre_release_counter_if_repository_only_has_pre_release_tags(t *testing.T) { + workdir := initRepo( + t, + commit{message: "feat: initial commit", tags: []string{"v0.1.0-alpha.1"}}, + commit{message: "fix: a fix", tags: []string{"v0.1.0-alpha.2"}}, + commit{message: "fix: another fix"}, + ) + + version, err := Next(NextOptions{ + Workdir: workdir, + Stable: false, + MajorVersionFilter: -1, + PreReleaseOptions: semver.PreReleaseOptions{ + Label: "alpha", + AppendCounter: true, + }, + }) + + require.NoError(t, err) + assert.Equal(t, "0.1.0-alpha.3", version.ToString()) +} + +type commit struct { + message string + tags []string +} + +// initRepo creates a repository containing the given commits and returns its working directory. +func initRepo(t *testing.T, commits ...commit) string { + t.Helper() + + workdir := t.TempDir() + + repo, err := git.PlainInit(workdir, false) + assert.NoError(t, err) + + worktree, err := repo.Worktree() + assert.NoError(t, err) + + for i, c := range commits { + var hash plumbing.Hash + + hash, err = worktree.Commit(c.message, &git.CommitOptions{ + Author: &object.Signature{ + Name: "git-semver", + Email: "git-semver@example.com", + When: time.Now().Add(time.Duration(i) * time.Second), + }, + AllowEmptyCommits: true, + }) + assert.NoError(t, err) + + for _, tagName := range c.tags { + _, err = repo.CreateTag(tagName, hash, nil) + assert.NoError(t, err) + } + } + + return workdir +} From cbafe3af932f7b548ed019d374cc82ebf0ea91a4 Mon Sep 17 00:00:00 2001 From: Nathan Klick Date: Tue, 1 Sep 2026 15:05:29 -0500 Subject: [PATCH 2/2] fix: do not require the git binary to look up version tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `findLatestVersionTag` shelled out to `git rev-list --tags --max-count=1` and discarded the result — its own comment says "We no longer need to capture the output of the command". The call had no effect on the tags that are considered, since those come from `repo.Tags()`, but it did make the lookup fail outright wherever the git binary is missing or the repository has no worktree: exec: "git": executable file not found in $PATH That is not hypothetical: the build stage of this repository's own Dockerfile runs on `golang:1.24-alpine`, which does not ship git, so any test touching this code path fails during `docker build`. Drop the dead exec so the lookup relies on go-git alone. Signed-off-by: Nathan Klick Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_016kVCwjXgKkUCtQz7n1Yitd --- latest/latest.go | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/latest/latest.go b/latest/latest.go index ce6fd00..e618c9e 100644 --- a/latest/latest.go +++ b/latest/latest.go @@ -7,7 +7,6 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/pkg/errors" "io" - "os/exec" "strings" ) @@ -52,22 +51,8 @@ func FindLatestVersion(repo *git.Repository, majorVersionFilter int, preRelease } func findLatestVersionTag(repo *git.Repository, majorVersionFilter int, includePreReleases bool) (*plumbing.Reference, error) { - // Use git rev-list to get the latest tag from all branches, not just the current branch - cmd := exec.Command("git", "rev-list", "--tags", "--max-count=1") - worktree, err := repo.Worktree() - if err != nil { - return nil, err - } - - cmd.Dir = worktree.Filesystem.Root() // Use the working directory for git commands - // We no longer need to capture the output of the command - _, err = cmd.Output() - - if err != nil { - return nil, err - } - - // Retrieve the tags from the repository + // Retrieve the tags from the repository. All tags are considered, not just the ones + // reachable from the current branch. tagIter, err := repo.Tags() if err != nil { return nil, err