< Summary - go-semantic-release Coverage

Line coverage
93%
Covered lines: 183
Uncovered lines: 13
Coverable lines: 196
Total lines: 1079
Line coverage: 93.3%
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

File(s)

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/git/cmd_discoverer.go

#LineLine coverage
 1package git
 2
 3import (
 4  "context"
 5  "fmt"
 6  "go/parser"
 7  "go/token"
 8  "io/fs"
 9  "path/filepath"
 10  "slices"
 11  "strings"
 12
 13  "github.com/jedi-knights/go-semantic-release/internal/domain"
 14  "github.com/jedi-knights/go-semantic-release/internal/ports"
 15)
 16
 17// CmdDiscoverer discovers projects in a single-module Go monorepo that follows
 18// the cmd/<service>/main.go layout. It is only activated when:
 19//   - A go.mod file exists at the repository root (single module).
 20//   - No go.work file is present (not a Go workspace — use WorkspaceDiscoverer for those).
 21//   - A cmd/ directory exists at the repository root.
 22//
 23// For each immediate subdirectory of cmd/ that contains a main.go, a
 24// ProjectTypeCmdService project is created. Additionally, any pkg/<name> import
 25// path that is used by more than one service becomes a ProjectTypeCmdLibrary
 26// project and is listed as a dependency of all consuming services.
 27type CmdDiscoverer struct {
 28  fs ports.FileSystem
 29}
 30
 31// NewCmdDiscoverer creates a CmdDiscoverer that reads from the given filesystem.
 1332func NewCmdDiscoverer(fsys ports.FileSystem) *CmdDiscoverer {
 1333  return &CmdDiscoverer{fs: fsys}
 1334}
 35
 36// Discover returns discovered projects. Returns (nil, nil) when the repository
 37// does not match the cmd/ monorepo pattern, or when the cmd/ directory exists
 38// but contains no subdirectory with a main.go.
 39//
 40// Output order: services (sorted by name) followed by shared libraries (sorted
 41// by name). This ordering is stable but is an implementation detail — callers
 42// should not depend on the relative position of a specific project.
 43func (d *CmdDiscoverer) Discover(ctx context.Context, rootPath string) ([]domain.Project, error) {
 44  // Guard 1: single-module — go.mod must exist at root.
 45  if !d.fs.Exists(filepath.Join(rootPath, "go.mod")) {
 46    return nil, nil
 47  }
 48  // Guard 2: not a workspace — go.work must NOT exist.
 49  if d.fs.Exists(filepath.Join(rootPath, "go.work")) {
 50    return nil, nil
 51  }
 52  // Guard 3: cmd/ directory must exist.
 53  cmdDir := filepath.Join(rootPath, "cmd")
 54  if !d.fs.Exists(cmdDir) {
 55    return nil, nil
 56  }
 57
 58  moduleName, err := readModuleName(d.fs, filepath.Join(rootPath, "go.mod"))
 59  if err != nil {
 60    return nil, fmt.Errorf("cmd discoverer: reading module name: %w", err)
 61  }
 62
 63  // Walk cmd/ to find immediate subdirectories that contain a main.go file.
 64  // We collect (serviceName → importedPkgNames) per service.
 65  type serviceInfo struct {
 66    name string   // e.g. "api"
 67    pkgs []string // pkg/ sub-package names imported (e.g. "queue")
 68  }
 69
 70  var services []serviceInfo
 71
 72  walkErr := d.fs.Walk(cmdDir, func(path string, de fs.DirEntry, err error) error {
 73    if err != nil {
 74      return err
 75    }
 76    if ctxErr := ctx.Err(); ctxErr != nil {
 77      return ctxErr
 78    }
 79
 80    rel, relErr := filepath.Rel(cmdDir, path)
 81    if relErr != nil {
 82      return relErr
 83    }
 84    parts := strings.Split(rel, string(filepath.Separator))
 85
 86    // Only interested in main.go files exactly one level below cmd/:
 87    // rel = "<service>/main.go" → parts = ["<service>", "main.go"]
 88    // Service name is always derived from parts[0] so that discovery does
 89    // not depend on the directory entry arriving before its children.
 90    if !de.IsDir() && de.Name() == "main.go" && len(parts) == 2 {
 91      svcName := parts[0]
 92      pkgs, parseErr := d.parsePkgImports(path, moduleName)
 93      if parseErr != nil {
 94        return parseErr
 95      }
 96      services = append(services, serviceInfo{name: svcName, pkgs: pkgs})
 97    }
 98    return nil
 99  })
 100  if walkErr != nil {
 101    return nil, fmt.Errorf("cmd discoverer: walking %s: %w", cmdDir, walkErr)
 102  }
 103
 104  if len(services) == 0 {
 105    return nil, nil
 106  }
 107
 108  // Count how many services use each pkg/ package.
 109  // parsePkgImports already deduplicates within a file, so each entry in
 110  // services[i].pkgs is unique — no additional per-service dedup is needed.
 111  pkgUsage := make(map[string]int) // pkgName → usage count
 112  for i := range services {
 113    for _, pkg := range services[i].pkgs {
 114      pkgUsage[pkg]++
 115    }
 116  }
 117
 118  // Packages used by more than one service become library projects.
 119  sharedPkgs := make(map[string]bool)
 120  for pkg, count := range pkgUsage {
 121    if count > 1 {
 122      sharedPkgs[pkg] = true
 123    }
 124  }
 125
 126  var projects []domain.Project
 127
 128  // Service projects first, sorted by name for determinism.
 129  slices.SortFunc(services, func(a, b serviceInfo) int { return strings.Compare(a.name, b.name) })
 130  for i := range services {
 131    svc := &services[i]
 132    var deps []string
 133    for _, pkg := range svc.pkgs {
 134      if sharedPkgs[pkg] {
 135        deps = append(deps, pkg)
 136      }
 137    }
 138    slices.Sort(deps)
 139
 140    projects = append(projects, domain.Project{
 141      Name:         svc.name,
 142      Path:         filepath.Join("cmd", svc.name),
 143      Type:         domain.ProjectTypeCmdService,
 144      ModulePath:   moduleName,
 145      TagPrefix:    svc.name + "/",
 146      Dependencies: deps,
 147    })
 148  }
 149
 150  // Library projects, sorted by name.
 151  var libNames []string
 152  for name := range sharedPkgs {
 153    libNames = append(libNames, name)
 154  }
 155  slices.Sort(libNames)
 156
 157  for _, name := range libNames {
 158    projects = append(projects, domain.Project{
 159      Name:       name,
 160      Path:       filepath.Join("pkg", name),
 161      Type:       domain.ProjectTypeCmdLibrary,
 162      ModulePath: moduleName,
 163      TagPrefix:  name + "/",
 164    })
 165  }
 166
 167  return projects, nil
 168}
 169
 170// parsePkgImports reads a Go source file and returns the names of any
 171// pkg/<name> sub-packages it imports within the given module.
 172func (d *CmdDiscoverer) parsePkgImports(filePath, moduleName string) ([]string, error) {
 173  data, err := d.fs.ReadFile(filePath)
 174  if err != nil {
 175    return nil, fmt.Errorf("reading %s: %w", filePath, err)
 176  }
 177
 178  fset := token.NewFileSet()
 179  f, err := parser.ParseFile(fset, filePath, data, parser.ImportsOnly)
 180  if err != nil {
 181    return nil, fmt.Errorf("parsing imports in %s: %w", filePath, err)
 182  }
 183
 184  pkgPrefix := moduleName + "/pkg/"
 185  seen := make(map[string]bool)
 186  var pkgs []string
 187  for _, imp := range f.Imports {
 188    path := strings.Trim(imp.Path.Value, `"`)
 189    if rest, ok := strings.CutPrefix(path, pkgPrefix); ok {
 190      // Extract the immediate sub-package name: "module/pkg/queue/sub" → "queue".
 191      name := strings.SplitN(rest, "/", 2)[0]
 192      if name != "" && !seen[name] {
 193        seen[name] = true
 194        pkgs = append(pkgs, name)
 195      }
 196    }
 197  }
 198  return pkgs, nil
 199}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/git/commit_parser.go

