Skip to content

Commit 1dd80f0

Browse files
committed
feat: implement tag --check
1 parent 5c16faa commit 1dd80f0

File tree

3 files changed

+172
-1
lines changed

3 files changed

+172
-1
lines changed

git/git.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,77 @@ func FetchSemverTags(remote string, prefix, suffix string) error {
247247
return nil
248248
}
249249

250+
// GetTagAtHEAD returns the semver tag at HEAD, or empty string if none exists
251+
func GetTagAtHEAD(prefix, suffix string) (string, error) {
252+
tagPattern := genTagPattern(prefix, suffix)
253+
log.WithFields(log.Fields{
254+
"pattern": tagPattern,
255+
"prefix": prefix,
256+
"suffix": suffix,
257+
}).Debug("GetTagAtHEAD")
258+
cmd := exec.Command("git", "tag", "--points-at", "HEAD", "--list", tagPattern)
259+
cmd.Stderr = os.Stderr
260+
output, err := cmd.Output()
261+
if err != nil {
262+
log.WithError(err).Debug("GetTagAtHEAD: error checking tags")
263+
return "", fmt.Errorf("failed to check tags for HEAD: %w", err)
264+
}
265+
tags := strings.TrimSpace(string(output))
266+
if tags == "" {
267+
log.Debug("GetTagAtHEAD: no tags found at HEAD")
268+
return "", nil
269+
}
270+
271+
// If multiple tags exist, find the largest one
272+
tagList := strings.Split(tags, "\n")
273+
var largestTag string
274+
var largestVersion *semver.Version
275+
276+
for _, tag := range tagList {
277+
if tag == "" {
278+
continue
279+
}
280+
version, err := semver.ParseSemver(tag)
281+
if err != nil {
282+
log.WithError(err).WithField("tag", tag).Debug("GetTagAtHEAD: failed to parse tag")
283+
continue
284+
}
285+
286+
// Check suffix match if specified
287+
if suffix != "" && version.PreRelease != suffix {
288+
continue
289+
}
290+
291+
if largestVersion == nil || semver.CompareSemver(version, largestVersion) {
292+
largestTag = tag
293+
largestVersion = version
294+
}
295+
}
296+
297+
log.WithField("tag", largestTag).Debug("GetTagAtHEAD: returning tag at HEAD")
298+
return largestTag, nil
299+
}
300+
301+
// IsAncestor checks if ancestorRef is an ancestor of descendantRef
302+
func IsAncestor(ancestorRef, descendantRef string) (bool, error) {
303+
log.WithFields(log.Fields{
304+
"ancestor": ancestorRef,
305+
"descendant": descendantRef,
306+
}).Debug("IsAncestor: checking ancestry")
307+
cmd := exec.Command("git", "merge-base", "--is-ancestor", ancestorRef, descendantRef)
308+
err := cmd.Run()
309+
if err != nil {
310+
if exitError, ok := err.(*exec.ExitError); ok && exitError.ExitCode() == 1 {
311+
log.Debug("IsAncestor: not an ancestor")
312+
return false, nil
313+
}
314+
log.WithError(err).Debug("IsAncestor: error checking ancestry")
315+
return false, err
316+
}
317+
log.Debug("IsAncestor: is an ancestor")
318+
return true, nil
319+
}
320+
250321
func IsHEADAlreadyTagged(prefix, suffix string) (bool, error) {
251322
tagPattern := genTagPattern(prefix, suffix)
252323
log.WithFields(log.Fields{

main.go

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ var (
1919
)
2020

2121
func main() {
22-
var major, minor, patch, push, print bool
22+
var major, minor, patch, push, print, check bool
2323
var metadata, prefix, suffix, remote string
2424
var debug bool
2525

@@ -55,13 +55,54 @@ func main() {
5555
"major": major,
5656
"minor": minor,
5757
"patch": patch,
58+
"check": check,
5859
}).Debug("Configuration")
5960

6061
if err := git.FetchSemverTags(remote, prefix, suffix); err != nil {
6162
fmt.Printf("Error fetching tags: %v\n", err)
6263
os.Exit(1)
6364
}
6465

66+
// Handle --check flag: validate that the tag at HEAD has its previous version as an ancestor
67+
if check {
68+
currentTag, err := git.GetTagAtHEAD(prefix, suffix)
69+
if err != nil {
70+
fmt.Printf("Error getting tag at HEAD: %v\n", err)
71+
os.Exit(1)
72+
}
73+
if currentTag == "" {
74+
fmt.Println("Error: HEAD is not tagged")
75+
os.Exit(1)
76+
}
77+
78+
allTags, err := git.ListTags(prefix, suffix)
79+
if err != nil {
80+
fmt.Printf("Error listing tags: %v\n", err)
81+
os.Exit(1)
82+
}
83+
84+
previousTag, err := semver.FindPreviousVersion(currentTag, allTags)
85+
if err != nil {
86+
// No previous version means this is the first version, which is valid
87+
fmt.Printf("Tag '%s' is valid (first version)\n", currentTag)
88+
os.Exit(0)
89+
}
90+
91+
isAncestor, err := git.IsAncestor(previousTag, "HEAD")
92+
if err != nil {
93+
fmt.Printf("Error checking ancestry: %v\n", err)
94+
os.Exit(1)
95+
}
96+
97+
if !isAncestor {
98+
fmt.Printf("Error: Previous tag '%s' is not an ancestor of current tag '%s'\n", previousTag, currentTag)
99+
os.Exit(1)
100+
}
101+
102+
fmt.Printf("Tag '%s' is valid (previous version '%s' is an ancestor)\n", currentTag, previousTag)
103+
os.Exit(0)
104+
}
105+
65106
// Check if HEAD is already tagged
66107
alreadyTagged, err := git.IsHEADAlreadyTagged(prefix, suffix)
67108
if err != nil {
@@ -137,6 +178,7 @@ func main() {
137178
rootCmd.Flags().BoolVar(&patch, "patch", false, "increment the patch version")
138179
rootCmd.Flags().BoolVar(&push, "push", false, "create and push the tag to remote")
139180
rootCmd.Flags().BoolVar(&print, "print-only", false, "print the next tag and exit")
181+
rootCmd.Flags().BoolVar(&check, "check", false, "validate that the tag at HEAD has its previous version as an ancestor")
140182
rootCmd.Flags().BoolVar(&debug, "debug", false, "enable debug logging")
141183
rootCmd.Flags().StringVar(&prefix, "prefix", "", "set a prefix for the tag")
142184
rootCmd.Flags().StringVar(&suffix, "suffix", "", "set the pre-release suffix (e.g., rc, alpha, beta)")

semver/semver.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,64 @@ func CompareSemver(v1, v2 *Version) bool {
9393
return false
9494
}
9595

96+
// FindPreviousVersion finds the tag immediately before the given tag by semver ordering
97+
func FindPreviousVersion(currentTag string, allTags []string) (string, error) {
98+
currentVersion, err := ParseSemver(currentTag)
99+
if err != nil {
100+
return "", err
101+
}
102+
103+
var previousTag string
104+
var previousVersion *Version
105+
106+
for _, tag := range allTags {
107+
if tag == "" || tag == currentTag {
108+
continue
109+
}
110+
version, err := ParseSemver(tag)
111+
if err != nil {
112+
log.WithError(err).WithField("tag", tag).Debug("FindPreviousVersion: failed to parse tag")
113+
continue
114+
}
115+
116+
// Skip tags with different prefix
117+
if version.Prefix != currentVersion.Prefix {
118+
continue
119+
}
120+
121+
// Skip versions that are >= current version
122+
if CompareSemver(version, currentVersion) || versionsEqual(version, currentVersion) {
123+
continue
124+
}
125+
126+
// Keep track of the largest version that is still less than current
127+
if previousVersion == nil || CompareSemver(version, previousVersion) {
128+
previousTag = tag
129+
previousVersion = version
130+
}
131+
}
132+
133+
if previousTag == "" {
134+
return "", fmt.Errorf("no previous version found for %s", currentTag)
135+
}
136+
137+
log.WithFields(log.Fields{
138+
"currentTag": currentTag,
139+
"previousTag": previousTag,
140+
}).Debug("FindPreviousVersion: found previous version")
141+
142+
return previousTag, nil
143+
}
144+
145+
// versionsEqual checks if two versions are equal
146+
func versionsEqual(v1, v2 *Version) bool {
147+
return v1.Major == v2.Major &&
148+
v1.Minor == v2.Minor &&
149+
v1.Patch == v2.Patch &&
150+
v1.PreRelease == v2.PreRelease &&
151+
v1.PreReleaseNum == v2.PreReleaseNum
152+
}
153+
96154
func CalculateNextVersion(tag string, allTags []string, incMajor, incMinor, incPatch bool, suffix string) (string, error) {
97155
log.WithFields(log.Fields{
98156
"latest": tag,

0 commit comments

Comments
 (0)