Skip to content

Commit 6ca1acc

Browse files
xoxyspat-s
andauthored
feat: add retag command (#339)
Co-authored-by: pat-s <patrick.schratz@gmail.com>
1 parent 9e7b538 commit 6ca1acc

10 files changed

Lines changed: 486 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ COMMANDS:
167167
release-notes, rn generate release notes
168168
changelog, cgl generate changelog
169169
tag, tg generate tag with version based on git commit messages
170+
retag, rt move the most recent (or a specific) tag to HEAD and force-push it
170171
commit, cmt execute git commit with conventional commit message helper
171172
validate-commit-message, vcm use as prepare-commit-message hook to validate and enhance commit message
172173
help, h Shows a list of commands or help for one command

app/app.go

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@ var (
2525

2626
// Tag git tag info.
2727
type Tag struct {
28-
Name string
29-
Date time.Time
28+
Name string
29+
Date time.Time
30+
Annotated bool
3031
}
3132

3233
// LogRangeType type of log range.
@@ -270,7 +271,6 @@ func (g GitSV) Tag(version semver.Version, annotate, local bool) (string, error)
270271

271272
var tagOpts *git.CreateTagOptions
272273
if annotate {
273-
// Create an annotated tag with a message
274274
tagOpts = &git.CreateTagOptions{
275275
Message: tagMsg,
276276
}
@@ -336,7 +336,9 @@ func (g GitSV) Tags() ([]Tag, error) {
336336

337337
// Try to get annotated tag first
338338
tagObj, err := repo.TagObject(ref.Hash())
339-
if err == nil {
339+
340+
annotated := err == nil
341+
if annotated {
340342
tagDate = tagObj.Tagger.When
341343
} else {
342344
// For lightweight tags, get the commit
@@ -348,8 +350,9 @@ func (g GitSV) Tags() ([]Tag, error) {
348350
}
349351

350352
tags = append(tags, Tag{
351-
Name: tagName,
352-
Date: tagDate,
353+
Name: tagName,
354+
Date: tagDate,
355+
Annotated: annotated,
353356
})
354357

355358
return nil
@@ -366,6 +369,83 @@ func (g GitSV) Tags() ([]Tag, error) {
366369
return tags, nil
367370
}
368371

372+
// Retag moves the existing tag with the given name to HEAD. The exact name
373+
// is preserved (no pattern reformatting). If the tag already points to HEAD
374+
// and no annotation upgrade is required, the local rewrite is skipped.
375+
func (g GitSV) Retag(existingName string, annotate, local bool) (string, error) {
376+
repo, err := git.PlainOpen(".")
377+
if err != nil {
378+
return existingName, fmt.Errorf("failed to open git repository: %w", err)
379+
}
380+
381+
head, err := repo.Head()
382+
if err != nil {
383+
return existingName, fmt.Errorf("failed to get HEAD reference: %w", err)
384+
}
385+
386+
tagCommit, existingAnnotated, err := resolveTagCommit(repo, existingName)
387+
if err != nil {
388+
return existingName, fmt.Errorf("failed to resolve tag %q: %w", existingName, err)
389+
}
390+
391+
if tagCommit != head.Hash() || (annotate && !existingAnnotated) {
392+
tagMsg := fmt.Sprintf("Version %s", existingName)
393+
394+
var tagOpts *git.CreateTagOptions
395+
if annotate {
396+
tagOpts = &git.CreateTagOptions{Message: tagMsg}
397+
}
398+
399+
if delErr := repo.DeleteTag(existingName); delErr != nil && !errors.Is(delErr, git.ErrTagNotFound) {
400+
return existingName, fmt.Errorf("failed to delete existing tag: %w", delErr)
401+
}
402+
403+
if _, err = repo.CreateTag(existingName, head.Hash(), tagOpts); err != nil {
404+
return existingName, fmt.Errorf("failed to create tag: %w", err)
405+
}
406+
}
407+
408+
if local {
409+
return existingName, nil
410+
}
411+
412+
remote, err := repo.Remote("origin")
413+
if err != nil {
414+
return existingName, fmt.Errorf("failed to get remote: %w", err)
415+
}
416+
417+
refSpec := fmt.Sprintf("refs/tags/%s:refs/tags/%s", existingName, existingName)
418+
419+
if err = remote.Push(&git.PushOptions{
420+
RefSpecs: []config.RefSpec{config.RefSpec(refSpec)},
421+
Force: true,
422+
}); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
423+
return existingName, fmt.Errorf("failed to push tag: %w", err)
424+
}
425+
426+
return existingName, nil
427+
}
428+
429+
// resolveTagCommit returns the commit hash the named tag points to and
430+
// whether the tag is annotated, handling both annotated and lightweight
431+
// tags.
432+
func resolveTagCommit(repo *git.Repository, name string) (plumbing.Hash, bool, error) {
433+
ref, err := repo.Reference(plumbing.ReferenceName("refs/tags/"+name), false)
434+
if err != nil {
435+
return plumbing.ZeroHash, false, err
436+
}
437+
438+
if ref == nil {
439+
return plumbing.ZeroHash, false, plumbing.ErrReferenceNotFound
440+
}
441+
442+
if tagObj, terr := repo.TagObject(ref.Hash()); terr == nil {
443+
return tagObj.Target, true, nil
444+
}
445+
446+
return ref.Hash(), false, nil
447+
}
448+
369449
// Branch get git branch.
370450
func (g GitSV) Branch() string {
371451
repo, err := git.PlainOpen(".")

app/app_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ type testRepo struct {
2525
func setupGitRepo(t *testing.T, tr testRepo) string {
2626
t.Helper()
2727

28+
t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null")
29+
2830
// Create a temporary directory using t.TempDir() which is automatically cleaned up
2931
tmpDir := t.TempDir()
3032

app/commands/retag.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package commands
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"slices"
8+
9+
"github.com/thegeeklab/git-sv/app"
10+
"github.com/thegeeklab/git-sv/sv"
11+
"github.com/urfave/cli/v3"
12+
)
13+
14+
var (
15+
errNoTagToRetag = errors.New("no tag found to retag")
16+
errTagNotFound = errors.New("tag not found")
17+
)
18+
19+
func RetagFlags(settings *app.RetagSettings) []cli.Flag {
20+
return []cli.Flag{
21+
&cli.BoolFlag{
22+
Name: "annotate",
23+
Aliases: []string{"a"},
24+
Usage: "force an annotated tag object (annotated source tags are preserved regardless)",
25+
Destination: &settings.Annotate,
26+
},
27+
&cli.BoolFlag{
28+
Name: "local",
29+
Usage: "retag local tag only",
30+
Destination: &settings.Local,
31+
},
32+
&cli.StringFlag{
33+
Name: "tag",
34+
Aliases: []string{"t"},
35+
Usage: "retag a specific existing tag instead of the most recently created one",
36+
Destination: &settings.Tag,
37+
},
38+
}
39+
}
40+
41+
func RetagHandler(g app.GitSV, settings *app.RetagSettings) cli.ActionFunc {
42+
return func(_ context.Context, _ *cli.Command) error {
43+
tags, err := g.Tags()
44+
if err != nil {
45+
return fmt.Errorf("error listing tags: %w", err)
46+
}
47+
48+
var target app.Tag
49+
50+
if settings.Tag != "" {
51+
idx := slices.IndexFunc(tags, func(t app.Tag) bool { return t.Name == settings.Tag })
52+
if idx < 0 {
53+
return fmt.Errorf("%w: %s", errTagNotFound, settings.Tag)
54+
}
55+
56+
target = tags[idx]
57+
} else {
58+
if len(tags) == 0 {
59+
return errNoTagToRetag
60+
}
61+
62+
// Tags() is sorted by date (oldest first), so the most recently
63+
// created tag is the last entry.
64+
target = tags[len(tags)-1]
65+
}
66+
67+
if _, err := sv.ToVersion(target.Name); err != nil {
68+
return fmt.Errorf("error parsing version: %s from git tag: %w", target.Name, err)
69+
}
70+
71+
// Preserve the original tag type: never silently downgrade an annotated
72+
// tag to a lightweight one.
73+
annotate := settings.Annotate || target.Annotated
74+
75+
tagname, err := g.Retag(target.Name, annotate, settings.Local)
76+
if err != nil {
77+
return fmt.Errorf("error retagging %s: %w", target.Name, err)
78+
}
79+
80+
fmt.Println(tagname)
81+
82+
return nil
83+
}
84+
}

0 commit comments

Comments
 (0)