< Summary - go-semantic-release Coverage

Line coverage
62%
Covered lines: 28
Uncovered lines: 17
Coverable lines: 45
Total lines: 316
Line coverage: 62.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
Discover0%0062.22%

File(s)

/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.
 38func NewWorkspaceDiscoverer(fsys ports.FileSystem) *WorkspaceDiscoverer {
 39  return &WorkspaceDiscoverer{fs: fsys}
 40}
 41
 742func (d *WorkspaceDiscoverer) Discover(ctx context.Context, rootPath string) ([]domain.Project, error) {
 743  workFile := filepath.Join(rootPath, "go.work")
 144  if !d.fs.Exists(workFile) {
 145    return nil, nil
 146  }
 47
 648  data, err := d.fs.ReadFile(workFile)
 049  if err != nil {
 050    return nil, fmt.Errorf("reading go.work: %w", err)
 051  }
 52
 653  dirs, err := parseGoWorkUse(data)
 054  if err != nil {
 055    return nil, fmt.Errorf("parsing go.work: %w", err)
 056  }
 657  projects := make([]domain.Project, 0, len(dirs))
 658
 659  // Context is only checked between loop iterations. The ReadFile and
 660  // parseGoWorkUse calls above the loop do not respect cancellation; this is
 661  // acceptable for the typically small go.work files seen in practice.
 662  for _, dir := range dirs {
 963    select {
 064    case <-ctx.Done():
 065      return nil, ctx.Err()
 966    default:
 67    }
 968    modPath := filepath.Join(rootPath, dir, "go.mod")
 969    moduleName, err := readModuleName(d.fs, modPath)
 070    if err != nil {
 071      return nil, fmt.Errorf("reading module name from %s: %w", modPath, err)
 072    }
 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").
 978    cleanDir := filepath.Clean(dir)
 979    name := cleanDir
 980    tagPrefix := cleanDir + "/"
 081    if cleanDir == "." {
 082      name = filepath.Base(rootPath)
 083      if name == "/" || name == "." {
 084        name = "root"
 085      }
 86      // Root project has no tag prefix, matching ModuleDiscoverer behavior.
 087      tagPrefix = ""
 88    }
 89
 990    projects = append(projects, domain.Project{
 991      Name:       name,
 992      Path:       cleanDir,
 993      Type:       domain.ProjectTypeGoWorkspace,
 994      ModulePath: moduleName,
 995      TagPrefix:  tagPrefix,
 996    })
 97  }
 698  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.
 104func parseGoWorkUse(content []byte) ([]string, error) {
 105  var dirs []string
 106  scanner := bufio.NewScanner(bytes.NewReader(content))
 107  inUseBlock := false
 108
 109  for scanner.Scan() {
 110    line := strings.TrimSpace(scanner.Text())
 111
 112    // noSpaces is used only to detect "use(" with arbitrary spacing between
 113    // the keyword and the opening paren. It is NOT used for directory parsing —
 114    // dir is always derived from the original line so paths with spaces are
 115    // preserved correctly.
 116    noSpaces := strings.ReplaceAll(line, " ", "")
 117    if noSpaces == "use(" {
 118      inUseBlock = true
 119      continue
 120    }
 121    if inUseBlock && line == ")" {
 122      inUseBlock = false
 123      continue
 124    }
 125    if inUseBlock {
 126      dir := strings.TrimSpace(line)
 127      if strings.HasPrefix(dir, "//") {
 128        continue
 129      }
 130      // Strip inline comments (e.g. "./path // comment" → "./path").
 131      if idx := strings.Index(dir, "//"); idx >= 0 {
 132        dir = strings.TrimSpace(dir[:idx])
 133      }
 134      if dir != "" {
 135        dirs = append(dirs, dir)
 136      }
 137      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".
 142    if (strings.HasPrefix(line, "use ") || strings.HasPrefix(line, "use\t")) && !strings.Contains(line, "(") {
 143      dir := strings.TrimSpace(line[len("use"):])
 144      if dir != "" {
 145        dirs = append(dirs, dir)
 146      }
 147    }
 148  }
 149  if err := scanner.Err(); err != nil {
 150    return nil, fmt.Errorf("scanning go.work: %w", err)
 151  }
 152  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.
 161func NewModuleDiscoverer(fsys ports.FileSystem) *ModuleDiscoverer {
 162  return &ModuleDiscoverer{fs: fsys}
 163}
 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.
 239func NewConfiguredDiscoverer(projects []domain.ProjectConfig) *ConfiguredDiscoverer {
 240  return &ConfiguredDiscoverer{projects: projects}
 241}
 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.
 271func readModuleName(fsys ports.FileSystem, modFile string) (string, error) {
 272  data, err := fsys.ReadFile(modFile)
 273  if err != nil {
 274    return "", fmt.Errorf("reading %s: %w", modFile, err)
 275  }
 276  scanner := bufio.NewScanner(bytes.NewReader(data))
 277  for scanner.Scan() {
 278    line := strings.TrimSpace(scanner.Text())
 279    // HasPrefix and TrimPrefix both use "module " (with a trailing space) so the
 280    // guard and the trim are consistent: a line like "modulex github.com/..." is
 281    // not matched, and the returned name has no leading space.
 282    if strings.HasPrefix(line, "module ") {
 283      return strings.TrimSpace(strings.TrimPrefix(line, "module ")), nil
 284    }
 285  }
 286  if scanErr := scanner.Err(); scanErr != nil {
 287    return "", fmt.Errorf("scanning %s: %w", modFile, scanErr)
 288  }
 289  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.
 298func NewCompositeDiscoverer(discoverers ...ports.ProjectDiscoverer) *CompositeDiscoverer {
 299  return &CompositeDiscoverer{discoverers: discoverers}
 300}
 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}

Methods/Properties

Discover