< Summary - go-semantic-release Coverage

Line coverage
90%
Covered lines: 215
Uncovered lines: 22
Coverable lines: 237
Total lines: 433
Line coverage: 90.7%
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
WithLogger0%00100%
Logger0%00100%
Config0%00100%
GitRepository0%0081.25%
CommitParser0%00100%
FileSystem0%00100%
TagService0%00100%
VersionCalculator0%00100%
ChangelogGenerator0%00100%
ReleasePublisher0%00100%
ProjectImpactAnalyzer0%00100%
ProjectDiscoverer0%00100%
buildDiscoverer0%00100%
Plugins0%00100%
buildPlugins0%0080%
Pipeline0%00100%
CommitAnalyzer0%00100%
ProjectDetector0%00100%
ReleasePlanner0%00100%
ReleaseExecutor0%00100%
ConditionVerifier0%00100%

File(s)

/home/runner/work/go-semantic-release/go-semantic-release/internal/di/container.go

#LineLine coverage
 1package di
 2
 3import (
 4  "context"
 5  "fmt"
 6  "os"
 7  "sync"
 8  "sync/atomic"
 9
 10  "github.com/jedi-knights/go-semantic-release/internal/adapters/bitbucket"
 11  "github.com/jedi-knights/go-semantic-release/internal/adapters/changelog"
 12  adapterfs "github.com/jedi-knights/go-semantic-release/internal/adapters/fs"
 13  adaptergit "github.com/jedi-knights/go-semantic-release/internal/adapters/git"
 14  adaptergithub "github.com/jedi-knights/go-semantic-release/internal/adapters/github"
 15  "github.com/jedi-knights/go-semantic-release/internal/adapters/gitlab"
 16  adaptergogit "github.com/jedi-knights/go-semantic-release/internal/adapters/gogit"
 17  adapterlint "github.com/jedi-knights/go-semantic-release/internal/adapters/lint"
 18  "github.com/jedi-knights/go-semantic-release/internal/adapters/plugins"
 19  "github.com/jedi-knights/go-semantic-release/internal/app"
 20  "github.com/jedi-knights/go-semantic-release/internal/domain"
 21  "github.com/jedi-knights/go-semantic-release/internal/platform"
 22  "github.com/jedi-knights/go-semantic-release/internal/ports"
 23)
 24
 25// Container is the dependency injection container that wires all components.
 26// It is safe for concurrent use: lazy singletons are protected by mu; the logger
 27// is stored atomically so WithLogger has no ordering constraints.
 28// fileSystem is the exception: it is initialized via fileSystemOnce (sync.Once)
 29// rather than mu so that ProjectDiscoverer can call FileSystem() while already
 30// holding mu without deadlocking.
 31type Container struct {
 32  mu        sync.Mutex
 33  config    domain.Config // immutable after construction; read without mu is safe
 34  loggerPtr atomic.Pointer[ports.Logger]
 35  workDir   string
 36
 37  // fileSystem is initialized exactly once via fileSystemOnce; all other
 38  // singletons below are lazily initialized under mu.
 39  fileSystemOnce sync.Once
 40  fileSystem     ports.FileSystem
 41
 42  // Singletons (lazily initialized; access only while holding mu).
 43  gitRepo        ports.GitRepository
 44  commitParser   ports.CommitParser
 45  tagService     ports.TagService
 46  versionCalc    ports.VersionCalculator
 47  changelogGen   ports.ChangelogGenerator
 48  publisher      ports.ReleasePublisher
 49  impactAnalyzer ports.ProjectImpactAnalyzer
 50  discoverer     ports.ProjectDiscoverer
 51
 52  // Plugin list is built exactly once via pluginsOnce.
 53  pluginsOnce sync.Once
 54  pluginList  []ports.Plugin
 55  pluginsErr  error
 56}
 57
 58// NewContainer creates a DI container with the given config.
 59// workDir must be an absolute path to the repository root; construction fails if the path does not exist.
 60func NewContainer(config domain.Config, workDir string) (*Container, error) {
 61  if _, err := os.Stat(workDir); err != nil {
 62    return nil, fmt.Errorf("invalid workDir %q: %w", workDir, err)
 63  }
 64  c := &Container{config: config, workDir: workDir}
 65  var l ports.Logger = platform.DefaultLogger()
 66  c.loggerPtr.Store(&l)
 67  return c, nil
 68}
 69
 70// WithLogger overrides the logger. Safe to call concurrently with any other method.
 171func (c *Container) WithLogger(logger ports.Logger) *Container {
 172  c.loggerPtr.Store(&logger)
 173  return c
 174}
 75
 76// Logger returns the current logger. Safe to call concurrently.
 1677func (c *Container) Logger() ports.Logger {
 1678  return *c.loggerPtr.Load()
 1679}
 80
 81// Config returns the container's configuration. config is immutable after
 82// construction so this is safe to call without holding mu.
 183func (c *Container) Config() domain.Config {
 184  return c.config
 185}
 86
 87// GitRepository returns the configured git repository implementation (CLI or go-git).
 1388func (c *Container) GitRepository() ports.GitRepository {
 1389  c.mu.Lock()
 1390  defer c.mu.Unlock()
 1391  if c.gitRepo == nil {
 192    if c.config.GitBackend == "go-git" {
 193      repo, err := adaptergogit.NewRepository(c.workDir)
 194      if err != nil {
 195        c.Logger().Warn("failed to open go-git repository, falling back to CLI", "error", err)
 196        c.gitRepo = adaptergit.NewRepository(c.workDir)
 097      } else {
 098        c.gitRepo = repo
 099      }
 12100    } else {
 12101      c.gitRepo = adaptergit.NewRepository(c.workDir)
 12102    }
 103  }
 13104  return c.gitRepo
 105}
 106
 107// CommitParser returns the conventional commit parser.
 9108func (c *Container) CommitParser() ports.CommitParser {
 9109  c.mu.Lock()
 9110  defer c.mu.Unlock()
 9111  if c.commitParser == nil {
 9112    c.commitParser = adaptergit.NewConventionalCommitParser()
 9113  }
 9114  return c.commitParser
 115}
 116
 117// FileSystem returns the OS filesystem adapter.
 118// Initialization is guarded by fileSystemOnce rather than mu so that
 119// ProjectDiscoverer (and buildPlugins) can call FileSystem() while already
 120// holding mu without deadlocking.
 14121func (c *Container) FileSystem() ports.FileSystem {
 13122  c.fileSystemOnce.Do(func() {
 13123    c.fileSystem = adapterfs.NewOSFileSystem()
 13124  })
 14125  return c.fileSystem
 126}
 127
 128// TagService returns the tag formatter configured for this repository's tag format.
 10129func (c *Container) TagService() ports.TagService {
 10130  c.mu.Lock()
 10131  defer c.mu.Unlock()
 10132  if c.tagService == nil {
 10133    c.tagService = adaptergit.NewTemplateTagService(c.config.TagFormat, c.config.ProjectTagFormat)
 10134  }
 10135  return c.tagService
 136}
 137
 138// VersionCalculator returns the semver bump calculator.
 2139func (c *Container) VersionCalculator() ports.VersionCalculator {
 2140  c.mu.Lock()
 2141  defer c.mu.Unlock()
 2142  if c.versionCalc == nil {
 2143    c.versionCalc = app.NewVersionCalculatorService()
 2144  }
 2145  return c.versionCalc
 146}
 147
 148// ChangelogGenerator returns the template-based changelog generator.
 9149func (c *Container) ChangelogGenerator() ports.ChangelogGenerator {
 9150  c.mu.Lock()
 9151  defer c.mu.Unlock()
 9152  if c.changelogGen == nil {
 9153    c.changelogGen = changelog.NewTemplateGenerator(c.config.ChangelogTemplate)
 9154  }
 9155  return c.changelogGen
 156}
 157
 158// ReleasePublisher returns the configured release publisher.
 159// When github.create_release is false, a no-op publisher is returned so callers
 160// that receive the publisher from the container never need to nil-check the result.
 161// MustNewReleaseExecutor panics on nil publisher; callers that bypass the container
 162// must pass noopPublisher{} explicitly when publishing is disabled.
 5163func (c *Container) ReleasePublisher() ports.ReleasePublisher {
 5164  c.mu.Lock()
 5165  defer c.mu.Unlock()
 5166  if c.publisher == nil {
 1167    if c.config.GitHub.CreateRelease {
 1168      c.publisher = adaptergithub.NewPublisher(
 1169        c.config.GitHub.Owner,
 1170        c.config.GitHub.Repo,
 1171        c.config.GitHub.Token,
 1172      )
 1173    } else {
 4174      c.publisher = noopPublisher{}
 4175    }
 176  }
 5177  return c.publisher
 178}
 179
 180// ProjectImpactAnalyzer returns the path-based project impact analyzer.
 2181func (c *Container) ProjectImpactAnalyzer() ports.ProjectImpactAnalyzer {
 2182  c.mu.Lock()
 2183  defer c.mu.Unlock()
 2184  if c.impactAnalyzer == nil {
 2185    c.impactAnalyzer = adaptergit.NewPathBasedImpactAnalyzer(c.config.DependencyPropagation, c.config.IncludePaths, c.co
 2186  }
 2187  return c.impactAnalyzer
 188}
 189
 190// ProjectDiscoverer returns the composite project discoverer for this repository.
 5191func (c *Container) ProjectDiscoverer() ports.ProjectDiscoverer {
 5192  c.mu.Lock()
 5193  defer c.mu.Unlock()
 5194  if c.discoverer == nil {
 5195    c.discoverer = c.buildDiscoverer()
 5196  }
 5197  return c.discoverer
 198}
 199
 200// buildDiscoverer must be called with mu held. It calls c.FileSystem() safely
 201// because FileSystem uses its own fileSystemOnce and does not acquire mu.
 5202func (c *Container) buildDiscoverer() ports.ProjectDiscoverer {
 5203  fs := c.FileSystem()
 5204  var discoverers []ports.ProjectDiscoverer
 5205
 1206  if len(c.config.Projects) > 0 {
 1207    discoverers = append(discoverers, adaptergit.NewConfiguredDiscoverer(c.config.Projects))
 1208  }
 209  // WorkspaceDiscoverer is always appended so that repos without explicit project config
 210  // are still discovered via go.work. Note: CompositeDiscoverer uses first-wins semantics,
 211  // so if ConfiguredDiscoverer returns results, WorkspaceDiscoverer is NOT called and
 212  // additional go.work modules are silently skipped. A future MergingDiscoverer would be
 213  // needed to combine both sources.
 5214  discoverers = append(discoverers, adaptergit.NewWorkspaceDiscoverer(fs))
 1215  if c.config.DiscoverModules {
 1216    discoverers = append(discoverers, adaptergit.NewModuleDiscoverer(fs))
 1217  }
 1218  if c.config.DiscoverCmd {
 1219    discoverers = append(discoverers, adaptergit.NewCmdDiscoverer(fs))
 1220  }
 221
 5222  return adaptergit.NewCompositeDiscoverer(discoverers...)
 223}
 224
 225// Plugins builds and caches the ordered list of lifecycle plugins based on config.
 226// The list is constructed exactly once (via sync.Once) regardless of concurrent callers.
 227// Returns an error if any explicitly configured external plugin fails to load.
 228//
 229// Once a load error occurs the sync.Once body is complete and will never re-run,
 230// so subsequent calls will always return (nil, err) for the same permanent error.
 231// Callers that need to react to transient failures must create a new Container.
 232//
 233// Concurrency note: pluginsOnce.Do intentionally does NOT hold c.mu. Each
 234// accessor called inside Do (GitRepository, CommitParser, etc.) acquires c.mu
 235// independently for its own singleton initialisation. Holding c.mu across the
 236// entire Do body would cause a deadlock because those accessors also try to
 237// acquire c.mu. sync.Once provides its own mutual-exclusion guarantee for the
 238// Do body itself, so no additional locking is required here.
 8239func (c *Container) Plugins() ([]ports.Plugin, error) {
 7240  c.pluginsOnce.Do(func() {
 7241    c.pluginList, c.pluginsErr = c.buildPlugins()
 7242  })
 8243  return c.pluginList, c.pluginsErr
 244}
 245
 246// buildPlugins constructs the ordered plugin list. It must only be called from
 247// within pluginsOnce.Do; sync.Once provides the mutual-exclusion guarantee.
 248//
 249// Side effect: calling buildPlugins initializes most container singletons
 250// (GitRepository, TagService, FileSystem, CommitParser, ChangelogGenerator, …)
 251// as a side effect of constructing their respective plugins. A first call to
 252// Plugins() is therefore equivalent to eagerly initialising the majority of
 253// the container's infrastructure.
 7254func (c *Container) buildPlugins() ([]ports.Plugin, error) {
 7255  logger := c.Logger()
 7256
 7257  ps := []ports.Plugin{
 7258    // Git plugin: verifyConditions + publish (stage → commit → push → tag).
 7259    plugins.NewGitPlugin(
 7260      c.GitRepository(),
 7261      c.TagService(),
 7262      c.FileSystem(),
 7263      logger,
 7264      c.config.GitAuthor,
 7265      c.config.Git,
 7266    ),
 7267    // Commit analyzer plugin: analyzeCommits.
 7268    plugins.NewCommitAnalyzerPlugin(
 7269      c.CommitParser(),
 7270      c.config.CommitTypes,
 7271    ),
 7272    // Release notes plugin: generateNotes.
 7273    plugins.NewReleaseNotesPlugin(
 7274      c.ChangelogGenerator(),
 7275      c.config.ChangelogSections,
 7276    ),
 7277  }
 7278
 7279  // Prepare plugin: update CHANGELOG.md, VERSION, version_files, run command.
 7280  // Register if any prepare option is configured OR any project defines a per-project changelog_file.
 7281  if c.config.Prepare.ChangelogFile != "" || c.config.Prepare.VersionFile != "" ||
 7282    c.config.Prepare.Command != "" || len(c.config.Prepare.VersionFiles) > 0 ||
 1283    c.config.AnyProjectDefinesChangelog() {
 1284    ps = append(ps, plugins.NewPreparePlugin(
 1285      c.FileSystem(),
 1286      logger,
 1287      c.config.Prepare,
 1288    ))
 1289  }
 290
 291  // Lint plugin: verifyRelease (commit message linting).
 1292  if c.config.Lint.Enabled {
 1293    lintCfg := c.config.Lint
 1294    if len(lintCfg.AllowedTypes) == 0 {
 1295      // No allowed types configured — fall back to the full default set.
 1296      lintCfg = domain.DefaultEnabledLintConfig()
 1297    }
 1298    ps = append(ps, plugins.NewLintPlugin(
 1299      adapterlint.NewConventionalLinter(lintCfg),
 1300      logger,
 1301    ))
 302  }
 303
 304  // GitHub plugin: verifyConditions + publish + addChannel + success + fail.
 0305  if c.config.GitHub.CreateRelease {
 0306    ps = append(ps, adaptergithub.NewPlugin(
 0307      adaptergithub.PluginConfig{
 0308        Owner:                  c.config.GitHub.Owner,
 0309        Repo:                   c.config.GitHub.Repo,
 0310        Token:                  c.config.GitHub.Token,
 0311        APIURL:                 c.config.GitHub.APIURL,
 0312        Assets:                 c.config.GitHub.Assets,
 0313        DraftRelease:           c.config.GitHub.DraftRelease,
 0314        DiscussionCategoryName: c.config.GitHub.DiscussionCategoryName,
 0315        SuccessComment:         c.config.GitHub.SuccessComment,
 0316        FailComment:            c.config.GitHub.FailComment,
 0317        ReleasedLabels:         c.config.GitHub.ReleasedLabels,
 0318        FailLabels:             c.config.GitHub.FailLabels,
 0319      },
 0320      logger,
 0321    ))
 0322  }
 323
 324  // GitLab plugin: verifyConditions + publish + addChannel + success + fail.
 1325  if c.config.GitLab.CreateRelease {
 1326    ps = append(ps, gitlab.NewPlugin(
 1327      gitlab.PluginConfig{
 1328        ProjectID:  c.config.GitLab.ProjectID,
 1329        Token:      c.config.GitLab.Token,
 1330        APIURL:     c.config.GitLab.APIURL,
 1331        Assets:     c.config.GitLab.Assets,
 1332        Milestones: c.config.GitLab.Milestones,
 1333      },
 1334      logger,
 1335    ))
 1336  }
 337
 338  // Bitbucket plugin: verifyConditions + publish + addChannel + success + fail.
 1339  if c.config.Bitbucket.CreateRelease {
 1340    ps = append(ps, bitbucket.NewPlugin(
 1341      bitbucket.PluginConfig{
 1342        Workspace: c.config.Bitbucket.Workspace,
 1343        RepoSlug:  c.config.Bitbucket.RepoSlug,
 1344        Token:     c.config.Bitbucket.Token,
 1345        APIURL:    c.config.Bitbucket.APIURL,
 1346      },
 1347      logger,
 1348    ))
 1349  }
 350
 351  // External plugins from config/flags.
 1352  if len(c.config.Plugins) > 0 {
 1353    external, err := plugins.LoadExternalPlugins(c.config.Plugins)
 1354    if err != nil {
 1355      // Return (nil, err) so callers cannot accidentally use a partial list.
 1356      return nil, err
 1357    }
 0358    ps = append(ps, external...)
 359  }
 360
 6361  return ps, nil
 362}
 363
 364// noopPublisher is a null-object implementation of ports.ReleasePublisher.
 365// It is used when github.create_release is false so that ReleasePublisher()
 366// always returns a non-nil value and callers never need to nil-check.
 367type noopPublisher struct{}
 368
 369func (noopPublisher) Publish(ctx context.Context, _ ports.PublishParams) (domain.ProjectReleaseResult, error) {
 370  // Respect cancellation so that a cancelled pipeline does not silently
 371  // succeed at the publish step, keeping pipeline error propagation consistent
 372  // regardless of whether a real publisher is wired.
 373  if ctx.Err() != nil {
 374    return domain.ProjectReleaseResult{}, ctx.Err()
 375  }
 376  return domain.ProjectReleaseResult{}, nil
 377}
 378
 379// Pipeline creates a lifecycle pipeline with all configured plugins.
 380// Returns an error if any explicitly configured external plugin failed to load.
 2381func (c *Container) Pipeline() (*app.Pipeline, error) {
 2382  ps, err := c.Plugins()
 1383  if err != nil {
 1384    return nil, err
 1385  }
 1386  return app.NewPipeline(ps, c.Logger()), nil
 387}
 388
 389// CommitAnalyzer creates a CommitAnalyzer use case.
 390// A new instance is returned on each call — this is intentional. Use cases are
 391// lightweight value objects; the underlying infrastructure (GitRepository, CommitParser)
 392// is shared via their own singletons.
 1393func (c *Container) CommitAnalyzer() *app.CommitAnalyzer {
 1394  return app.NewCommitAnalyzer(c.GitRepository(), c.CommitParser(), c.Logger())
 1395}
 396
 397// ProjectDetector creates a ProjectDetector use case.
 398// A new instance is returned on each call (intentional — see CommitAnalyzer).
 1399func (c *Container) ProjectDetector() *app.ProjectDetector {
 1400  return app.NewProjectDetector(c.ProjectDiscoverer(), c.Logger())
 1401}
 402
 403// ReleasePlanner creates a ReleasePlanner use case.
 404// A new instance is returned on each call (intentional — see CommitAnalyzer).
 1405func (c *Container) ReleasePlanner() *app.ReleasePlanner {
 1406  return app.NewReleasePlanner(
 1407    c.GitRepository(),
 1408    c.TagService(),
 1409    c.VersionCalculator(),
 1410    c.ProjectImpactAnalyzer(),
 1411    c.Logger(),
 1412    c.config.CommitTypes,
 1413  )
 1414}
 415
 416// ReleaseExecutor creates a ReleaseExecutor use case.
 417// A new instance is returned on each call (intentional — see CommitAnalyzer).
 1418func (c *Container) ReleaseExecutor() *app.ReleaseExecutor {
 1419  return app.MustNewReleaseExecutor(
 1420    c.GitRepository(),
 1421    c.TagService(),
 1422    c.ChangelogGenerator(),
 1423    c.ReleasePublisher(),
 1424    c.Logger(),
 1425    c.config.ChangelogSections,
 1426  )
 1427}
 428
 429// ConditionVerifier creates a ConditionVerifier use case.
 430// A new instance is returned on each call (intentional — see CommitAnalyzer).
 1431func (c *Container) ConditionVerifier() *app.ConditionVerifier {
 1432  return app.NewConditionVerifier(c.GitRepository(), c.config, c.Logger())
 1433}