#LineLine coverage
 1package git
 2
 3import (
 4  "regexp"
 5  "strings"
 6
 7  "github.com/jedi-knights/go-semantic-release/internal/domain"
 8  "github.com/jedi-knights/go-semantic-release/internal/ports"
 9)
 10
 11// Compile-time interface compliance check.
 12var _ ports.CommitParser = (*ConventionalCommitParser)(nil)
 13
 14var conventionalCommitRe = regexp.MustCompile(
 15  `^(?P<type>\w+)` +
 16    `(?:\((?P<scope>[^)]*)\))?` +
 17    `(?P<breaking>!)?` +
 18    `:\s*(?P<description>.+)$`,
 19)
 20
 21// ConventionalCommitParser implements ports.CommitParser for Conventional Commits.
 22type ConventionalCommitParser struct{}
 23
 24// NewConventionalCommitParser creates a new parser.
 125func NewConventionalCommitParser() *ConventionalCommitParser {
 126  return &ConventionalCommitParser{}
 127}
 28
 29func (p *ConventionalCommitParser) Parse(message string) (domain.Commit, error) {
 30  lines := strings.SplitN(message, "\n", 2)
 31  subject := strings.TrimSpace(lines[0])
 32
 33  matches := conventionalCommitRe.FindStringSubmatch(subject)
 34  if matches == nil {
 35    return domain.Commit{
 36      Message:     subject,
 37      Description: subject,
 38    }, nil
 39  }
 40
 41  commit := domain.Commit{
 42    Message:     subject,
 43    Type:        matches[1],
 44    Scope:       matches[2],
 45    Description: matches[4],
 46  }
 47
 48  // Check for ! marker.
 49  if matches[3] == "!" {
 50    commit.IsBreakingChange = true
 51  }
 52
 53  // Parse body and footer.
 54  if len(lines) > 1 {
 55    body := strings.TrimSpace(lines[1])
 56    commit.Body, commit.Footer = splitBodyFooter(body)
 57    detectBreakingChange(&commit)
 58  }
 59
 60  return commit, nil
 61}
 62
 463func splitBodyFooter(text string) (body, footer string) {
 464  // Footer is separated from body by a blank line and starts with a token.
 465  parts := strings.Split(text, "\n\n")
 266  if len(parts) <= 1 {
 267    return text, ""
 268  }
 69
 270  lastPart := parts[len(parts)-1]
 271  if isFooter(lastPart) {
 272    return strings.Join(parts[:len(parts)-1], "\n\n"), lastPart
 273  }
 074  return text, ""
 75}
 76
 77var footerTokenRe = regexp.MustCompile(`^[\w-]+(?:: | #)`)
 78
 279func isFooter(text string) bool {
 280  lines := strings.Split(text, "\n")
 081  if len(lines) == 0 {
 082    return false
 083  }
 284  return footerTokenRe.MatchString(lines[0]) ||
 285    strings.HasPrefix(lines[0], "BREAKING CHANGE:") ||
 286    strings.HasPrefix(lines[0], "BREAKING-CHANGE:")
 87}
 88
 489func detectBreakingChange(commit *domain.Commit) {
 490  for _, prefix := range []string{"BREAKING CHANGE:", "BREAKING-CHANGE:"} {
 191    if note := findBreakingNote(commit.Footer, prefix); note != "" {
 192      commit.IsBreakingChange = true
 193      commit.BreakingNote = note
 194      return
 195    }
 296    if note := findBreakingNote(commit.Body, prefix); note != "" {
 297      commit.IsBreakingChange = true
 298      commit.BreakingNote = note
 299      return
 2100    }
 101  }
 102}
 103
 11104func findBreakingNote(text, prefix string) string {
 11105  for _, line := range strings.Split(text, "\n") {
 11106    line = strings.TrimSpace(line)
 3107    if strings.HasPrefix(line, prefix) {
 3108      return strings.TrimSpace(strings.TrimPrefix(line, prefix))
 3109    }
 110  }
 8111  return ""
 112}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/git/impact_analyzer.go

#LineLine coverage
 1package git
 2
 3import (
 4  "path/filepath"
 5  "strings"
 6
 7  "github.com/jedi-knights/go-semantic-release/internal/domain"
 8  "github.com/jedi-knights/go-semantic-release/internal/ports"
 9)
 10
 11// Compile-time interface compliance check.
 12var _ ports.ProjectImpactAnalyzer = (*PathBasedImpactAnalyzer)(nil)
 13
 14// PathBasedImpactAnalyzer maps changed files to projects based on path prefixes.
 15type PathBasedImpactAnalyzer struct {
 16  propagateDeps bool
 17  includePaths  []string
 18  excludePaths  []string
 19}
 20
 21// NewPathBasedImpactAnalyzer creates a new path-based impact analyzer.
 822func NewPathBasedImpactAnalyzer(propagateDeps bool, includePaths, excludePaths []string) *PathBasedImpactAnalyzer {
 823  return &PathBasedImpactAnalyzer{
 824    propagateDeps: propagateDeps,
 825    includePaths:  includePaths,
 826    excludePaths:  excludePaths,
 827  }
 828}
 29
 30func (a *PathBasedImpactAnalyzer) Analyze(projects []domain.Project, commits []domain.Commit) map[string][]domain.Commit
 31  result := make(map[string][]domain.Commit)
 32
 33  for i := range commits {
 34    affected := a.findAffectedProjects(projects, commits[i].FilesChanged)
 35    for _, projName := range affected {
 36      result[projName] = append(result[projName], commits[i])
 37    }
 38  }
 39
 40  if a.propagateDeps {
 41    a.propagateDependencies(projects, result)
 42  }
 43
 44  return result
 45}
 46
 47func (a *PathBasedImpactAnalyzer) findAffectedProjects(projects []domain.Project, files []string) []string {
 48  seen := make(map[string]bool)
 49  var affected []string
 50
 51  filtered := a.filterFiles(files)
 52  for _, file := range filtered {
 53    for _, proj := range projects {
 54      if seen[proj.Name] {
 55        continue
 56      }
 57      if proj.IsRoot() || fileInProject(file, proj.Path) {
 58        seen[proj.Name] = true
 59        affected = append(affected, proj.Name)
 60      }
 61    }
 62  }
 63  return affected
 64}
 65
 66// filterFiles applies include/exclude glob patterns to the file list.
 67func (a *PathBasedImpactAnalyzer) filterFiles(files []string) []string {
 68  if len(a.includePaths) == 0 && len(a.excludePaths) == 0 {
 69    return files
 70  }
 71
 72  result := make([]string, 0, len(files))
 73  for _, file := range files {
 74    if len(a.includePaths) > 0 && !matchesAny(file, a.includePaths) {
 75      continue
 76    }
 77    if matchesAny(file, a.excludePaths) {
 78      continue
 79    }
 80    result = append(result, file)
 81  }
 82  return result
 83}
 84
 85// matchesAny returns true if the file matches any of the glob patterns.
 2986func matchesAny(file string, patterns []string) bool {
 2587  for _, pattern := range patterns {
 688    if matched, _ := filepath.Match(pattern, file); matched {
 689      return true
 690    }
 91    // Also try matching against just the filename for simple patterns.
 192    if matched, _ := filepath.Match(pattern, filepath.Base(file)); matched {
 193      return true
 194    }
 95    // Support prefix-based patterns like "services/api/**" by checking prefix.
 1696    if strings.HasSuffix(pattern, "/**") {
 1697      prefix := strings.TrimSuffix(pattern, "/**")
 498      if strings.HasPrefix(file, prefix+"/") || file == prefix {
 499        return true
 4100      }
 101    }
 102  }
 18103  return false
 104}
 105
 52106func fileInProject(file, projectPath string) bool {
 0107  if projectPath == "" || projectPath == "." {
 0108    return true
 0109  }
 52110  prefix := projectPath + "/"
 52111  return strings.HasPrefix(file, prefix) || file == projectPath
 112}
 113
 114func (a *PathBasedImpactAnalyzer) propagateDependencies(projects []domain.Project, result map[string][]domain.Commit) {
 115  projectMap := make(map[string]domain.Project, len(projects))
 116  for _, p := range projects {
 117    projectMap[p.Name] = p
 118  }
 119
 120  // Simple single-pass propagation: if a dependency has commits, mark dependents.
 121  for _, proj := range projects {
 122    for _, dep := range proj.Dependencies {
 123      if commits, ok := result[dep]; ok && len(commits) > 0 {
 124        if _, alreadyAffected := result[proj.Name]; !alreadyAffected {
 125          // Propagate with a synthetic marker — use the dependency's commits.
 126          result[proj.Name] = commits
 127        }
 128      }
 129    }
 130  }
 131}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/git/project_discoverer.go

