feat(gitread): list release bookmarks + bookmark downgrade guard
1dd04bc86e7456b73de3f81f7277172dd37fc208
humdrum <me@humdrum.me> · 2026-06-19 08:06
parent 1b76d4e1
2 files changed
internal/gitread/gitread.go +62 −0
@@ -228,6 +228,68 @@ lv, _ := parseSemver(latest.Name)
return !semverLess(v, lv) // v >= latest
}
+// ReleaseBookmarks returns the repo's release bookmarks: branches under prefix
+// whose remaining name is a semver, as version-named Refs (Name = vX.Y.Z, Hash =
+// tip commit, Message = tip commit subject), sorted semver-descending.
+func (s *Store) ReleaseBookmarks(name, prefix string) ([]Ref, error) {
+ repo, err := s.open(name)
+ if err != nil {
+ return nil, err
+ }
+ bs, err := repo.Branches()
+ if err != nil {
+ return nil, err
+ }
+ var out []Ref
+ _ = bs.ForEach(func(r *plumbing.Reference) error {
+ short := r.Name().Short()
+ rest, ok := strings.CutPrefix(short, prefix)
+ if !ok {
+ return nil
+ }
+ if _, ok := parseSemver(rest); !ok {
+ return nil
+ }
+ ref := Ref{Name: rest, Hash: r.Hash().String()}
+ if c, err := repo.CommitObject(r.Hash()); err == nil {
+ ref.Message = firstLine(c.Message)
+ }
+ out = append(out, ref)
+ return nil
+ })
+ sort.Slice(out, func(i, j int) bool {
+ vi, _ := parseSemver(out[i].Name)
+ vj, _ := parseSemver(out[j].Name)
+ return semverLess(vj, vi) // descending
+ })
+ return out, nil
+}
+
+// LatestRelease returns the highest-semver release bookmark, ok=false if none.
+func (s *Store) LatestRelease(name, prefix string) (Ref, bool) {
+ bm, err := s.ReleaseBookmarks(name, prefix)
+ if err != nil || len(bm) == 0 {
+ return Ref{}, false
+ }
+ return bm[0], true
+}
+
+// IsHighestRelease reports whether version is >= the highest release bookmark
+// (ties allowed). Guards bookmark-mode publishes against out-of-order pushes.
+// A non-semver version is never highest.
+func (s *Store) IsHighestRelease(name, prefix, version string) bool {
+ v, ok := parseSemver(version)
+ if !ok {
+ return false
+ }
+ latest, have := s.LatestRelease(name, prefix)
+ if !have {
+ return true
+ }
+ lv, _ := parseSemver(latest.Name)
+ return !semverLess(v, lv)
+}
+
// firstLine returns the first non-empty line of s, trimmed.
func firstLine(s string) string {
s = strings.TrimSpace(s)
internal/gitread/gitread_test.go +78 −0
@@ -0,0 +1,78 @@
+package gitread
+
+import (
+ "os/exec"
+ "path/filepath"
+ "testing"
+)
+
+// newBareWithReleases builds a bare repo named "<root>/proj.git" containing one
+// commit plus the given release branches (each pointing at its own commit whose
+// subject is "ship <branch>"). Returns the Store rooted at the temp dir.
+func newBareWithReleases(t *testing.T, branches ...string) *Store {
+ t.Helper()
+ root := t.TempDir()
+ work := filepath.Join(root, "work")
+ run := func(dir string, args ...string) {
+ t.Helper()
+ c := exec.Command("git", args...)
+ c.Dir = dir
+ c.Env = append(c.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@x", "GIT_AUTHOR_DATE=2026-01-01T00:00:00",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@x", "GIT_COMMITTER_DATE=2026-01-01T00:00:00")
+ if out, err := c.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+ }
+ run(root, "init", "-q", "-b", "main", "work")
+ run(work, "commit", "-q", "--allow-empty", "-m", "init")
+ for _, b := range branches {
+ run(work, "checkout", "-q", "-b", b)
+ run(work, "commit", "-q", "--allow-empty", "-m", "ship "+b)
+ run(work, "checkout", "-q", "main")
+ }
+ run(work, "clone", "-q", "--bare", work, filepath.Join(root, "proj.git"))
+ return New(root)
+}
+
+func TestReleaseBookmarks(t *testing.T) {
+ s := newBareWithReleases(t, "release/v1.0.0", "release/v1.2.0", "release/v0.9.0", "feature/x")
+ got, err := s.ReleaseBookmarks("proj", "release/")
+ if err != nil {
+ t.Fatalf("ReleaseBookmarks: %v", err)
+ }
+ want := []string{"v1.2.0", "v1.0.0", "v0.9.0"} // semver-descending, feature/x excluded
+ if len(got) != len(want) {
+ t.Fatalf("got %d refs %v, want %v", len(got), got, want)
+ }
+ for i, w := range want {
+ if got[i].Name != w {
+ t.Errorf("ReleaseBookmarks[%d].Name = %q, want %q", i, got[i].Name, w)
+ }
+ }
+ if got[0].Message != "ship release/v1.2.0" {
+ t.Errorf("Message = %q, want tip subject", got[0].Message)
+ }
+}
+
+func TestLatestReleaseAndGuard(t *testing.T) {
+ s := newBareWithReleases(t, "release/v1.0.0", "release/v1.2.0")
+ latest, ok := s.LatestRelease("proj", "release/")
+ if !ok || latest.Name != "v1.2.0" {
+ t.Fatalf("LatestRelease = %q,%v; want v1.2.0,true", latest.Name, ok)
+ }
+ if !s.IsHighestRelease("proj", "release/", "v1.2.0") {
+ t.Error("v1.2.0 should be highest (tie)")
+ }
+ if !s.IsHighestRelease("proj", "release/", "v2.0.0") {
+ t.Error("v2.0.0 should be highest")
+ }
+ if s.IsHighestRelease("proj", "release/", "v1.1.0") {
+ t.Error("v1.1.0 must NOT be highest (downgrade guard)")
+ }
+
+ empty := newBareWithReleases(t)
+ if !empty.IsHighestRelease("proj", "release/", "v1.0.0") {
+ t.Error("first release should be highest when none exist")
+ }
+}