< Summary - go-semantic-release Coverage

Line coverage
90%
Covered lines: 112
Uncovered lines: 12
Coverable lines: 124
Total lines: 199
Line coverage: 90.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

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
Discover0%0091.09%
parsePkgImports0%0086.96%

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.
 32func NewCmdDiscoverer(fsys ports.FileSystem) *CmdDiscoverer {
 33  return &CmdDiscoverer{fs: fsys}
 34}
 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.
 1343func (d *CmdDiscoverer) Discover(ctx context.Context, rootPath string) ([]domain.Project, error) {
 1344  // Guard 1: single-module — go.mod must exist at root.
 145  if !d.fs.Exists(filepath.Join(rootPath, "go.mod")) {
 146    return nil, nil
 147  }
 48  // Guard 2: not a workspace — go.work must NOT exist.
 149  if d.fs.Exists(filepath.Join(rootPath, "go.work")) {
 150    return nil, nil
 151  }
 52  // Guard 3: cmd/ directory must exist.
 1153  cmdDir := filepath.Join(rootPath, "cmd")
 154  if !d.fs.Exists(cmdDir) {
 155    return nil, nil
 156  }
 57
 1058  moduleName, err := readModuleName(d.fs, filepath.Join(rootPath, "go.mod"))
 159  if err != nil {
 160    return nil, fmt.Errorf("cmd discoverer: reading module name: %w", err)
 161  }
 62
 63  // Walk cmd/ to find immediate subdirectories that contain a main.go file.
 64  // We collect (serviceName → importedPkgNames) per service.
 965  type serviceInfo struct {
 966    name string   // e.g. "api"
 967    pkgs []string // pkg/ sub-package names imported (e.g. "queue")
 968  }
 969
 970  var services []serviceInfo
 971
 972  walkErr := d.fs.Walk(cmdDir, func(path string, de fs.DirEntry, err error) error {
 073    if err != nil {
 074      return err
 075    }
 176    if ctxErr := ctx.Err(); ctxErr != nil {
 177      return ctxErr
 178    }
 79
 1480    rel, relErr := filepath.Rel(cmdDir, path)
 081    if relErr != nil {
 082      return relErr
 083    }
 1484    parts := strings.Split(rel, string(filepath.Separator))
 1485
 1486    // Only interested in main.go files exactly one level below cmd/:
 1487    // rel = "<service>/main.go" → parts = ["<service>", "main.go"]
 1488    // Service name is always derived from parts[0] so that discovery does
 1489    // not depend on the directory entry arriving before its children.
 790    if !de.IsDir() && de.Name() == "main.go" && len(parts) == 2 {
 791      svcName := parts[0]
 792      pkgs, parseErr := d.parsePkgImports(path, moduleName)
 193      if parseErr != nil {
 194        return parseErr
 195      }
 696      services = append(services, serviceInfo{name: svcName, pkgs: pkgs})
 97    }
 1398    return nil
 99  })
 4100  if walkErr != nil {
 4101    return nil, fmt.Errorf("cmd discoverer: walking %s: %w", cmdDir, walkErr)
 4102  }
 103
 0104  if len(services) == 0 {
 0105    return nil, nil
 0106  }
 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.
 5111  pkgUsage := make(map[string]int) // pkgName → usage count
 5112  for i := range services {
 4113    for _, pkg := range services[i].pkgs {
 4114      pkgUsage[pkg]++
 4115    }
 116  }
 117
 118  // Packages used by more than one service become library projects.
 5119  sharedPkgs := make(map[string]bool)
 3120  for pkg, count := range pkgUsage {
 1121    if count > 1 {
 1122      sharedPkgs[pkg] = true
 1123    }
 124  }
 125
 5126  var projects []domain.Project
 5127
 5128  // Service projects first, sorted by name for determinism.
 1129  slices.SortFunc(services, func(a, b serviceInfo) int { return strings.Compare(a.name, b.name) })
 5130  for i := range services {
 6131    svc := &services[i]
 6132    var deps []string
 4133    for _, pkg := range svc.pkgs {
 2134      if sharedPkgs[pkg] {
 2135        deps = append(deps, pkg)
 2136      }
 137    }
 6138    slices.Sort(deps)
 6139
 6140    projects = append(projects, domain.Project{
 6141      Name:         svc.name,
 6142      Path:         filepath.Join("cmd", svc.name),
 6143      Type:         domain.ProjectTypeCmdService,
 6144      ModulePath:   moduleName,
 6145      TagPrefix:    svc.name + "/",
 6146      Dependencies: deps,
 6147    })
 148  }
 149
 150  // Library projects, sorted by name.
 5151  var libNames []string
 1152  for name := range sharedPkgs {
 1153    libNames = append(libNames, name)
 1154  }
 5155  slices.Sort(libNames)
 5156
 1157  for _, name := range libNames {
 1158    projects = append(projects, domain.Project{
 1159      Name:       name,
 1160      Path:       filepath.Join("pkg", name),
 1161      Type:       domain.ProjectTypeCmdLibrary,
 1162      ModulePath: moduleName,
 1163      TagPrefix:  name + "/",
 1164    })
 1165  }
 166
 5167  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.
 7172func (d *CmdDiscoverer) parsePkgImports(filePath, moduleName string) ([]string, error) {
 7173  data, err := d.fs.ReadFile(filePath)
 0174  if err != nil {
 0175    return nil, fmt.Errorf("reading %s: %w", filePath, err)
 0176  }
 177
 7178  fset := token.NewFileSet()
 7179  f, err := parser.ParseFile(fset, filePath, data, parser.ImportsOnly)
 1180  if err != nil {
 1181    return nil, fmt.Errorf("parsing imports in %s: %w", filePath, err)
 1182  }
 183
 6184  pkgPrefix := moduleName + "/pkg/"
 6185  seen := make(map[string]bool)
 6186  var pkgs []string
 6187  for _, imp := range f.Imports {
 8188    path := strings.Trim(imp.Path.Value, `"`)
 5189    if rest, ok := strings.CutPrefix(path, pkgPrefix); ok {
 5190      // Extract the immediate sub-package name: "module/pkg/queue/sub" → "queue".
 5191      name := strings.SplitN(rest, "/", 2)[0]
 4192      if name != "" && !seen[name] {
 4193        seen[name] = true
 4194        pkgs = append(pkgs, name)
 4195      }
 196    }
 197  }
 6198  return pkgs, nil
 199}

Methods/Properties

Discover
parsePkgImports