#LineLine coverage
 1package git
 2
 3import (
 4  "bufio"
 5  "bytes"
 6  "context"
 7  "errors"
 8  "fmt"
 9  "io/fs"
 10  "path/filepath"
 11  "strings"
 12
 13  "github.com/jedi-knights/go-semantic-release/internal/domain"
 14  "github.com/jedi-knights/go-semantic-release/internal/ports"
 15)
 16
 17// ErrNoModuleDirective is returned by readModuleName when the go.mod file
 18// contains no module directive. Callers can use errors.Is to distinguish this
 19// from I/O failures.
 20var ErrNoModuleDirective = errors.New("no module directive found")
 21
 22// Compile-time interface compliance checks for all ProjectDiscoverer implementations
 23// in this package.
 24var (
 25  _ ports.ProjectDiscoverer = (*WorkspaceDiscoverer)(nil)
 26  _ ports.ProjectDiscoverer = (*ModuleDiscoverer)(nil)
 27  _ ports.ProjectDiscoverer = (*ConfiguredDiscoverer)(nil)
 28  _ ports.ProjectDiscoverer = (*CompositeDiscoverer)(nil)
 29  _ ports.ProjectDiscoverer = (*CmdDiscoverer)(nil)
 30)
 31
 32// WorkspaceDiscoverer discovers projects from go.work files.
 33type WorkspaceDiscoverer struct {
 34  fs ports.FileSystem
 35}
 36
 37// NewWorkspaceDiscoverer creates a discoverer for go workspace monorepos.
 738func NewWorkspaceDiscoverer(fsys ports.FileSystem) *WorkspaceDiscoverer {
 739  return &WorkspaceDiscoverer{fs: fsys}
 740}
 41
 42func (d *WorkspaceDiscoverer) Discover(ctx context.Context, rootPath string) ([]domain.Project, error) {
 43  workFile := filepath.Join(rootPath, "go.work")
 44  if !d.fs.Exists(workFile) {
 45    return nil, nil
 46  }
 47
 48  data, err := d.fs.ReadFile(workFile)
 49  if err != nil {
 50    return nil, fmt.Errorf("reading go.work: %w", err)
 51  }
 52
 53  dirs, err := parseGoWorkUse(data)
 54  if err != nil {
 55    return nil, fmt.Errorf("parsing go.work: %w", err)
 56  }
 57  projects := make([]domain.Project, 0, len(dirs))
 58
 59  // Context is only checked between loop iterations. The ReadFile and
 60  // parseGoWorkUse calls above the loop do not respect cancellation; this is
 61  // acceptable for the typically small go.work files seen in practice.
 62  for _, dir := range dirs {
 63    select {
 64    case <-ctx.Done():
 65      return nil, ctx.Err()
 66    default:
 67    }
 68    modPath := filepath.Join(rootPath, dir, "go.mod")
 69    moduleName, err := readModuleName(d.fs, modPath)
 70    if err != nil {
 71      return nil, fmt.Errorf("reading module name from %s: %w", modPath, err)
 72    }
 73    // filepath.Clean strips the leading "./" from go.work use entries
 74    // (e.g. "./svc-api" → "svc-api") so that project names and tag prefixes
 75    // are consistent with ModuleDiscoverer, which uses filepath.Rel for the
 76    // same normalisation. Without this, tag names would contain an invalid
 77    // "./" segment (e.g. "./svc-api/v1.0.0").
 78    cleanDir := filepath.Clean(dir)
 79    name := cleanDir
 80    tagPrefix := cleanDir + "/"
 81    if cleanDir == "." {
 82      name = filepath.Base(rootPath)
 83      if name == "/" || name == "." {
 84        name = "root"
 85      }
 86      // Root project has no tag prefix, matching ModuleDiscoverer behavior.
 87      tagPrefix = ""
 88    }
 89
 90    projects = append(projects, domain.Project{
 91      Name:       name,
 92      Path:       cleanDir,
 93      Type:       domain.ProjectTypeGoWorkspace,
 94      ModulePath: moduleName,
 95      TagPrefix:  tagPrefix,
 96    })
 97  }
 98  return projects, nil
 99}
 100
 101// parseGoWorkUse extracts "use" directives from a go.work file.
 102// Returns (dirs, error) where error is non-nil only on scanner I/O failure or
 103// a line exceeding bufio.MaxScanTokenSize (64 KiB) — not on parse/format issues.
 6104func parseGoWorkUse(content []byte) ([]string, error) {
 6105  var dirs []string
 6106  scanner := bufio.NewScanner(bytes.NewReader(content))
 6107  inUseBlock := false
 6108
 6109  for scanner.Scan() {
 31110    line := strings.TrimSpace(scanner.Text())
 31111
 31112    // noSpaces is used only to detect "use(" with arbitrary spacing between
 31113    // the keyword and the opening paren. It is NOT used for directory parsing —
 31114    // dir is always derived from the original line so paths with spaces are
 31115    // preserved correctly.
 31116    noSpaces := strings.ReplaceAll(line, " ", "")
 4117    if noSpaces == "use(" {
 4118      inUseBlock = true
 4119      continue
 120    }
 4121    if inUseBlock && line == ")" {
 4122      inUseBlock = false
 4123      continue
 124    }
 9125    if inUseBlock {
 9126      dir := strings.TrimSpace(line)
 2127      if strings.HasPrefix(dir, "//") {
 2128        continue
 129      }
 130      // Strip inline comments (e.g. "./path // comment" → "./path").
 2131      if idx := strings.Index(dir, "//"); idx >= 0 {
 2132        dir = strings.TrimSpace(dir[:idx])
 2133      }
 7134      if dir != "" {
 7135        dirs = append(dirs, dir)
 7136      }
 7137      continue
 138    }
 139    // Single-line form: "use ./path". The go toolchain accepts both a space
 140    // and a tab after "use", so we check for both. We avoid "used ./path"
 141    // by requiring that the separator character immediately follows "use".
 2142    if (strings.HasPrefix(line, "use ") || strings.HasPrefix(line, "use\t")) && !strings.Contains(line, "(") {
 2143      dir := strings.TrimSpace(line[len("use"):])
 2144      if dir != "" {
 2145        dirs = append(dirs, dir)
 2146      }
 147    }
 148  }
 0149  if err := scanner.Err(); err != nil {
 0150    return nil, fmt.Errorf("scanning go.work: %w", err)
 0151  }
 6152  return dirs, nil
 153}
 154
 155// ModuleDiscoverer discovers projects by finding go.mod files recursively.
 156type ModuleDiscoverer struct {
 157  fs ports.FileSystem
 158}
 159
 160// NewModuleDiscoverer creates a discoverer for nested go.mod monorepos.
 4161func NewModuleDiscoverer(fsys ports.FileSystem) *ModuleDiscoverer {
 4162  return &ModuleDiscoverer{fs: fsys}
 4163}
 164
 165func (d *ModuleDiscoverer) Discover(ctx context.Context, rootPath string) ([]domain.Project, error) {
 166  // filepath.Glob does not support recursive "**" patterns — it treats "**" as a
 167  // literal directory name, not a recursive wildcard. Walk is used instead so that
 168  // go.mod files at any depth are discovered correctly.
 169  //
 170  // Context is checked inside the WalkDirFunc: returning an error from the func is
 171  // the standard mechanism to abort a Walk early. The Walk return value propagates
 172  // the cancellation error to the caller below.
 173  //
 174  // The returned project order mirrors the Walk order (lexicographic, depth-first).
 175  // The root go.mod is visited before nested ones, so projects[0].Type == Root
 176  // holds for any repo with a root module. Tests that rely on this must not sort
 177  // the result.
 178  var matches []string
 179  walkErr := d.fs.Walk(rootPath, func(path string, de fs.DirEntry, err error) error {
 180    if err != nil {
 181      return err
 182    }
 183    if ctxErr := ctx.Err(); ctxErr != nil {
 184      return ctxErr
 185    }
 186    if !de.IsDir() && de.Name() == "go.mod" {
 187      matches = append(matches, path)
 188    }
 189    return nil
 190  })
 191  if walkErr != nil {
 192    return nil, fmt.Errorf("scanning for go.mod files: %w", walkErr)
 193  }
 194
 195  projects := make([]domain.Project, 0, len(matches))
 196  for _, match := range matches {
 197    // No per-iteration ctx check needed here: the Walk callback above already
 198    // propagates cancellation, so walkErr will be non-nil and the function
 199    // returns before reaching this loop if the context was cancelled.
 200    rel, err := filepath.Rel(rootPath, filepath.Dir(match))
 201    if err != nil {
 202      return nil, fmt.Errorf("computing relative path for %s: %w", match, err)
 203    }
 204
 205    moduleName, err := readModuleName(d.fs, match)
 206    if err != nil {
 207      return nil, fmt.Errorf("reading module name from %s: %w", match, err)
 208    }
 209    name := rel
 210    projType := domain.ProjectTypeGoModule
 211    tagPrefix := name + "/"
 212
 213    if rel == "." {
 214      name = filepath.Base(rootPath)
 215      if name == "/" || name == "." {
 216        name = "root"
 217      }
 218      projType = domain.ProjectTypeRoot
 219      tagPrefix = ""
 220    }
 221
 222    projects = append(projects, domain.Project{
 223      Name:       name,
 224      Path:       rel,
 225      Type:       projType,
 226      ModulePath: moduleName,
 227      TagPrefix:  tagPrefix,
 228    })
 229  }
 230  return projects, nil
 231}
 232
 233// ConfiguredDiscoverer creates projects from static config definitions.
 234type ConfiguredDiscoverer struct {
 235  projects []domain.ProjectConfig
 236}
 237
 238// NewConfiguredDiscoverer creates a discoverer from config-defined projects.
 4239func NewConfiguredDiscoverer(projects []domain.ProjectConfig) *ConfiguredDiscoverer {
 4240  return &ConfiguredDiscoverer{projects: projects}
 4241}
 242
 243// Discover returns the statically configured projects. The context is intentionally
 244// ignored because this discoverer operates on in-memory data with no I/O.
 245// rootPath is also ignored: project paths come directly from config and are
 246// resolved relative to the repo root by callers.
 247func (d *ConfiguredDiscoverer) Discover(_ context.Context, _ string) ([]domain.Project, error) {
 248  result := make([]domain.Project, 0, len(d.projects))
 249  for _, pc := range d.projects {
 250    prefix := pc.TagPrefix
 251    if prefix == "" {
 252      prefix = pc.Name + "/"
 253    }
 254    // Clean the path from config so that IsRoot() and tag-prefix logic
 255    // receive a normalised value. filepath.Clean("./services/api") →
 256    // "services/api", matching what ModuleDiscoverer produces via filepath.Rel.
 257    result = append(result, domain.Project{
 258      Name:          pc.Name,
 259      Path:          filepath.Clean(pc.Path),
 260      Type:          domain.ProjectTypeConfigured,
 261      Dependencies:  pc.Dependencies,
 262      TagPrefix:     prefix,
 263      ChangelogFile: pc.ChangelogFile,
 264    })
 265  }
 266  return result, nil
 267}
 268
 269// readModuleName reads the module directive from a go.mod file.
 270// It returns an error if the file cannot be read or has no module directive.
 23271func readModuleName(fsys ports.FileSystem, modFile string) (string, error) {
 23272  data, err := fsys.ReadFile(modFile)
 1273  if err != nil {
 1274    return "", fmt.Errorf("reading %s: %w", modFile, err)
 1275  }
 22276  scanner := bufio.NewScanner(bytes.NewReader(data))
 22277  for scanner.Scan() {
 22278    line := strings.TrimSpace(scanner.Text())
 22279    // HasPrefix and TrimPrefix both use "module " (with a trailing space) so the
 22280    // guard and the trim are consistent: a line like "modulex github.com/..." is
 22281    // not matched, and the returned name has no leading space.
 21282    if strings.HasPrefix(line, "module ") {
 21283      return strings.TrimSpace(strings.TrimPrefix(line, "module ")), nil
 21284    }
 285  }
 0286  if scanErr := scanner.Err(); scanErr != nil {
 0287    return "", fmt.Errorf("scanning %s: %w", modFile, scanErr)
 0288  }
 1289  return "", fmt.Errorf("%w in %s", ErrNoModuleDirective, modFile)
 290}
 291
 292// CompositeDiscoverer tries multiple discoverers in order and returns the first non-empty result.
 293type CompositeDiscoverer struct {
 294  discoverers []ports.ProjectDiscoverer
 295}
 296
 297// NewCompositeDiscoverer chains discoverers. First non-empty result wins.
 1298func NewCompositeDiscoverer(discoverers ...ports.ProjectDiscoverer) *CompositeDiscoverer {
 1299  return &CompositeDiscoverer{discoverers: discoverers}
 1300}
 301
 302// Discover runs each discoverer in order and returns the first non-empty result.
 303// Returns (nil, nil) when no discoverer finds any projects — callers should treat
 304// this as "nothing found", not as an error or "unsupported" state.
 305func (d *CompositeDiscoverer) Discover(ctx context.Context, rootPath string) ([]domain.Project, error) {
 306  for i, disc := range d.discoverers {
 307    projects, err := disc.Discover(ctx, rootPath)
 308    if err != nil {
 309      return nil, fmt.Errorf("discoverer[%d] %T: %w", i, disc, err)
 310    }
 311    if len(projects) > 0 {
 312      return projects, nil
 313    }
 314  }
 315  return nil, nil
 316}

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

