Skip to content

Instantly share code, notes, and snippets.

@cg-soft
Last active August 29, 2015 14:20
Show Gist options
  • Save cg-soft/d4a53e68f8d04a27f6b3 to your computer and use it in GitHub Desktop.
Save cg-soft/d4a53e68f8d04a27f6b3 to your computer and use it in GitHub Desktop.
Compute version string from git branch and tag info.
// This is how you use the code in your top level build.gradle
def computedVersion = new ComputedVersion()
ext.computedShortVersion = computedVersion.getVersion(rootDir.toString())
ext.computedLongVersion = computedVersion.padVersion(computedShortVersion)
ext.computedTargetBranch = computedVersion.activeBranch
def computedVersionTest() {
// printing this here allows the tracking branch behavior to be
// tested manually.
println "Current Branch / Version: ${computedTargetBranch} / ${computedShortVersion} (${computedLongVersion})"
new ComputedVersion().testMe()
}
task testComputedVersion << {
computedVersionTest()
}
repositories {
mavenCentral()
}
dependencies {
compile "org.eclipse.jgit:org.eclipse.jgit:3.7.0.201502260915-r"
}
/*
* We determine the product version from the collection
* of tags and branches and our current active branch.
* Essentially, the versions implied by the branch and tag
* names define an "occupied space", and the goal is to
* find the next "free" version available that fits the
* current branch name and is not occupied by a tag or
* a more precise branch name.
*
* Version strings used here are 4 component "semantic" versions:
* major.minor.patch.hotfix
*
* Branching is assumed to be as follows:
*
* master (latest and greatest) ____________________________________
* \ \
* patch \_______________1.0.next \____1.1.next ...
* \ \ \
* hotfix \_1.0.0.next \_1.0.1.next \_1.1.0.next ...
* | | | |
* tagged releases v1.0GA v1.0.0.1GA v1.0.1GA v1.1GA
*
* In this example, a build on a branch results in the following versions:
*
* master -> 1.2 (because 1.1 is taken by 1.1.next)
* 1.0.next -> 1.0.2 (because 1.0.1 is taken by 1.0.1.next)
* 1.0.0.next -> 1.0.0.2 (because 1.0 and 1.0.0.1 are already tagged)
* 1.1.next -> 1.1.1 (because 1.1 is taken by 1.1.0.next)
* 1.1.0.next -> 1.1.0.1 (because 1.1 is already tagged)
*
* Note that bumping the major version requires changing the constant MASTER_VERSION below
* Altenatively, one can drop the use of master altogether and use 1.next, 2.next instead.
*/
import org.gradle.api.GradleException
import org.eclipse.jgit.api.Git
import org.eclipse.jgit.api.LogCommand
import org.eclipse.jgit.api.ListBranchCommand.ListMode
import org.eclipse.jgit.api.errors.GitAPIException
import org.eclipse.jgit.lib.Ref
import org.eclipse.jgit.lib.Repository
import org.eclipse.jgit.lib.Config
import org.eclipse.jgit.lib.BranchConfig
import org.eclipse.jgit.storage.file.FileRepositoryBuilder
import org.eclipse.jgit.revwalk.RevCommit;
/*
* JGit wrapper class to implement build related git functions.
*/
class ComputedVersionGit {
private Repository repo = null
// Default constructor
ComputedVersionGit() {
repo = (new FileRepositoryBuilder())
.readEnvironment()
.findGitDir()
.build()
}
// Constructor needed for things to work inside IntelliJ. For some reason,
// IntelliJ switches the current directory to someplace outside the git repo,
// so we need to find our way back. The top level gradle script knows...
ComputedVersionGit(String rootDir) {
repo = (new FileRepositoryBuilder())
.setGitDir(new File(rootDir+'/.git'))
.setWorkTree(new File(rootDir))
.build()
}
/**
* Locate all remote branches
* @return List of branches, stripped of the refs/remotes/ prefix
*/
List<String> getBranches() {
return (new Git(repo)).branchList().setListMode(ListMode.REMOTE).call().collect {
it.getName()
}
}
/**
* Locate all tags
* @return List of tags
*/
List<String> getTags() {
return new Git(repo).tagList().call().collect {
it.getName()
}
}
/**
* Compute the number of commits in localBranch which are not
* in remoteBranch.
* @return number of commits in localBranch not in remoteBranch
*/
Integer getDistanceBetween(String remoteBranch, String localBranch, Integer maxCount) {
Integer distance = 0
// TODO: hook this into the Gradle logger. This is not as simple as it appears,
// because the gradle logger only works in a project or a task context. This will
// be taken care of in a future changeset.
//println("Distance between ${remoteBranch} and ${localBranch} is ${distance}")
LogCommand git = (new Git(repo)).log()
.addRange(repo.resolve(remoteBranch), repo.resolve(localBranch))
if (maxCount) {
git = git.setMaxCount(maxCount)
}
git.call().each { distance += 1 }
// TODO: see above.
//println("Distance between ${remoteBranch} and ${localBranch} is ${distance}")
return distance
}
/**
* Given a list of remote branches and a local branch, find the closest
* remote branch
* @return closest remote branch
*/
String findClosestRemoteBranch(List<String> remoteBranches, String localBranch) {
// First check for direct hit
if (remoteBranches.contains(localBranch)) {
return localBranch
}
// Now check for minimum
Integer minDistance = null
Integer maxCount = null
String minBranch = null
remoteBranches.each { remoteBranch ->
Integer distance = getDistanceBetween(remoteBranch, localBranch, maxCount)
// We go from oldest (master) to newest. It might be desirable to go the other
// way, but master being by far the most common use case, it's more effective
// to optimize for it.
if (minDistance == null || distance < minDistance) {
minDistance = distance
maxCount = distance + 1 // Limit log retrieval to current min to save time
minBranch = remoteBranch
}
}
return minBranch
}
/**
* Determine active remote branch by determining the closest known
* remote branch to the current HEAD.
* @return active remote branch
*/
String getActiveBranch(List<String> remoteBranches) {
String branch = repo.getBranch() ?: 'HEAD'
String trackingBranch = (new BranchConfig(repo.getConfig(), branch)).getTrackingBranch() ?: branch
return findClosestRemoteBranch(remoteBranches, trackingBranch)
}
void close() {
repo.close()
}
}
class ComputedVersionComponent {
// Define the node of a tree rooted at the major version,
// and the tail being a map of lesser version numbers to
// further nodes. We also track the highest version number
// for that component. The next free version is max + 1
private Integer max = -1
private Map<Integer,ComputedVersionComponent> tail = [:] // Empty map
void addVersionComponentTuple(List<Integer> tuple) {
if (tuple.size() > 0) {
Integer head = tuple[0]
if (!tail[head]) {
tail[head] = new ComputedVersionComponent()
}
if (head > max) {
max = head
}
if (tuple.size() > 1) {
tail[head].addVersionComponentTuple(tuple[1..-1]) // [1..-1] is all elements except the first
}
}
}
List<Integer> getVersionComponentTuple(List<Integer> tuple) {
Integer head = tuple[0]
if (!tail[head]) {
throw new GradleException("Attempting to retrieve a version outside the range of registered versions")
}
return ( tuple.size() > 1
? [head]+tail[head].getVersionComponentTuple(tuple[1..-1]) // [1..-1] is all elements except the first
: [head]+[tail[head].max + 1] )
}
}
class ComputedVersion {
// Class holding the version tree for our current checkout
// and the current active branch
static List<String> MASTER_BRANCHES = [ // map of master branch names
'refs/remotes/origin/master',
'refs/remotes/origin/master-latest-build' ]
static Integer MASTER_VERSION = 2 // master is 2.next - needs to be updated
// as soon as we start working on 3.0
// See http://groovy.codehaus.org/Documenting+Regular+Expressions+in+Groovy
static String TAG_REGEX = '''(?x) # Allow this format
^v # always begins with "v"
(\\d+) # major 2nd group (the 1st group is always the whole expression)
(\\.(\\d+))? # minor 4th group (the 3rd includes the dot)
(\\.(\\d+))? # patch 6th...
(\\.(\\d+))? # hotfix 8th...
GA$ # must end in GA'''
static String BRANCH_REGEX = '''(?x) # Allow this format
^.*/ # remote branch
(\\d+) # major
(\\.(\\d+))? # minor (optional)
(\\.(\\d+))? # patch (optional)
\\.next$ # must end in .next'''
String activeBranch = null
private ComputedVersionComponent versions = new ComputedVersionComponent()
private List<List<Integer>> remoteBranches = []
private List<Integer> makeTuple(String string, String matchRegex) {
Integer matchCount = 0
List<Integer> tuple = []
string.findAll(~matchRegex) { match ->
match.each {
matchCount += 1
// We assume the "matcher" regexp is of the form
// ^prefix(\d+)(\.(\d+))?(\.(\d+))?(\.(\d+))?suffix$
// so only even numbered matches contain version numbers.
if (matchCount % 2 == 0 && it != null) {
tuple << it.toInteger()
}
}
}
return tuple
}
private List<Integer> makeTupleFromBranch(String branch) {
return ( MASTER_BRANCHES.contains(branch)
? [MASTER_VERSION]
: makeTuple(branch, BRANCH_REGEX) )
}
private String makeBranchFromTuple(List<Integer> tuple) {
return ( tuple
? 'refs/remotes/origin/' + tuple.join('.') + '.next'
: MASTER_BRANCHES[0] )
}
private List<Integer> makeTupleFromTag(String tag) {
return makeTuple(tag, TAG_REGEX)
}
private void addBranch(String branch) {
List<Integer> tuple = makeTupleFromBranch(branch)
if (tuple) {
versions.addVersionComponentTuple(tuple)
// Master is weird: on the one hand, it could be 4.next, but
// for sorting purposes, it is the oldest branch ever, so we
// assign it the empty tuple instead of [MASTER_VERSION]
// If this is not done, then 3.9.next would be older than 4.next (aka master)
// which is wrong. A feature branch created off of master
// before 3.9.next was created would then get assigned 3.9.next,
// when it should be staying with master.
remoteBranches << ( MASTER_BRANCHES.contains(branch) ? [] : tuple )
}
}
private void addTag(String tag) {
List<Integer> tuple = makeTupleFromTag(tag)
if (tuple) {
versions.addVersionComponentTuple(tuple)
}
}
private List<Integer> getVersionTuple(List<Integer> tuple) {
try {
List<Integer> result = versions.getVersionComponentTuple(tuple)
while (result[-1] == 0) { // [-1] = last element
result.pop()
}
return result
} catch(e) {
// catch and rethrow, so we can add the original tuple into the
// exception. And yes, if for some reason we cannot determine the
// product version, we need to fail the build.
throw new GradleException("Version outside of known range: ${tuple}")
}
}
private void seedBranchAndTags(String rootDir) {
ComputedVersionGit git = new ComputedVersionGit(rootDir)
git.getTags().each { addTag(it) }
git.getBranches().each { addBranch(it) }
// Sort by age. 1.0.next is created before 1.0.1.next etc..
// master is the empty list and is the oldest of them all.
// The sorting helps resolve ties when determining the closest
// remote branch
remoteBranches.sort {
if (it) {
// Sadly, groovy has a weird rule for sorting tuples:
// [1,3] < [1,4], but [1,3] > [4]. This means one
// has to ensure we are comparing same sized tuples.
List<Integer> padded = it.collect()
while (padded.size() < 4) {
padded << 0 // append zeros until we are good.
}
return padded
} else {
return []
}
}
activeBranch = git.getActiveBranch(remoteBranches.collect { makeBranchFromTuple(it) })
git.close()
}
/**
* Compute the version string to use on the current git branch
* @return the unpadded version string (no trailing zeros)
*/
String getVersion(String rootDir) {
seedBranchAndTags(rootDir)
String version = 'unknown'
if (activeBranch) {
version = getVersionTuple(makeTupleFromBranch(activeBranch)).join('.')
}
return version
}
/**
* Pad a version string with zeros to make a 4 component version
* @return a version string of the form "major.minor.patch.hotfix"
*/
String padVersion(String version) {
List<String> tuple = version.tokenize('.') // split() adds regex overhead
while (tuple.size() < 4) {
tuple << '0'
}
return tuple.join('.')
}
void testMe() {
[ 'v1.8.0.1GA',
'v1.8.0.3GA',
'v1.8.1.1GA',
'v1.8.2.1GA' ].each { addTag(it) }
[ 'refs/remotes/origin/1.8.0.next',
'refs/remotes/origin/1.8.1.next',
'refs/remotes/origin/1.8.next',
'refs/remotes/origin/1.9.next',
'refs/remotes/origin/master',
'refs/remotes/origin/1.next' ].each { addBranch(it) }
[ 'refs/remotes/origin/1.next': '1.10',
'refs/remotes/origin/master': "${MASTER_VERSION}",
'refs/remotes/origin/1.8.next': '1.8.3',
'refs/remotes/origin/1.9.next': '1.9',
'refs/remotes/origin/1.8.0.next': '1.8.0.4',
'refs/remotes/origin/1.8.1.next': '1.8.1.2' ].each { testCase, expectedResult ->
String actualResult = getVersionTuple(makeTupleFromBranch(testCase)).join('.')
println("${testCase} -> ${actualResult} (expected: ${expectedResult})")
assert(actualResult == expectedResult)
}
}
}
% gradle testComputedVersion
Configuration on demand is an incubating feature.
:buildSrc:compileJava UP-TO-DATE
:buildSrc:compileGroovy
:buildSrc:processResources UP-TO-DATE
:buildSrc:classes
:buildSrc:jar
:buildSrc:assemble
:buildSrc:compileTestJava UP-TO-DATE
:buildSrc:compileTestGroovy UP-TO-DATE
:buildSrc:processTestResources UP-TO-DATE
:buildSrc:testClasses UP-TO-DATE
:buildSrc:test UP-TO-DATE
:buildSrc:check UP-TO-DATE
:buildSrc:build
:testComputedVersion
Current Branch / Version: null / unknown (unknown.0.0.0)
refs/remotes/origin/1.next -> 1.10 (expected: 1.10)
refs/remotes/origin/master -> 2 (expected: 2)
refs/remotes/origin/1.8.next -> 1.8.3 (expected: 1.8.3)
refs/remotes/origin/1.9.next -> 1.9 (expected: 1.9)
refs/remotes/origin/1.8.0.next -> 1.8.0.4 (expected: 1.8.0.4)
refs/remotes/origin/1.8.1.next -> 1.8.1.2 (expected: 1.8.1.2)
BUILD SUCCESSFUL
Total time: 2.215 secs
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment