Class VersionResolver

java.lang.Object
io.github.duckasteroid.gradle.versioning.VersionResolver

public final class VersionResolver extends Object
Derives project versions from git history rather than a single nearest tag.

See the versioning documentation for the full picture (why this exists, the develop/release/main flow, worked examples). In short, the algorithm for an ordinary build (resolveBuildVersion(java.io.File, java.lang.String, java.lang.String)) is:

  1. If HEAD is exactly on a real tag matching tagPrefix, use it verbatim - no computation needed.
  2. Otherwise, find the last final release tag (a plain "X.Y.Z", no "-RC"/"-SNAPSHOT" suffix) reachable from HEAD under tagPrefix. If this module has no final tag of its own yet, retry each of fallbackPrefixes in turn (see lastFinalVersionLookup(org.eclipse.jgit.lib.Repository, java.lang.String, java.util.List<java.lang.String>)) - e.g. a brand-new subproject in a multi-module build inherits the root project's version line instead of starting over at "0.0.0".
  3. Walk the commits between that tag and HEAD, optionally restricted to ones that touched modulePath (see commitMessagesSince(org.eclipse.jgit.lib.Repository, org.eclipse.jgit.lib.ObjectId, java.lang.String, java.util.List<java.lang.String>)), and classify them with CommitAnalyzer using typeRules (feat/fix/perf/BREAKING CHANGE -> minor/patch/patch/major by default, but configurable via CommitAnalyzerExtension).
  4. Bump the last-final version by the highest classification found (see bumpVersion(java.lang.String, io.github.duckasteroid.gradle.versioning.CommitAnalyzer.Bump)), and decorate it with "-SNAPSHOT" (plus the branch name, for feature branches) since we're not exactly on a tag.

nextReleaseCandidateTag(java.io.File, java.lang.String, java.lang.String) and promoteTag(java.io.File, java.lang.String) are the two release-engineering operations built on the same primitives, used by the release-flow plugin's tasks rather than by ordinary builds: the former mints the next "-RCn" tag for the computed candidate version, the latter strips the "-RCn" suffix off the nearest reachable RC tag to produce the final release tag.

commitMessagesForChangelog(java.io.File, java.lang.String, java.lang.String, java.util.List<java.lang.String>, io.github.duckasteroid.gradle.versioning.ChangelogScope) is the equivalent supporting operation for release notes: it reuses the same tag-lookup machinery to decide which commits are "new" for a changelog, just with its own scope rules rather than the version-bump rules above.

Deliberately uses JGit rather than shelling out to the git CLI: resolveBuildVersion is called from version = ... in VersionPlugin at Gradle configuration time, and starting an external process during configuration is incompatible with the Gradle configuration cache. JGit is a pure-Java implementation, but it is NOT automatically config-cache-safe on its own - the first time any code in the JVM constructs a Repository, JGit itself shells out to the real git binary to discover the system-level git config (see ConfigCacheSafeSystemReader, which withRepo(java.io.File, io.github.duckasteroid.gradle.versioning.VersionResolver.RepoFunction<T>) installs before every RepositoryBuilder().build() call in this class to suppress that). The release-flow plugin's tasks that call into this class only do so from doLast { } (task execution time, where external processes are fine), but they share the same JGit-based code for simplicity and to avoid two parallel implementations of the same logic.

