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
57 changes: 36 additions & 21 deletions internal/git/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,27 @@ import (
"errors"
"fmt"
"os/exec"
"regexp"
"strings"
"time"

"github.com/stablekernel/cascade/internal/taggrammar"
)

// versionTagRegex matches cascade's canonical version tags: vX.Y.Z, optionally
// with an -rc.N prerelease and a nested .hotfix.M segment. It is kept in lockstep
// with the parser in internal/version (see version.Parse). The git package cannot
// import internal/version directly because that package depends, transitively
// through internal/changelog, on this one; TestIsValidVersionTag_InSyncWithVersionParse
// asserts the two stay in agreement.
var versionTagRegex = regexp.MustCompile(`^([a-zA-Z]*)(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+)(?:\.hotfix\.(\d+))?)?$`)

// IsValidVersionTag reports whether tag is a well-formed cascade version tag.
// Tags that do not match (for example a vX.Y.Z-dryrun.N exercise tag, a foreign
// "nightly" or "latest" tag, or a typo) are invisible to version discovery so
// they can never be mistaken for the latest released or prereleased version.
// IsValidVersionTag reports whether tag is a well-formed cascade version tag
// under the default grammar. Tags that do not match (for example a
// vX.Y.Z-dryrun.N exercise tag, a foreign "nightly" or "latest" tag, or a typo)
// are invisible to version discovery so they can never be mistaken for the
// latest released or prereleased version.
func IsValidVersionTag(tag string) bool {
return versionTagRegex.MatchString(tag)
return IsValidVersionTagSpec(taggrammar.Default(), tag)
}

// IsValidVersionTagSpec reports whether tag is a well-formed version tag under
// spec. Version discovery and git both read this from the canonical grammar, so
// the predicate can never drift from the parser the way a hand-copied regex
// once could.
func IsValidVersionTagSpec(spec taggrammar.Spec, tag string) bool {
return spec.IsVersionTag(tag)
}

// GetChangedFiles returns the list of files changed between two commits
Expand Down Expand Up @@ -490,7 +492,18 @@ func remoteRefAlreadyGone(out []byte) bool {
// process working directory points elsewhere; an empty dir falls back to the
// process working directory.
func GetLatestReleaseTag(dir, prefix string) (string, string, error) {
cmd := exec.Command("git", "tag", "-l", prefix+"*", "--sort=-v:refname")
spec := taggrammar.Default()
spec.Prefix = prefix
return GetLatestReleaseTagSpec(dir, spec)
}

// GetLatestReleaseTagSpec is GetLatestReleaseTag under a caller-supplied grammar.
// The prefix glob widens the candidate set to every tag that leads with the
// grammar's prefix; the release predicate then narrows it to a published
// release, so a custom pre-release token is classified correctly rather than
// slipping through a hard-wired "-rc." check.
func GetLatestReleaseTagSpec(dir string, spec taggrammar.Spec) (string, string, error) {
cmd := exec.Command("git", "tag", "-l", spec.Prefix+"*", "--sort=-v:refname")
cmd.Dir = dir
output, err := cmd.Output()
if err != nil {
Expand All @@ -502,15 +515,17 @@ func GetLatestReleaseTag(dir, prefix string) (string, string, error) {
return "", "", nil
}

// Find the first published release: a valid cascade version with no -rc
// suffix. Filtering through IsValidVersionTag keeps non-version tags (such as
// a vX.Y.Z-dryrun.N exercise tag, which also lacks an -rc suffix) from being
// mistaken for a release.
// Find the first published release: a tag that parses as a version under the
// grammar and carries no pre-release segment. Parsing through the grammar
// keeps non-version tags (such as a vX.Y.Z-dryrun.N exercise tag) out, and
// the no-pre-release check keeps pre-releases (rc, beta, or any custom token)
// from being mistaken for a release.
for _, tag := range tags {
if !IsValidVersionTag(tag) {
parsed, ok := spec.Parse(tag)
if !ok {
continue
}
if !strings.Contains(tag, "-rc.") {
if parsed.PreRelease < 0 {
// Get the SHA for this tag
cmd = exec.Command("git", "rev-list", "-n", "1", tag)
cmd.Dir = dir
Expand Down
25 changes: 25 additions & 0 deletions internal/git/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"strings"
"testing"
"time"

"github.com/stablekernel/cascade/internal/taggrammar"
)

// newScratchRepo initializes a git repository in a temp directory, changes the
Expand Down Expand Up @@ -705,3 +707,26 @@ func TestIsValidVersionTag(t *testing.T) {
})
}
}

// TestGetLatestReleaseTag_CustomTokenNotMistakenForRelease proves the release
// classifier narrows on "parses with no pre-release" rather than a hard-wired
// "-rc." substring. Under a beta grammar, v1.2.3-beta.1 is a pre-release and
// must be skipped, so the lookup returns the real release v1.2.2.
func TestGetLatestReleaseTag_CustomTokenNotMistakenForRelease(t *testing.T) {
newScratchRepo(t)
commitFile(t, "a.txt", "one", "first commit")

tagHead(t, "v1.2.2") // published release
tagHead(t, "v1.2.3-beta.1") // pre-release under the beta grammar

spec := taggrammar.Default()
spec.PreReleaseToken = "beta"

got, _, err := GetLatestReleaseTagSpec("", spec)
if err != nil {
t.Fatalf("GetLatestReleaseTagSpec() unexpected error: %v", err)
}
if got != "v1.2.2" {
t.Errorf("GetLatestReleaseTagSpec() = %q, want %q (beta pre-release must not count as a release)", got, "v1.2.2")
}
}
24 changes: 11 additions & 13 deletions internal/git/version_tag_sync_test.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
package git_test
package git

import (
"testing"

"github.com/stablekernel/cascade/internal/git"
"github.com/stablekernel/cascade/internal/version"
"github.com/stablekernel/cascade/internal/taggrammar"
)

// TestIsValidVersionTag_InSyncWithVersionParse guards against the git package's
// local version predicate drifting from the canonical parser in internal/version.
// The git package cannot import internal/version directly (that would create an
// import cycle through internal/changelog), so this external test asserts the two
// agree across a representative corpus of tag strings.
func TestIsValidVersionTag_InSyncWithVersionParse(t *testing.T) {
// TestIsValidVersionTag_MatchesDefaultGrammar pins the git predicate to the
// canonical grammar. Both the git package and version discovery now read tag
// shape from internal/taggrammar, so the old cross-package drift class is gone;
// this test keeps a representative corpus honest against the default spec.
func TestIsValidVersionTag_MatchesDefaultGrammar(t *testing.T) {
spec := taggrammar.Default()
corpus := []string{
"v1.2.3",
"v0.5.1",
Expand Down Expand Up @@ -41,10 +40,9 @@ func TestIsValidVersionTag_InSyncWithVersionParse(t *testing.T) {
for _, tag := range corpus {
tag := tag
t.Run(tag, func(t *testing.T) {
_, err := version.Parse(tag)
wantValid := err == nil
if got := git.IsValidVersionTag(tag); got != wantValid {
t.Errorf("IsValidVersionTag(%q) = %v, but version.Parse success = %v (predicate drifted from canonical regex)", tag, got, wantValid)
want := spec.IsVersionTag(tag)
if got := IsValidVersionTag(tag); got != want {
t.Errorf("IsValidVersionTag(%q) = %v, want %v (predicate drifted from the canonical grammar)", tag, got, want)
}
})
}
Expand Down
Loading