summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-28 10:20:02 +0300
committerPaul Buetow <paul@buetow.org>2026-05-28 10:20:02 +0300
commit61ebe59a088af0f758115e63340552f3bbac7c19 (patch)
tree1e86f7e8a0ffb487b4831b1cedd414d7e336a9fd
parenta2242a4f6e65434701e23827e02da71590365a58 (diff)
refactor(version): unify version tag detection across packages (gq)
-rw-r--r--internal/cli/release.go13
-rw-r--r--internal/cli/release_test.go2
-rw-r--r--internal/release/release.go13
-rw-r--r--internal/release/release_tags_test.go54
-rw-r--r--internal/showcase/metadata.go42
-rw-r--r--internal/showcase/metadata_test.go5
-rw-r--r--internal/version/version_tag.go11
-rw-r--r--internal/version/version_tag_test.go37
8 files changed, 114 insertions, 63 deletions
diff --git a/internal/cli/release.go b/internal/cli/release.go
index da3f2e9..b5300c6 100644
--- a/internal/cli/release.go
+++ b/internal/cli/release.go
@@ -5,22 +5,13 @@ import (
"fmt"
"os"
"path/filepath"
- "regexp"
"strings"
"codeberg.org/snonux/gitsyncer/internal/config"
"codeberg.org/snonux/gitsyncer/internal/release"
+ "codeberg.org/snonux/gitsyncer/internal/version"
)
-// isVersionTag checks if a tag name is a version tag
-// Supports formats: vX.Y.Z, vX.Y, vX, X.Y.Z, X.Y, X
-func isVersionTag(tag string) bool {
- // Pattern matches version tags with optional 'v' prefix
- pattern := `^v?\d+(\.\d+)?(\.\d+)?$`
- matched, _ := regexp.MatchString(pattern, tag)
- return matched
-}
-
// HandleCheckReleases checks for version tags without releases and creates them with confirmation
func HandleCheckReleases(cfg *config.Config, flags *Flags) int {
// Get all repositories from work directory
@@ -392,7 +383,7 @@ func processUpdateReleasesForTarget(
fmt.Printf("\n Updating existing %s releases...\n", target.name)
for _, tag := range existingReleases {
- if !isVersionTag(tag) {
+ if !version.IsVersionTag(tag) {
continue
}
diff --git a/internal/cli/release_test.go b/internal/cli/release_test.go
index 5712530..12876ae 100644
--- a/internal/cli/release_test.go
+++ b/internal/cli/release_test.go
@@ -363,7 +363,7 @@ func TestProcessUpdateReleasesForTarget_UsesCachedAIAndSkipsNonVersionTags(t *te
name: "GitHub",
owner: "owner",
getReleases: func(_ string, _ string) ([]string, error) {
- return []string{"latest", "v1.0.0"}, nil
+ return []string{"latest", "1-beta", "v1.0.0"}, nil
},
updateRelease: func(_ string, _ string, tag string, notes string) error {
if notes != "cached ai notes" {
diff --git a/internal/release/release.go b/internal/release/release.go
index 9ddf563..da2c221 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -8,11 +8,11 @@ import (
"net/http"
"os"
"os/exec"
- "regexp"
"sort"
"strings"
"codeberg.org/snonux/gitsyncer/internal/httpclient"
+ "codeberg.org/snonux/gitsyncer/internal/version"
)
// Tag represents a git tag
@@ -117,15 +117,6 @@ func (m *Manager) EnsureCodebergReleasesEnabled(owner, repo string) error {
return nil
}
-// isVersionTag checks if a tag name is a version tag
-// Supports formats: vX.Y.Z, vX.Y, vX, X.Y.Z, X.Y, X
-func isVersionTag(tag string) bool {
- // Pattern matches version tags with optional 'v' prefix
- pattern := `^v?\d+(\.\d+)?(\.\d+)?$`
- matched, _ := regexp.MatchString(pattern, tag)
- return matched
-}
-
// GetLocalTags returns all version tags from the local git repository
func (m *Manager) GetLocalTags(repoPath string) ([]string, error) {
cmd := exec.Command("git", "-C", repoPath, "tag", "--list")
@@ -139,7 +130,7 @@ func (m *Manager) GetLocalTags(repoPath string) ([]string, error) {
for _, tag := range tags {
tag = strings.TrimSpace(tag)
- if tag != "" && isVersionTag(tag) {
+ if tag != "" && version.IsVersionTag(tag) {
versionTags = append(versionTags, tag)
}
}
diff --git a/internal/release/release_tags_test.go b/internal/release/release_tags_test.go
new file mode 100644
index 0000000..334c165
--- /dev/null
+++ b/internal/release/release_tags_test.go
@@ -0,0 +1,54 @@
+package release
+
+import (
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+func TestGetLocalTags_FiltersToStrictVersionTags(t *testing.T) {
+ t.Parallel()
+
+ repoPath := t.TempDir()
+ runGit(t, repoPath, "init")
+ runGit(t, repoPath, "config", "user.name", "Test User")
+ runGit(t, repoPath, "config", "user.email", "test@example.com")
+
+ readmePath := filepath.Join(repoPath, "README.md")
+ if err := os.WriteFile(readmePath, []byte("test"), 0o644); err != nil {
+ t.Fatalf("write file: %v", err)
+ }
+ runGit(t, repoPath, "add", "README.md")
+ runGit(t, repoPath, "commit", "-m", "initial commit")
+
+ runGit(t, repoPath, "tag", "v2")
+ runGit(t, repoPath, "tag", "1.2")
+ runGit(t, repoPath, "tag", "v1.0.0")
+ runGit(t, repoPath, "tag", "latest")
+ runGit(t, repoPath, "tag", "1-beta")
+
+ manager := NewManager("")
+ tags, err := manager.GetLocalTags(repoPath)
+ if err != nil {
+ t.Fatalf("GetLocalTags() error = %v", err)
+ }
+
+ want := []string{"v1.0.0", "1.2", "v2"}
+ if !reflect.DeepEqual(tags, want) {
+ t.Fatalf("GetLocalTags() = %#v, want %#v", tags, want)
+ }
+}
+
+func runGit(t *testing.T, repoPath string, args ...string) string {
+ t.Helper()
+
+ cmd := exec.Command("git", append([]string{"-C", repoPath}, args...)...)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v failed: %v\n%s", args, err, string(output))
+ }
+
+ return string(output)
+}
diff --git a/internal/showcase/metadata.go b/internal/showcase/metadata.go
index c6c756b..6a8d4d6 100644
--- a/internal/showcase/metadata.go
+++ b/internal/showcase/metadata.go
@@ -9,6 +9,8 @@ import (
"strconv"
"strings"
"time"
+
+ "codeberg.org/snonux/gitsyncer/internal/version"
)
const unreleasedScorePenalty = 0.75
@@ -29,7 +31,7 @@ type RepoMetadata struct {
LinesOfDocs int // Lines of documentation
FirstCommitDate string
LastCommitDate string
- LastActivityDate string // Most recent commit on any local branch (--all); used for activity checks
+ LastActivityDate string // Most recent commit on any local branch (--all); used for activity checks
License string
AvgCommitAge float64 // Average age of last 42 commits in days (HEAD only; used for score)
TagCount int // Total number of git tags in the repository
@@ -374,7 +376,7 @@ func getLatestTag(repoPath string) (string, string, bool, int, error) {
// Find the first tag that looks like a version number
latestTag := ""
for _, tag := range tags {
- if isVersionTag(tag) {
+ if version.IsVersionTag(tag) {
latestTag = tag
break
}
@@ -403,39 +405,3 @@ func getLatestTag(repoPath string) (string, string, bool, int, error) {
// Return the latest tag and its date
return latestTag, tagDate, true, tagCount, nil
}
-
-// isVersionTag checks if a tag looks like a version number
-func isVersionTag(tag string) bool {
- // Remove 'v' prefix if present
- versionStr := strings.TrimPrefix(tag, "v")
-
- // Check if the remaining string contains at least one digit and one dot
- hasDigit := false
- hasDot := false
-
- for _, ch := range versionStr {
- if ch >= '0' && ch <= '9' {
- hasDigit = true
- } else if ch == '.' {
- hasDot = true
- } else if ch != '-' && ch != '+' && ch != '_' &&
- (ch < 'a' || ch > 'z') && (ch < 'A' || ch > 'Z') {
- // Allow alphanumeric characters and common separators
- // but anything else makes it not a version
- return false
- }
- }
-
- // Must have at least one digit, and either:
- // - have a dot (e.g., 1.0, 0.1.2)
- // - be just digits (e.g., 2, 2024)
- // - start with a digit (e.g., 1-beta)
- if hasDigit && len(versionStr) > 0 {
- firstChar := versionStr[0]
- if firstChar >= '0' && firstChar <= '9' {
- return true
- }
- }
-
- return hasDigit && hasDot
-}
diff --git a/internal/showcase/metadata_test.go b/internal/showcase/metadata_test.go
index abc4664..e905755 100644
--- a/internal/showcase/metadata_test.go
+++ b/internal/showcase/metadata_test.go
@@ -59,6 +59,7 @@ func TestGetLatestTag_ReturnsTotalTagCount(t *testing.T) {
writeAndCommit("README.md", "first", "first")
runGit(t, repoPath, "tag", "notes")
+ runGit(t, repoPath, "tag", "1-beta")
runGit(t, repoPath, "tag", "v1.0.0")
writeAndCommit("README.md", "second", "second")
@@ -74,8 +75,8 @@ func TestGetLatestTag_ReturnsTotalTagCount(t *testing.T) {
if !hasReleases {
t.Fatal("expected hasReleases to be true")
}
- if tagCount != 3 {
- t.Fatalf("tagCount = %d, want %d", tagCount, 3)
+ if tagCount != 4 {
+ t.Fatalf("tagCount = %d, want %d", tagCount, 4)
}
}
diff --git a/internal/version/version_tag.go b/internal/version/version_tag.go
new file mode 100644
index 0000000..0d12ffa
--- /dev/null
+++ b/internal/version/version_tag.go
@@ -0,0 +1,11 @@
+package version
+
+import "regexp"
+
+var versionTagPattern = regexp.MustCompile(`^v?\d+(\.\d+)?(\.\d+)?$`)
+
+// IsVersionTag checks if a tag name is a version tag.
+// Supported formats: vX.Y.Z, vX.Y, vX, X.Y.Z, X.Y, X.
+func IsVersionTag(tag string) bool {
+ return versionTagPattern.MatchString(tag)
+}
diff --git a/internal/version/version_tag_test.go b/internal/version/version_tag_test.go
new file mode 100644
index 0000000..93ee3b8
--- /dev/null
+++ b/internal/version/version_tag_test.go
@@ -0,0 +1,37 @@
+package version
+
+import "testing"
+
+func TestIsVersionTag(t *testing.T) {
+ t.Parallel()
+
+ testCases := []struct {
+ name string
+ tag string
+ want bool
+ }{
+ {name: "single digit", tag: "1", want: true},
+ {name: "single digit with v prefix", tag: "v1", want: true},
+ {name: "major minor", tag: "1.2", want: true},
+ {name: "major minor with v prefix", tag: "v1.2", want: true},
+ {name: "major minor patch", tag: "1.2.3", want: true},
+ {name: "major minor patch with v prefix", tag: "v1.2.3", want: true},
+ {name: "empty", tag: "", want: false},
+ {name: "letter prefix", tag: "release-1.2.3", want: false},
+ {name: "too many components", tag: "1.2.3.4", want: false},
+ {name: "trailing dot", tag: "1.", want: false},
+ {name: "prerelease style", tag: "1-beta", want: false},
+ {name: "prerelease style with v prefix", tag: "v1-beta", want: false},
+ }
+
+ for _, tc := range testCases {
+ tc := tc
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ got := IsVersionTag(tc.tag)
+ if got != tc.want {
+ t.Fatalf("IsVersionTag(%q) = %v, want %v", tc.tag, got, tc.want)
+ }
+ })
+ }
+}