#LineLine coverage
 1package git
 2
 3import (
 4  "bytes"
 5  "context"
 6  "fmt"
 7  "os/exec"
 8  "strings"
 9  "time"
 10
 11  "github.com/jedi-knights/go-semantic-release/internal/domain"
 12  "github.com/jedi-knights/go-semantic-release/internal/ports"
 13)
 14
 15// Compile-time interface compliance check.
 16var _ ports.GitRepository = (*Repository)(nil)
 17
 18// Repository implements ports.GitRepository using the git CLI.
 19type Repository struct {
 20  workDir string
 21}
 22
 23// NewRepository creates a new git CLI adapter.
 1624func NewRepository(workDir string) *Repository {
 1625  return &Repository{workDir: workDir}
 1626}
 27
 28func (r *Repository) run(ctx context.Context, args ...string) (string, error) {
 29  cmd := exec.CommandContext(ctx, "git", args...)
 30  cmd.Dir = r.workDir
 31  var stdout, stderr bytes.Buffer
 32  cmd.Stdout = &stdout
 33  cmd.Stderr = &stderr
 34  if err := cmd.Run(); err != nil {
 35    return "", fmt.Errorf("git %s: %s: %w", strings.Join(args, " "), stderr.String(), err)
 36  }
 37  return strings.TrimSpace(stdout.String()), nil
 38}
 39
 40func (r *Repository) CurrentBranch(ctx context.Context) (string, error) {
 41  return r.run(ctx, "rev-parse", "--abbrev-ref", "HEAD")
 42}
 43
 44func (r *Repository) ListTags(ctx context.Context) ([]domain.Tag, error) {
 45  output, err := r.run(ctx, "tag", "--list", "--sort=-version:refname")
 46  if err != nil {
 47    return nil, err
 48  }
 49  if output == "" {
 50    return nil, nil
 51  }
 52
 53  lines := strings.Split(output, "\n")
 54  tags := make([]domain.Tag, 0, len(lines))
 55  for _, line := range lines {
 56    line = strings.TrimSpace(line)
 57    if line == "" {
 58      continue
 59    }
 60    hash, err := r.run(ctx, "rev-list", "-1", line)
 61    if err != nil {
 62      return nil, fmt.Errorf("resolving tag %s: %w", line, err)
 63    }
 64    tags = append(tags, domain.Tag{
 65      Name: line,
 66      Hash: hash,
 67    })
 68  }
 69  return tags, nil
 70}
 71
 72func (r *Repository) CommitsSince(ctx context.Context, sinceHash string) ([]domain.Commit, error) {
 73  args := []string{"log", "--format=%H|%an|%ae|%aI|%s|%b%x00"}
 74  if sinceHash != "" {
 75    args = append(args, sinceHash+"..HEAD")
 76  }
 77
 78  output, err := r.run(ctx, args...)
 79  if err != nil {
 80    return nil, err
 81  }
 82  if output == "" {
 83    return nil, nil
 84  }
 85
 86  return parseCommitLog(output)
 87}
 88
 689func parseCommitLog(output string) ([]domain.Commit, error) {
 690  entries := strings.Split(output, "\x00")
 691  commits := make([]domain.Commit, 0, len(entries))
 692
 693  for _, entry := range entries {
 1294    entry = strings.TrimSpace(entry)
 495    if entry == "" {
 496      continue
 97    }
 98
 899    commit, err := parseCommitEntry(entry)
 1100    if err != nil {
 1101      continue // skip unparseable entries
 102    }
 7103    commits = append(commits, commit)
 104  }
 6105  return commits, nil
 106}
 107
 12108func parseCommitEntry(entry string) (domain.Commit, error) {
 12109  // First line: hash|author|email|date|subject
 12110  // Remaining: body
 12111  lines := strings.SplitN(entry, "\n", 2)
 12112  firstLine := lines[0]
 12113
 12114  parts := strings.SplitN(firstLine, "|", 6)
 2115  if len(parts) < 5 {
 2116    return domain.Commit{}, fmt.Errorf("unexpected commit format: %q", firstLine)
 2117  }
 118
 10119  date, err := time.Parse(time.RFC3339, parts[3])
 1120  if err != nil {
 1121    return domain.Commit{}, fmt.Errorf("parsing commit date %q: %w", parts[3], err)
 1122  }
 123
 9124  body := ""
 9125  if len(parts) >= 6 {
 9126    body = parts[5]
 9127  }
 1128  if len(lines) > 1 {
 1129    body = body + "\n" + lines[1]
 1130  }
 131
 9132  return domain.Commit{
 9133    Hash:        parts[0],
 9134    Author:      parts[1],
 9135    AuthorEmail: parts[2],
 9136    Date:        date,
 9137    Message:     parts[4],
 9138    Body:        strings.TrimSpace(body),
 9139  }, nil
 140}
 141
 142func (r *Repository) FilesChangedInCommit(ctx context.Context, hash string) ([]string, error) {
 143  output, err := r.run(ctx, "diff-tree", "--no-commit-id", "--name-only", "-r", hash)
 144  if err != nil {
 145    return nil, err
 146  }
 147  if output == "" {
 148    return nil, nil
 149  }
 150  lines := strings.Split(output, "\n")
 151  result := make([]string, 0, len(lines))
 152  for _, l := range lines {
 153    if l != "" {
 154      result = append(result, l)
 155    }
 156  }
 157  return result, nil
 158}
 159
 160func (r *Repository) CreateTag(ctx context.Context, name, hash, message string) error {
 161  var err error
 162  if message != "" {
 163    _, err = r.run(ctx, "tag", "-a", name, hash, "-m", message)
 164  } else {
 165    _, err = r.run(ctx, "tag", name, hash)
 166  }
 167  if err == nil {
 168    return nil
 169  }
 170  // When the tag already exists, check whether it resolves to the same commit.
 171  // If it does, the operation is idempotent — return ErrTagAlreadyExists so
 172  // the caller can handle the re-run case without treating it as a hard failure.
 173  if strings.Contains(err.Error(), "already exists") {
 174    existing, resolveErr := r.run(ctx, "rev-parse", name+"^{commit}")
 175    if resolveErr == nil && existing == hash {
 176      return domain.ErrTagAlreadyExists
 177    }
 178  }
 179  return err
 180}
 181
 182func (r *Repository) PushTag(ctx context.Context, name string) error {
 183  _, err := r.run(ctx, "push", "origin", name)
 184  return err
 185}
 186
 187func (r *Repository) HeadHash(ctx context.Context) (string, error) {
 188  return r.run(ctx, "rev-parse", "HEAD")
 189}
 190
 191func (r *Repository) RemoteURL(ctx context.Context) (string, error) {
 192  return r.run(ctx, "remote", "get-url", "origin")
 193}
 194
 195func (r *Repository) Stage(ctx context.Context, files []string) error {
 196  if len(files) == 0 {
 197    return nil
 198  }
 199  args := append([]string{"add", "--"}, files...)
 200  _, err := r.run(ctx, args...)
 201  return err
 202}
 203
 204func (r *Repository) Commit(ctx context.Context, message string) error {
 205  _, err := r.run(ctx, "commit", "-m", message)
 206  return err
 207}
 208
 209func (r *Repository) Push(ctx context.Context) error {
 210  _, err := r.run(ctx, "push", "origin", "HEAD")
 211  return err
 212}