Every public method here that would otherwise propagate a checked JGit/IO exception wraps it in an unchecked RuntimeException instead (see withRepo(java.io.File, io.github.duckasteroid.gradle.versioning.VersionResolver.RepoFunction<T>)) - callers (this project's own VersionPlugin, and any consumer script) can catch a plain catch (Exception e) around a call into this class exactly as they would in a dynamically-typed script, without every call site needing its own checked-exception handling.

  • Method Details

    • resolveBuildVersion

      public static String resolveBuildVersion(File repoDir, String tagPrefix, String branchName)
    • resolveBuildVersion

      public static String resolveBuildVersion(File repoDir, String tagPrefix, String branchName, String modulePath)
    • resolveBuildVersion

      public static String resolveBuildVersion(File repoDir, String tagPrefix, String branchName, String modulePath, List<String> fallbackPrefixes)
    • resolveBuildVersion

      public static String resolveBuildVersion(File repoDir, String tagPrefix, String branchName, String modulePath, List<String> fallbackPrefixes, Map<CommitAnalyzer.Bump,Set<String>> typeRules)
    • resolveBuildVersion

      public static String resolveBuildVersion(File repoDir, String tagPrefix, String branchName, String modulePath, List<String> fallbackPrefixes, Map<CommitAnalyzer.Bump,Set<String>> typeRules, List<String> excludedModulePaths)
      The version to use for an ordinary build (e.g. ./gradlew build, publishToMavenLocal).
      Parameters:
      repoDir - any directory inside the git repo (JGit walks upward to find the .git dir)
      tagPrefix - this module's own tag prefix, e.g. "v" at the root or "sub/module/v"
      branchName - the current git branch, used to fold a sanitized form into the -SNAPSHOT suffix on feature branches (e.g. "1.0.1-cool-stuff-SNAPSHOT") so a build made while working on a feature branch is visibly distinguishable from one made on develop/release
      modulePath - this module's directory relative to the repo root ("" at the root); commits that didn't touch this path don't count towards this module's bump
      fallbackPrefixes - tried in order if tagPrefix has no final tag reachable from HEAD yet
      typeRules - see CommitAnalyzer.DEFAULT_TYPE_RULES; defaults to the built-in mapping. In the Gradle plugin this normally comes from the commitAnalyzer { } extension (see CommitAnalyzerExtension.toTypeRules()), not the bare default.
      excludedModulePaths - directories to exclude from modulePath's scope even though they'd otherwise match - e.g. a root project (modulePath "") in "mixed mode" excluding the modulePaths of subprojects that independently apply the release-flow plugin, so root doesn't also bump/release on a commit that's entirely one of those subprojects' own. A commit only needs to touch one path outside every excluded directory to still count - excluding "api" doesn't hide a commit that touches both "api/" and a root-level file.
    • resolveCandidateVersion

      public static String resolveCandidateVersion(File repoDir, String tagPrefix)
    • resolveCandidateVersion

      public static String resolveCandidateVersion(File repoDir, String tagPrefix, String modulePath)
    • resolveCandidateVersion

      public static String resolveCandidateVersion(File repoDir, String tagPrefix, String modulePath, List<String> fallbackPrefixes)
    • resolveCandidateVersion

      public static String resolveCandidateVersion(File repoDir, String tagPrefix, String modulePath, List<String> fallbackPrefixes, Map<CommitAnalyzer.Bump,Set<String>> typeRules)
    • resolveCandidateVersion

      public static String resolveCandidateVersion(File repoDir, String tagPrefix, String modulePath, List<String> fallbackPrefixes, Map<CommitAnalyzer.Bump,Set<String>> typeRules, List<String> excludedModulePaths)
      The last final release version, bumped by the conventional commits since it - with no "-SNAPSHOT"/branch decoration. This is what an actual release tag (RC or final) should be based on; resolveBuildVersion(java.io.File, java.lang.String, java.lang.String) calls this too and then decorates the result for ordinary (non-release) builds.
    • lastFinalVersion

      public static String lastFinalVersion(File repoDir, String tagPrefix)
    • lastFinalVersion

      public static String lastFinalVersion(File repoDir, String tagPrefix, List<String> fallbackPrefixes)
      Highest final release tag (prefix + strict X.Y.Z, no suffix) reachable from HEAD. If none exists under tagPrefix, retries each of fallbackPrefixes in order (e.g. a brand-new module inherits the root project's version line instead of starting over at "0.0.0"). Falls all the way back to "0.0.0" if nothing matches under any prefix - a fresh repo with no releases yet.
    • nextReleaseCandidateTag

      public static String nextReleaseCandidateTag(File repoDir, String tagPrefix, String candidateVersion)
      The full tag name (with prefix) for the next release-candidate to mint, e.g. "v1.1.0-RC2". Does not create or push anything itself - that's the caller's job (see the release-flow plugin's tagReleaseCandidate task). candidateVersion is normally whatever resolveCandidateVersion(java.io.File, java.lang.String) just computed, but the caller may instead pass a value straight from -Prelease.forceVersion to bypass commit analysis entirely.
    • promoteTag

      public static String promoteTag(File repoDir, String tagPrefix)
      The full tag name (with prefix) for the final release, derived by stripping the "-RCn" suffix off the nearest reachable release-candidate tag. Used by the release-flow plugin's promoteReleaseCandidate task when an accepted RC on the release branch is merged to main. Throws if there's no RC tag reachable from HEAD at all - promoting only makes sense once at least one RC has actually been cut.
    • currentCycleReleaseCandidateTags

      public static List<String> currentCycleReleaseCandidateTags(File repoDir, String tagPrefix)
    • currentCycleReleaseCandidateTags

      public static List<String> currentCycleReleaseCandidateTags(File repoDir, String tagPrefix, List<String> fallbackPrefixes)
      Every release-candidate tag (tagPrefix + "X.Y.Z-RCn") reachable from HEAD but not reachable from the last final release tag's commit - i.e. every RC minted during the current release cycle, regardless of which candidate version it was minted under (a later commit can raise the bump mid-cycle, "leapfrogging" an earlier RC's version - both still belong to the same cycle). Ordered nearest-to-HEAD first (most recently created). Used by the release-flow plugin's tagReleaseCandidates to warn when the freshly computed candidate leapfrogs the most recent RC's version, and to decide which of this cycle's RC GitHub Releases are superseded and safe to prune.
    • commitMessagesForChangelog

      public static List<String> commitMessagesForChangelog(File repoDir, String tagPrefix, String modulePath, List<String> fallbackPrefixes, ChangelogScope scope)
    • commitMessagesForChangelog

      public static List<String> commitMessagesForChangelog(File repoDir, String tagPrefix, String modulePath, List<String> fallbackPrefixes, ChangelogScope scope, List<String> excludedModulePaths)
      Full commit messages to build a changelog from. The "since" boundary depends on scope:

      Same modulePath restriction as version resolution: only commits that touched this module's own directory are included, so a changelog generated for one subproject in a monorepo doesn't list changes from an unrelated sibling.

      Caller must run this BEFORE minting any new tag for the current HEAD (see the release-flow plugin's changelogForReleaseCandidate/changelogForRelease tasks, which always run before tagReleaseCandidate/promoteReleaseCandidate in the same job). Both scopes locate their "since" boundary by looking for a tag reachable from HEAD - if HEAD were already tagged with the release currently being prepared, that brand-new tag would itself be "the nearest reachable RC tag" or "the last final release tag", making the commit range (and therefore the generated changelog) come back empty.

      Parameters:
      repoDir - any directory inside the git repo (JGit walks upward to find the .git dir)
      tagPrefix - this module's own tag prefix, e.g. "v" at the root or "sub/module/v"
      modulePath - this module's directory relative to the repo root ("" at the root)
      fallbackPrefixes - tried in order if tagPrefix has no final tag reachable from HEAD yet (only relevant for the SINCE_LAST_RELEASE fallback path, same as resolveCandidateVersion)
      scope - see ChangelogScope
      excludedModulePaths - same exclusion semantics as resolveBuildVersion(java.io.File, java.lang.String, java.lang.String) - a changelog generated for one project shouldn't list commits that belong entirely to a sibling project it excludes.