< Summary - go-semantic-release Coverage

Line coverage
72%
Covered lines: 138
Uncovered lines: 53
Coverable lines: 191
Total lines: 307
Line coverage: 72.2%
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
CurrentBranch0%0050%
ListTags0%0068.97%
CommitsSince0%0073.53%
FilesChangedInCommit0%0057.14%
CreateTag0%0086.11%
PushTag0%0082.35%
HeadHash0%0050%
RemoteURL0%0070%
Stage0%0053.85%
Commit0%0062.5%
Push0%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.
 35func NewRepository(workDir string) (*Repository, error) {
 36  repo, err := git.PlainOpen(workDir)
 37  if err != nil {
 38    return nil, fmt.Errorf("opening git repository at %s: %w", workDir, err)
 39  }
 40  return &Repository{repo: repo, workDir: workDir}, nil
 41}
 42
 43// CurrentBranch returns the name of the checked-out branch.
 244func (r *Repository) CurrentBranch(_ context.Context) (string, error) {
 245  head, err := r.repo.Head()
 046  if err != nil {
 047    return "", fmt.Errorf("getting HEAD: %w", err)
 048  }
 249  return head.Name().Short(), nil
 50}
 51
 52// ListTags returns all tags in the repository.
 453func (r *Repository) ListTags(_ context.Context) ([]domain.Tag, error) {
 454  tagRefs, err := r.repo.Tags()
 055  if err != nil {
 056    return nil, fmt.Errorf("listing tags: %w", err)
 057  }
 58
 459  var tags []domain.Tag
 360  err = tagRefs.ForEach(func(ref *plumbing.Reference) error {
 361    hash := ref.Hash().String()
 362
 363    // For annotated tags, resolve to the commit hash.
 364    tagObj, tagErr := r.repo.TagObject(ref.Hash())
 365    isAnnotated := false
 166    if tagErr == nil {
 167      hash = tagObj.Target.String()
 168      isAnnotated = true
 169    }
 70
 371    tags = append(tags, domain.Tag{
 372      Name:        ref.Name().Short(),
 373      Hash:        hash,
 374      IsAnnotated: isAnnotated,
 375    })
 376    return nil
 77  })
 078  if err != nil {
 079    return nil, err
 080  }
 81
 82  // Sort by name descending (version sort).
 083  slices.SortFunc(tags, func(a, b domain.Tag) int {
 084    return cmp.Compare(b.Name, a.Name)
 085  })
 86
 487  return tags, nil
 88}
 89
 90// CommitsSince returns commits since the given hash (exclusive).
 391func (r *Repository) CommitsSince(_ context.Context, sinceHash string) ([]domain.Commit, error) {
 392  head, err := r.repo.Head()
 093  if err != nil {
 094    return nil, fmt.Errorf("getting HEAD: %w", err)
 095  }
 96
 397  logOpts := &git.LogOptions{
 398    From:  head.Hash(),
 399    Order: git.LogOrderCommitterTime,
 3100  }
 3101
 3102  iter, err := r.repo.Log(logOpts)
 0103  if err != nil {
 0104    return nil, fmt.Errorf("getting log: %w", err)
 0105  }
 106
 3107  var commits []domain.Commit
 3108  err = iter.ForEach(func(c *object.Commit) error {
 1109    if sinceHash != "" && c.Hash.String() == sinceHash {
 1110      return storer.ErrStop
 1111    }
 112
 6113    subject, body := splitMessage(c.Message)
 6114
 6115    commits = append(commits, domain.Commit{
 6116      Hash:        c.Hash.String(),
 6117      Author:      c.Author.Name,
 6118      AuthorEmail: c.Author.Email,
 6119      Date:        c.Author.When,
 6120      Message:     subject,
 6121      Body:        body,
 6122    })
 6123    return nil
 124  })
 0125  if err != nil {
 0126    return nil, err
 0127  }
 128
 3129  return commits, nil
 130}
 131
 132// FilesChangedInCommit returns the list of files changed by a commit.
 1133func (r *Repository) FilesChangedInCommit(_ context.Context, hash string) ([]string, error) {
 1134  commitObj, err := r.repo.CommitObject(plumbing.NewHash(hash))
 0135  if err != nil {
 0136    return nil, fmt.Errorf("getting commit %s: %w", hash, err)
 0137  }
 138
 1139  stats, err := commitObj.Stats()
 0140  if err != nil {
 0141    return nil, fmt.Errorf("getting stats for %s: %w", hash, err)
 0142  }
 143
 1144  files := make([]string, 0, len(stats))
 1145  for _, s := range stats {
 1146    files = append(files, s.Name)
 1147  }
 1148  return files, nil
 149}
 150
 151// CreateTag creates a git tag at the given commit hash.
 8152func (r *Repository) CreateTag(_ context.Context, name, hash, message string) error {
 8153  commitHash := plumbing.NewHash(hash)
 8154
 3155  if message != "" {
 3156    // Create annotated tag.
 3157    _, err := r.repo.CreateTag(name, commitHash, &git.CreateTagOptions{
 3158      Message: message,
 3159      Tagger: &object.Signature{
 3160        Name:  "semantic-release-bot",
 3161        Email: "semantic-release-bot@users.noreply.github.com",
 3162        When:  time.Now(),
 3163      },
 3164    })
 2165    if err == nil {
 2166      return nil
 2167    }
 1168    if errors.Is(err, git.ErrTagExists) {
 1169      // Use the peel suffix "^{}" to dereference the annotated tag object
 1170      // to the underlying commit hash; without it ResolveRevision returns
 1171      // the tag object hash, which never matches the commit hash argument.
 1172      resolved, resolveErr := r.repo.ResolveRevision(plumbing.Revision("refs/tags/" + name + "^{}"))
 1173      if resolveErr == nil && resolved.String() == hash {
 1174        return domain.ErrTagAlreadyExists
 1175      }
 176    }
 0177    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.
 5182  existing, refErr := r.repo.Storer.Reference(plumbing.NewTagReferenceName(name))
 0183  if refErr != nil && !errors.Is(refErr, plumbing.ErrReferenceNotFound) {
 0184    return fmt.Errorf("checking existing tag %s: %w", name, refErr)
 0185  }
 1186  if existing != nil {
 1187    if existing.Hash().String() == hash {
 1188      return domain.ErrTagAlreadyExists
 1189    }
 0190    return fmt.Errorf("tag %s already exists at a different commit: %w", name, git.ErrTagExists)
 191  }
 4192  ref := plumbing.NewReferenceFromStrings("refs/tags/"+name, hash)
 4193  return r.repo.Storer.SetReference(ref)
 194}
 195
 196// PushTag pushes a tag to the remote.
 2197func (r *Repository) PushTag(ctx context.Context, name string) error {
 2198  refSpec := config.RefSpec(fmt.Sprintf("refs/tags/%s:refs/tags/%s", name, name))
 2199
 2200  auth := resolveAuth()
 2201
 2202  err := r.repo.Push(&git.PushOptions{
 2203    RemoteName: "origin",
 2204    RefSpecs:   []config.RefSpec{refSpec},
 2205    Auth:       auth,
 2206  })
 2207  // NoErrAlreadyUpToDate is a non-nil sentinel that go-git returns when the
 2208  // remote already has the ref at the same SHA. Treat it as success so that
 2209  // re-runs (where the tag was already pushed in a prior attempt) do not fail.
 0210  if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
 0211    return nil
 0212  }
 2213  return fmt.Errorf("pushing tag %s: %w", name, err)
 214}
 215
 216// HeadHash returns the hash of HEAD.
 1217func (r *Repository) HeadHash(_ context.Context) (string, error) {
 1218  head, err := r.repo.Head()
 0219  if err != nil {
 0220    return "", fmt.Errorf("getting HEAD: %w", err)
 0221  }
 1222  return head.Hash().String(), nil
 223}
 224
 225// RemoteURL returns the remote origin URL.
 2226func (r *Repository) RemoteURL(_ context.Context) (string, error) {
 2227  remote, err := r.repo.Remote("origin")
 1228  if err != nil {
 1229    return "", fmt.Errorf("getting remote: %w", err)
 1230  }
 1231  urls := remote.Config().URLs
 0232  if len(urls) == 0 {
 0233    return "", fmt.Errorf("no URLs configured for remote 'origin'")
 0234  }
 1235  return urls[0], nil
 236}
 237
 238// Stage adds the given file paths to the worktree index.
 3239func (r *Repository) Stage(_ context.Context, files []string) error {
 1240  if len(files) == 0 {
 1241    return nil
 1242  }
 2243  w, err := r.repo.Worktree()
 0244  if err != nil {
 0245    return fmt.Errorf("getting worktree: %w", err)
 0246  }
 2247  for _, file := range files {
 0248    if _, err := w.Add(file); err != nil {
 0249      return fmt.Errorf("staging %s: %w", file, err)
 0250    }
 251  }
 2252  return nil
 253}
 254
 255// Commit creates a commit with the given message from the current index.
 1256func (r *Repository) Commit(_ context.Context, message string) error {
 1257  w, err := r.repo.Worktree()
 0258  if err != nil {
 0259    return fmt.Errorf("getting worktree: %w", err)
 0260  }
 1261  _, err = w.Commit(message, &git.CommitOptions{
 1262    Author: &object.Signature{
 1263      Name:  "semantic-release-bot",
 1264      Email: "semantic-release-bot@users.noreply.github.com",
 1265      When:  time.Now(),
 1266    },
 1267  })
 0268  if err != nil {
 0269    return fmt.Errorf("committing: %w", err)
 0270  }
 1271  return nil
 272}
 273
 274// Push pushes the current branch to origin.
 2275func (r *Repository) Push(ctx context.Context) error {
 2276  auth := resolveAuth()
 2277  err := r.repo.Push(&git.PushOptions{
 2278    RemoteName: "origin",
 2279    Auth:       auth,
 2280  })
 1281  if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) {
 1282    return nil
 1283  }
 1284  return fmt.Errorf("pushing branch: %w", err)
 285}
 286
 287func splitMessage(msg string) (subject, body string) {
 288  parts := strings.SplitN(strings.TrimSpace(msg), "\n", 2)
 289  subject = strings.TrimSpace(parts[0])
 290  if len(parts) > 1 {
 291    body = strings.TrimSpace(parts[1])
 292  }
 293  return subject, body
 294}
 295
 296func resolveAuth() transport.AuthMethod {
 297  // Try token-based auth for HTTPS remotes.
 298  for _, key := range []string{"GH_TOKEN", "GITHUB_TOKEN", "GL_TOKEN", "GITLAB_TOKEN", "BB_TOKEN", "BITBUCKET_TOKEN"} {
 299    if token := os.Getenv(key); token != "" {
 300      return &githttp.BasicAuth{
 301        Username: "git",
 302        Password: token,
 303      }
 304    }
 305  }
 306  return nil
 307}