/home/runner/work/go-semantic-release/go-semantic-release/internal/adapters/git/tag_service.go

#LineLine coverage
 1package git
 2
 3import (
 4  "bytes"
 5  "fmt"
 6  "strings"
 7  "text/template"
 8
 9  "github.com/jedi-knights/go-semantic-release/internal/domain"
 10  "github.com/jedi-knights/go-semantic-release/internal/ports"
 11)
 12
 13// Compile-time interface compliance check.
 14var _ ports.TagService = (*TemplateTagService)(nil)
 15
 16// TemplateTagService formats and parses tags using Go templates.
 17type TemplateTagService struct {
 18  repoTemplate    string
 19  projectTemplate string
 20}
 21
 22// NewTemplateTagService creates a tag service with configurable templates.
 623func NewTemplateTagService(repoTemplate, projectTemplate string) *TemplateTagService {
 524  if repoTemplate == "" {
 525    repoTemplate = "v{{.Version}}"
 526  }
 527  if projectTemplate == "" {
 528    projectTemplate = "{{.Project}}/v{{.Version}}"
 529  }
 630  return &TemplateTagService{
 631    repoTemplate:    repoTemplate,
 632    projectTemplate: projectTemplate,
 633  }
 34}
 35
 36type tagData struct {
 37  Project string
 38  Version string
 39}
 40
 41func (s *TemplateTagService) FormatTag(project string, version domain.Version) (string, error) {
 42  tmplStr := s.repoTemplate
 43  if project != "" {
 44    tmplStr = s.projectTemplate
 45  }
 46
 47  tmpl, err := template.New("tag").Parse(tmplStr)
 48  if err != nil {
 49    return "", fmt.Errorf("parsing tag template: %w", err)
 50  }
 51
 52  var buf bytes.Buffer
 53  data := tagData{Project: project, Version: version.String()}
 54  if err := tmpl.Execute(&buf, data); err != nil {
 55    return "", fmt.Errorf("executing tag template: %w", err)
 56  }
 57  return buf.String(), nil
 58}
 59
 60func (s *TemplateTagService) ParseTag(tagName string) (string, domain.Version, error) {
 61  // Try project-scoped patterns first.
 62  // Pattern: project/vX.Y.Z
 63  if idx := strings.Index(tagName, "/v"); idx > 0 {
 64    project := tagName[:idx]
 65    ver, err := domain.ParseVersion(tagName[idx+1:])
 66    if err == nil {
 67      return project, ver, nil
 68    }
 69  }
 70
 71  // Pattern: project@X.Y.Z
 72  if idx := strings.LastIndex(tagName, "@"); idx > 0 {
 73    project := tagName[:idx]
 74    ver, err := domain.ParseVersion(tagName[idx+1:])
 75    if err == nil {
 76      return project, ver, nil
 77    }
 78  }
 79
 80  // Repo-level: vX.Y.Z or X.Y.Z
 81  ver, err := domain.ParseVersion(tagName)
 82  if err != nil {
 83    return "", domain.Version{}, fmt.Errorf("cannot parse tag %q: %w", tagName, err)
 84  }
 85  return "", ver, nil
 86}
 87
 88func (s *TemplateTagService) FindLatestTag(tags []domain.Tag, project string) (*domain.Tag, error) {
 89  var latest *domain.Tag
 90
 91  for i := range tags {
 92    proj, ver, err := s.ParseTag(tags[i].Name)
 93    if err != nil {
 94      continue
 95    }
 96    if proj != project {
 97      continue
 98    }
 99
 100    tags[i].Version = ver
 101    tags[i].Project = proj
 102
 103    if latest == nil || ver.GreaterThan(latest.Version) {
 104      t := tags[i]
 105      latest = &t
 106    }
 107  }
 108  return latest, nil
 109}