< Summary - go-semantic-release Coverage

Line coverage
100%
Covered lines: 23
Uncovered lines: 0
Coverable lines: 23
Total lines: 307
Line coverage: 100%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
NewRepository0%00100%
splitMessage0%00100%
resolveAuth0%00100%

File(s)

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/gogit/repository.go

#LineLine coverage
 1package gogit
 2
 3import (
 4  "cmp"
 5  "context"
 6  "errors"
 7  "fmt"
 8  "os"
 9  "slices"
 10  "strings"
 11  "time"
 12
 13  "github.com/go-git/go-git/v5"
 14  "github.com/go-git/go-git/v5/config"
 15  "github.com/go-git/go-git/v5/plumbing"
 16  "github.com/go-git/go-git/v5/plumbing/object"
 17  "github.com/go-git/go-git/v5/plumbing/storer"
 18  "github.com/go-git/go-git/v5/plumbing/transport"
 19  githttp "github.com/go-git/go-git/v5/plumbing/transport/http"
 20
 21  "github.com/jedi-knights/go-semantic-release/internal/domain"
 22  "github.com/jedi-knights/go-semantic-release/internal/ports"
 23)
 24
 25// Compile-time interface compliance check.
 26var _ ports.GitRepository = (*Repository)(nil)
 27
 28// Repository implements ports.GitRepository using go-git (pure Go, no CLI dependency).
 29type Repository struct {
 30  repo    *git.Repository
 31  workDir string
 32}
 33
 34// NewRepository opens an existing git repository at the given path.
 2335func NewRepository(workDir string) (*Repository, error) {
 2336  repo, err := git.PlainOpen(workDir)
 137  if err != nil {
 138    return nil, fmt.Errorf("opening git repository at %s: %w", workDir, err)
 139  }
 2240  return &Repository{repo: repo, workDir: workDir}, nil
 41}
 42
 43// CurrentBranch returns the name of the checked-out branch.
 44func (r *Repository) CurrentBranch(_ context.Context) (string, error) {
 45  head, err := r.repo.Head()
 46  if err != nil {
 47    return "", fmt.Errorf("getting HEAD: %w", err)
 48  }
 49  return head.Name().Short(), nil
 50}
 51
 52// ListTags returns all tags in the repository.
 53func (r *Repository) ListTags(_ context.Context) ([]domain.Tag, error) {
 54  tagRefs, err := r.repo.Tags()
 55  if err != nil {
 56    return nil, fmt.Errorf("listing tags: %w", err)
 57  }
 58
 59  var tags []domain.Tag
 60  err = tagRefs.ForEach(func(ref *plumbing.Reference) error {
 61    hash := ref.Hash().String()
 62
 63    // For annotated tags, resolve to the commit hash.
 64    tagObj, tagErr := r.repo.TagObject(ref.Hash())
 65    isAnnotated := false
 66    if tagErr == nil {
 67      hash = tagObj.Target.String()
 68      isAnnotated = true
 69    }
 70
 71    tags = append(tags, domain.Tag{
 72      Name:        ref.Name().Short(),
 73      Hash:        hash,
 74      IsAnnotated: isAnnotated,
 75    })
 76    return nil
 77  })
 78  if err != nil {
 79    return nil, err
 80  }
 81
 82  // Sort by name descending (version sort).
 83  slices.SortFunc(tags, func(a, b domain.Tag) int {
 84    return cmp.Compare(b.Name, a.Name)
 85  })
 86
 87  return tags, nil
 88}
 89
 90// CommitsSince returns commits since the given hash (exclusive).
 91func (r *Repository) CommitsSince(_ context.Context, sinceHash string) ([]domain.Commit, error) {
 92  head, err := r.repo.Head()
 93  if err != nil {
 94    return nil, fmt.Errorf("getting HEAD: %w", err)
 95  }
 96
 97  logOpts := &git.LogOptions{
 98    From:  head.Hash(),
 99    Order: git.LogOrderCommitterTime,
 100  }
 101
 102  iter, err := r.repo.Log(logOpts)
 103  if err != nil {
 104    return nil, fmt.Errorf("getting log: %w", err)
 105  }
 106
 107  var commits []domain.Commit
 108  err = iter.ForEach(func(c *object.Commit) error {
 109    if sinceHash != "" && c.Hash.String() == sinceHash {
 110      return storer.ErrStop
 111    }
 112
 113    subject, body := splitMessage(c.Message)
 114
 115    commits = append(commits, domain.Commit{
 116      Hash:        c.Hash.String(),
 117      Author:      c.Author.Name,
 118      AuthorEmail: c.Author.Email,
 119      Date:        c.Author.When,
 120      Message:     subject,
 121      Body:        body,
 122    })
 123    return nil
 124  })
 125  if err != nil {
 126    return nil, err
 127  }
 128
 129  return commits, nil
 130}
 131
 132// FilesChangedInCommit returns the list of files changed by a commit.
 133func (r *Repository) FilesChangedInCommit(_ context.Context, hash string) ([]string, error) {
 134  commitObj, err := r.repo.CommitObject(plumbing.NewHash(hash))
 135  if err != nil {
 136    return nil, fmt.Errorf("getting commit %s: %w", hash, err)
 137  }
 138
 139  stats, err := commitObj.Stats()
 140  if err != nil {
 141    return nil, fmt.Errorf("getting stats for %s: %w", hash, err)
 142  }
 143
 144  files := make([]string, 0, len(stats))
 145  for _, s := range stats {
 146    files = append(files, s.Name)
 147  }
 148  return files, nil
 149}
 150
 151// CreateTag creates a git tag at the given commit hash.
 152func (r *Repository) CreateTag(_ context.Context, name, hash, message string) error {
 153  commitHash := plumbing.NewHash(hash)
 154
 155  if message != "" {
 156    // Create annotated tag.
 157    _, err := r.repo.CreateTag(name, commitHash, &git.CreateTagOptions{
 158      Message: message,
 159      Tagger: &object.Signature{
 160        Name:  "semantic-release-bot",
 161        Email: "semantic-release-bot@users.noreply.github.com",
 162        When:  time.Now(),
 163      },
 164    })
 165    if err == nil {
 166      return nil
 167    }
 168    if errors.Is(err, git.ErrTagExists) {
 169      // Use the peel suffix "^{}" to dereference the annotated tag object
 170      // to the underlying commit hash; without it ResolveRevision returns
 171      // the tag object hash, which never matches the commit hash argument.
 172      resolved, resolveErr := r.repo.ResolveRevision(plumbing.Revision("refs/tags/" + name + "^{}"))
 173      if resolveErr == nil && resolved.String() == hash {
 174        return domain.ErrTagAlreadyExists
 175      }
 176    }
 177    return fmt.Errorf("creating annotated tag %s: %w", name, err)
 178  }
 179
 180  // Create lightweight tag — check for an existing ref first because
 181  // Storer.SetReference silently overwrites rather than erroring on duplicates.
 182  existing, refErr := r.repo.Storer.Reference(plumbing.NewTagReferenceName(name))
 183  if refErr != nil && !errors.Is(refErr, plumbing.ErrReferenceNotFound) {
 184    return fmt.Errorf("checking existing tag %s: %w", name, refErr)
 185  }
 186  if existing != nil {
 187    if existing.Hash().String() == hash {
 188      return domain.ErrTagAlreadyExists
 189    }
 190    return fmt.Errorf("tag %s already exists at a different commit: %w", name, git.ErrTagExists)
 191  }
 192  ref := plumbing.NewReferenceFromStrings("refs/tags/"+name, hash)
 193  return r.repo.Storer.SetReference(ref)
 194}
 195
 196// PushTag pushes a tag to the remote.
 197func (r *Repository) PushTag(ctx context.Context, name string) error {
 198  refSpec := config.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", name, name))
 199
 200  auth := resolveAuth()
 201
 202  err := r.repo.Push(&git.PushOptions{
 203    RemoteName: "origin",
 204    RefSpecs:   []config.RefSpec{refSpec},
 205    Auth:       auth,
 206  })
 207  // NoErrAlreadyUpToDate is a non-nil sentinel that go-git returns when the
 208  // remote already has the ref at the same SHA. Treat it as success so that
 209  // re-runs (where the tag was already pushed in a prior attempt) do not fail.
 210  if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
 211    return nil
 212  }
 213  return fmt.Errorf("pushing tag %s: %w", name, err)
 214}
 215
 216// HeadHash returns the hash of HEAD.
 217func (r *Repository) HeadHash(_ context.Context) (string, error) {
 218  head, err := r.repo.Head()
 219  if err != nil {
 220    return "", fmt.Errorf("getting HEAD: %w", err)
 221  }
 222  return head.Hash().String(), nil
 223}
 224
 225// RemoteURL returns the remote origin URL.
 226func (r *Repository) RemoteURL(_ context.Context) (string, error) {
 227  remote, err := r.repo.Remote("origin")
 228  if err != nil {
 229    return "", fmt.Errorf("getting remote: %w", err)
 230  }
 231  urls := remote.Config().URLs
 232  if len(urls) == 0 {
 233    return "", fmt.Errorf("no URLs configured for remote 'origin'")
 234  }
 235  return urls[0], nil
 236}
 237
 238// Stage adds the given file paths to the worktree index.
 239func (r *Repository) Stage(_ context.Context, files []string) error {
 240  if len(files) == 0 {
 241    return nil
 242  }
 243  w, err := r.repo.Worktree()
 244  if err != nil {
 245    return fmt.Errorf("getting worktree: %w", err)
 246  }
 247  for _, file := range files {
 248    if _, err := w.Add(file); err != nil {
 249      return fmt.Errorf("staging %s: %w", file, err)
 250    }
 251  }
 252  return nil
 253}
 254
 255// Commit creates a commit with the given message from the current index.
 256func (r *Repository) Commit(_ context.Context, message string) error {
 257  w, err := r.repo.Worktree()
 258  if err != nil {
 259    return fmt.Errorf("getting worktree: %w", err)
 260  }
 261  _, err = w.Commit(message, &git.CommitOptions{
 262    Author: &object.Signature{
 263      Name:  "semantic-release-bot",
 264      Email: "semantic-release-bot@users.noreply.github.com",
 265      When:  time.Now(),
 266    },
 267  })
 268  if err != nil {
 269    return fmt.Errorf("committing: %w", err)
 270  }
 271  return nil
 272}
 273
 274// Push pushes the current branch to origin.
 275func (r *Repository) Push(ctx context.Context) error {
 276  auth := resolveAuth()
 277  err := r.repo.Push(&git.PushOptions{
 278    RemoteName: "origin",
 279    Auth:       auth,
 280  })
 281  if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
 282    return nil
 283  }
 284  return fmt.Errorf("pushing branch: %w", err)
 285}
 286
 6287func splitMessage(msg string) (subject, body string) {
 6288  parts := strings.SplitN(strings.TrimSpace(msg), "\n", 2)
 6289  subject = strings.TrimSpace(parts[0])
 1290  if len(parts) > 1 {
 1291    body = strings.TrimSpace(parts[1])
 1292  }
 6293  return subject, body
 294}
 295
 4296func resolveAuth() transport.AuthMethod {
 4297  // Try token-based auth for HTTPS remotes.
 4298  for _, key := range []string{"GH_TOKEN", "GITHUB_TOKEN", "GL_TOKEN", "GITLAB_TOKEN", "BB_TOKEN", "BITBUCKET_TOKEN"} {
 1299    if token := os.Getenv(key); token != "" {
 1300      return &githttp.BasicAuth{
 1301        Username: "git",
 1302        Password: token,
 1303      }
 1304    }
 305  }
 3306  return nil
 307}