From d000a77b5795b1d88707b8e9d4609030741bf97d Mon Sep 17 00:00:00 2001 From: Torben Buck Date: Sat, 26 Sep 2026 09:51:05 +0200 Subject: [PATCH 1/2] fix(git): limit release tags and push only requested ref --- internal/git/git.go | 7 ++- internal/git/git_test.go | 120 +++++++++++++++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/internal/git/git.go b/internal/git/git.go index bfafe10..13311c5 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -38,11 +38,11 @@ func run(args ...string) (string, error) { return strings.TrimSpace(stdout.String()), nil } -// LatestTag returns the most recent tag matching the prefix, or "" if none. +// LatestTag returns the highest version tag reachable from HEAD, or "" if none. // Tags that do not parse as a version with the given prefix are skipped, so a // stray non-semver tag cannot break the bump. func (ExecGit) LatestTag(prefix string) (string, error) { - out, err := run("tag", "--list", prefix+"*", "--sort=-version:refname") + out, err := run("tag", "--list", "--no-column", "--merged", "HEAD", prefix+"*", "--sort=-version:refname") if err != nil { return "", err } @@ -91,7 +91,8 @@ func (ExecGit) CreateTag(tag, message string) error { // PushTag pushes a single tag to origin. func (ExecGit) PushTag(tag string) error { - _, err := run("push", "origin", tag) + ref := "refs/tags/" + tag + _, err := run("push", "--no-follow-tags", "origin", ref+":"+ref) return err } diff --git a/internal/git/git_test.go b/internal/git/git_test.go index 4e25957..5dc7fc0 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -1,7 +1,9 @@ package git import ( + "os" "os/exec" + "path/filepath" "strings" "testing" ) @@ -109,20 +111,114 @@ func TestRunIncludesGitStderrOnError(t *testing.T) { // initTempRepo creates a throwaway git repository and chdirs into it. func initTempRepo(t *testing.T) { t.Helper() + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + t.Setenv("GIT_CONFIG_COUNT", "0") t.Chdir(t.TempDir()) - gitDir := func(args ...string) { - t.Helper() - cmd := exec.Command("git", args...) - out, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("git %v: %v: %s", args, err, out) - } + gitCommand(t, "init", "-q", "-b", "main") + gitCommand(t, "config", "user.email", "test@example.com") + gitCommand(t, "config", "user.name", "Test") + gitCommand(t, "config", "commit.gpgsign", "false") + gitCommand(t, "config", "tag.gpgsign", "false") + gitCommand(t, "commit", "--allow-empty", "--no-gpg-sign", "-m", "init") +} + +func gitCommand(t *testing.T, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func bareRemote(t *testing.T) string { + t.Helper() + remote := filepath.Join(t.TempDir(), "remote.git") + gitCommand(t, "init", "-q", "--bare", remote) + gitCommand(t, "remote", "add", "origin", remote) + return remote +} + +func TestExecGitPushTagDoesNotFollowOtherAnnotatedTags(t *testing.T) { + initTempRepo(t) + remote := bareRemote(t) + gitCommand(t, "config", "push.followTags", "true") + gitCommand(t, "tag", "-a", "private-draft", "-m", "private") + gitCommand(t, "tag", "-a", "v1.0.0", "-m", "release") + + if err := (ExecGit{}).PushTag("v1.0.0"); err != nil { + t.Fatalf("PushTag: %v", err) + } + if got := gitCommand(t, "--git-dir", remote, "tag", "--list"); got != "v1.0.0" { + t.Errorf("remote tags = %q, want only v1.0.0", got) + } +} + +func TestExecGitPushTagWithSameNamedBranch(t *testing.T) { + initTempRepo(t) + remote := bareRemote(t) + gitCommand(t, "branch", "v1.0.0") + gitCommand(t, "tag", "v1.0.0") + + if err := (ExecGit{}).PushTag("v1.0.0"); err != nil { + t.Fatalf("PushTag with same-named branch: %v", err) + } + if got := gitCommand(t, "--git-dir", remote, "tag", "--list"); got != "v1.0.0" { + t.Errorf("remote tags = %q, want v1.0.0", got) + } + if got := gitCommand(t, "--git-dir", remote, "for-each-ref", "--format=%(refname)", "refs/heads"); got != "" { + t.Errorf("remote branches = %q, want none", got) + } +} + +func TestExecGitLatestTagSkipsUnreachableTags(t *testing.T) { + initTempRepo(t) + gitCommand(t, "tag", "v1.0.0") + gitCommand(t, "switch", "-q", "-c", "other") + gitCommand(t, "commit", "--allow-empty", "--no-gpg-sign", "-m", "feat: other") + gitCommand(t, "tag", "v2.0.0") + gitCommand(t, "switch", "-q", "main") + gitCommand(t, "commit", "--allow-empty", "--no-gpg-sign", "-m", "fix: main") + + got, err := (ExecGit{}).LatestTag("v") + if err != nil { + t.Fatalf("LatestTag: %v", err) + } + if got != "v1.0.0" { + t.Errorf("LatestTag = %q, want v1.0.0", got) + } +} + +func TestExecGitLatestTagWithColumnsEnabled(t *testing.T) { + initTempRepo(t) + gitCommand(t, "config", "column.tag", "always") + gitCommand(t, "tag", "v1.0.0") + gitCommand(t, "tag", "v1.1.0") + + got, err := (ExecGit{}).LatestTag("v") + if err != nil { + t.Fatalf("LatestTag: %v", err) + } + if got != "v1.1.0" { + t.Errorf("LatestTag = %q, want v1.1.0", got) + } +} + +func TestExecGitLatestTagPreservesNumericBuildOrdering(t *testing.T) { + initTempRepo(t) + for _, tag := range []string{"v1.2.0-9", "v1.2.0-10"} { + gitCommand(t, "tag", tag) + } + + got, err := (ExecGit{}).LatestTag("v") + if err != nil { + t.Fatalf("LatestTag: %v", err) + } + if got != "v1.2.0-10" { + t.Errorf("LatestTag = %q, want v1.2.0-10", got) } - gitDir("init", "-q", "-b", "main") - gitDir("config", "user.email", "test@example.com") - gitDir("config", "user.name", "Test") - gitDir("config", "commit.gpgsign", "false") - gitDir("commit", "--allow-empty", "--no-gpg-sign", "-m", "init") } func TestExecGitLatestTagSkipsUnparsableTags(t *testing.T) { From 88ae11135169e1ec9e4eccbb102e39d2ec6f89f4 Mon Sep 17 00:00:00 2001 From: Torben Buck Date: Sat, 26 Sep 2026 09:54:19 +0200 Subject: [PATCH 2/2] docs: clarify release tag reachability --- README.md | 4 ++++ internal/git/git.go | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 932dc58..45a308a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,10 @@ tagger bump --patch `tagger status` has the same analysis flags (`--major`, `--minor`, `--patch`, `--prefix`) and never creates or pushes a tag. `tagger suggest` is an alias. +The release baseline is the highest valid version tag matching `--prefix` that +is reachable from `HEAD`. Tags on branches that have not been merged into the +current history are ignored. + ## Build suffixes Tagger also supports numeric build suffixes such as `v3.4.0-58`. When the diff --git a/internal/git/git.go b/internal/git/git.go index 13311c5..ca63217 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -38,7 +38,7 @@ func run(args ...string) (string, error) { return strings.TrimSpace(stdout.String()), nil } -// LatestTag returns the highest version tag reachable from HEAD, or "" if none. +// LatestTag returns the highest matching version tag reachable from HEAD, or "" if none. // Tags that do not parse as a version with the given prefix are skipped, so a // stray non-semver tag cannot break the bump. func (ExecGit) LatestTag(prefix string) (string, error) {