Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for detecting previous stable version #50

Merged
merged 1 commit into from
Jun 7, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ Inspired by:
* The way that Mercurial [versions itself](https://selenic.com/hg/file/3.9.1/setup.py#l179)
* The [GitVersioning][] AutoPlugin in [sbt-git][].

Features:
* Dynamically set your version by looking at the closest tag to the current commit
* Detect the previous version
* Useful for automatic [binary compatibility checks](https://github.com/lightbend/migration-manager) across library versions

[sbt-git]: https://github.com/sbt/sbt-git
[GitVersioning]: https://github.com/sbt/sbt-git/blob/v0.8.5/src/main/scala/com/typesafe/sbt/SbtGit.scala#L266-L270

Expand Down Expand Up @@ -43,6 +48,22 @@ Other than that, as `sbt-dynver` is an AutoPlugin that is all that is required.
| when there are no commits, or the project isn't a git repo | HEAD+20140707-1030 | true | false |
```

#### Previous Version Detection
Given the following git history, here's what `previousStableVersion` returns when at each commit:
```
* (tagged: v1.1.0) --> Some("1.0.0")
* (untagged) --> Some("1.0.0")
| * (tagged: v2.1.0) --> Some("2.0.0")
| * (tagged: v2.0.0) --> Some("1.0.0")
|/
* (tagged: v1.0.0) --> None
* (untagged) --> None
```
Previous version is detected by looking at the closest tag of the parent commit of HEAD.

If the current commit has multiple parents, the first parent will be used. In git, the first parent
comes from the branch you merged into (e.g. `master` in `git checkout master && git merge my-feature-branch`)

## Tag Requirements

In order to be recognized by sbt-dynver, tags must begin with the lowercase letter 'v' followed by a digit.
Expand Down
50 changes: 41 additions & 9 deletions src/main/scala/sbtdynver/DynVerPlugin.scala
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
package sbtdynver

import java.util._

import scala.util._
import sbt._, Keys._
import sbt._
import Keys._

import scala.sys.process.ProcessLogger

object DynVerPlugin extends AutoPlugin {
override def requires = plugins.JvmPlugin
Expand All @@ -14,28 +18,32 @@ object DynVerPlugin extends AutoPlugin {
val dynverCurrentDate = settingKey[Date]("The current date, for dynver purposes")
val dynverGitDescribeOutput = settingKey[Option[GitDescribeOutput]]("The output from git describe")
val dynverSonatypeSnapshots = settingKey[Boolean]("Whether to append -SNAPSHOT to snapshot versions")
val dynverGitPreviousStableVersion = settingKey[Option[GitDescribeOutput]]("The last stable tag")
val dynverCheckVersion = taskKey[Boolean]("Checks if version and dynver match")
val dynverAssertVersion = taskKey[Unit]("Asserts if version and dynver match")

// Would be nice if this were an 'upstream' key
val isVersionStable = taskKey[Boolean]("The version string identifies a specific point in version control, so artifacts built from this version can be safely cached")
val previousStableVersion = settingKey[Option[String]]("The last stable version as seen from the current commit (does not include the current commit's version/tag)")
}
import autoImport._

override def buildSettings = Seq(
version := {
val out = dynverGitDescribeOutput.value
val date = dynverCurrentDate.value
if(dynverSonatypeSnapshots.value) out.sonatypeVersion(date)
else out.version(date)
},
isSnapshot := dynverGitDescribeOutput.value.isSnapshot,
isVersionStable := dynverGitDescribeOutput.value.isVersionStable,
version := {
val out = dynverGitDescribeOutput.value
val date = dynverCurrentDate.value
if(dynverSonatypeSnapshots.value) out.sonatypeVersion(date)
else out.version(date)
},
isSnapshot := dynverGitDescribeOutput.value.isSnapshot,
isVersionStable := dynverGitDescribeOutput.value.isVersionStable,
previousStableVersion := dynverGitPreviousStableVersion.value.previousVersion,

dynverCurrentDate := new Date,
dynverInstance := DynVer(Some((Keys.baseDirectory in ThisBuild).value)),
dynverGitDescribeOutput := dynverInstance.value.getGitDescribeOutput(dynverCurrentDate.value),
dynverSonatypeSnapshots := false,
dynverGitPreviousStableVersion := dynverInstance.value.getGitPreviousStableTag,

dynver := dynverInstance.value.version(new Date),
dynverCheckVersion := (dynver.value == version.value),
Expand Down Expand Up @@ -86,6 +94,7 @@ final case class GitDescribeOutput(ref: GitRef, commitSuffix: GitCommitSuffix, d
else version

def isSnapshot(): Boolean = hasNoTags() || !commitSuffix.isEmpty || isDirty()
def previousVersion: String = ref.dropV.value
def isVersionStable(): Boolean = !isDirty()

def hasNoTags(): Boolean = !ref.isTag
Expand Down Expand Up @@ -120,18 +129,24 @@ object GitDescribeOutput extends ((GitRef, GitCommitSuffix, GitDirtySuffix) => G

def version(d: Date): String = mkVersion(_.version, DynVer fallback d)
def sonatypeVersion(d: Date): String = mkVersion(_.sonatypeVersion, DynVer fallback d)
def previousVersion: Option[String] = _x.map(_.previousVersion)
def isSnapshot: Boolean = _x.map(_.isSnapshot).getOrElse(true)
def isVersionStable: Boolean = _x.map(_.isVersionStable).getOrElse(false)


def isDirty: Boolean = _x.fold(true)(_.isDirty())
def hasNoTags: Boolean = _x.fold(true)(_.hasNoTags())
}
}

// sealed just so the companion object can extend it. Shouldn't've been a case class.
sealed case class DynVer(wd: Option[File]) {

private val errLogger = ProcessLogger(stdOut => (), stdErr => println(stdErr))

def version(d: Date): String = getGitDescribeOutput(d) version d
def sonatypeVersion(d: Date): String = getGitDescribeOutput(d) sonatypeVersion d
def previousVersion : Option[String] = getGitPreviousStableTag.previousVersion
def isSnapshot(): Boolean = getGitDescribeOutput(new Date).isSnapshot
def isVersionStable(): Boolean = getGitDescribeOutput(new Date).isVersionStable

Expand All @@ -146,8 +161,25 @@ sealed case class DynVer(wd: Option[File]) {
.map(GitDescribeOutput.parse)
}

def getGitPreviousStableTag: Option[GitDescribeOutput] = {
(for {
// Find the parent of the current commit. The "^1" instructs it to show only the first parent,
// as merge commits can have multiple parents
parentHash <- execAndHandleEmptyOuptut(s"git --no-pager log --pretty=%H -n 1 HEAD^1")
// Find the closest tag of the parent commit
tag <- execAndHandleEmptyOuptut(s"git describe --tags --abbrev=0 --always $parentHash")
} yield {
GitDescribeOutput.parse(tag)
}).toOption
}

def timestamp(d: Date): String = "%1$tY%1$tm%1$td-%1$tH%1$tM" format d
def fallback(d: Date): String = s"HEAD+${timestamp(d)}"

private def execAndHandleEmptyOuptut(cmd: String): Try[String] = {
Try(scala.sys.process.Process(cmd, wd) !! errLogger)
.filter(_.trim.nonEmpty)
}
}
object DynVer extends DynVer(None) with (Option[File] => DynVer)

Expand Down
77 changes: 77 additions & 0 deletions src/test/scala/sbtdynver/DynVerSpec.scala
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,83 @@ object VersionSpec extends Properties("VersionSpec") {
property("on tag v1.0.0 and 1 commit with local changes") = onTagAndCommitDirty().version() ?= "1.0.0+1-1234abcd+20160917-0000"
}

object PreviousVersionSpec extends Properties("PreviousVersionSpec") {
property("not a git repo") = notAGitRepo().previousVersion() ?= None
property("no commits") = noCommits().previousVersion() ?= None
property("on commit, w/o local changes") = onCommit().previousVersion() ?= None
property("on commit with local changes") = onCommitDirty().previousVersion() ?= None
property("on tag v1.0.0, w/o local changes") = onTag().previousVersion() ?= None
property("on tag v1.0.0 with local changes") = onTagDirty().previousVersion() ?= None
property("on tag v1.0.0 and 1 commit, w/o local changes") = onTagAndCommit().previousVersion() ?= Some("1.0.0")
property("on tag v1.0.0 and 1 commit with local changes") = onTagAndCommitDirty().previousVersion() ?= Some("1.0.0")
property("on tag v1.0.0 and 2 commits, w/o local changes") = onTagAndSecondCommit().previousVersion() ?= Some("1.0.0")
property("on tag v2.0.0, w/o local changes") = onSecondTag().previousVersion() ?= Some("1.0.0")
property("with merge commits") = {
val state = onTag()
.branch("newbranch")
.commit()
.checkout("master")
.merge("newbranch")
.tag("v2.0.0")

state.previousVersion() ?= Some("1.0.0")
}

property("with merge commits and with untagged HEAD") = {
val state = onTag()
.branch("newbranch")
.commit()
.checkout("master")
.merge("newbranch")

state.previousVersion() ?= Some("1.0.0")
}

property("multiple branches, each with their own tags") = {
val state = onTag()

state
.branch("v2")
.commit()
.tag("v2.0.0")

state
.commit()
.tag("v2.1.0")

state
.checkout("master") // checkout the v1.x branch
.commit()
.tag("v1.1.0")

state.checkout("v2")

state.previousVersion() ?= Some("2.0.0")
}

property("merge commit where both branches have tags - should use the first parent (branch that was merged into)") = {
val state = onTag()

state
.branch("v2")
.commit()
.tag("v2.0.0")

state
.commit()
.tag("v2.1.0")

state
.checkout("master") // checkout the v1.x branch
.commit()
.tag("v1.1.0")

state.merge("v2")

state.previousVersion() ?= Some("1.1.0")
}
}

object IsSnapshotSpec extends Properties("IsSnapshotSpec") {
property("not a git repo") = notAGitRepo().isSnapshot() ?= true
property("no commits") = noCommits().isSnapshot() ?= true
Expand Down
57 changes: 46 additions & 11 deletions src/test/scala/sbtdynver/RepoStates.scala
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
package sbtdynver

import java.nio.file._, StandardOpenOption._
import java.nio.file._
import StandardOpenOption._
import java.util._

import scala.collection.JavaConverters._
import org.eclipse.jgit.api.MergeCommand.FastForwardMode

import scala.collection.JavaConverters._
import org.eclipse.jgit.api._
import org.eclipse.jgit.merge.MergeStrategy

object RepoStates {
def notAGitRepo() = State()
def noCommits() = notAGitRepo().init()
def onCommit() = noCommits().commit()
def onCommitDirty() = onCommit().dirty()
def onTag(n: String = "v1.0.0") = onCommit().tag(n)
def onTagDirty() = onTag().dirty()
def onTagAndCommit() = onTag().commit()
def onTagAndCommitDirty() = onTagAndCommit().dirty()
def notAGitRepo() = State()
def noCommits() = notAGitRepo().init()
def onCommit() = noCommits().commit()
def onCommitDirty() = onCommit().dirty()
def onTag(n: String = "v1.0.0") = onCommit().tag(n)
def onTagDirty() = onTag().dirty()
def onTagAndCommit() = onTag().commit()
def onTagAndCommitDirty() = onTagAndCommit().dirty()
def onTagAndSecondCommit() = onTagAndCommitDirty().commit()
def onSecondTag(n: String = "v2.0.0") = onTagAndSecondCommit().tag(n)

final case class State() {
val dir = doto(Files.createTempDirectory(s"dynver-test-").toFile)(_.deleteOnExit())
Expand All @@ -26,7 +31,11 @@ object RepoStates {
var sha: String = "undefined"

def init() = andThis(git = Git.init().setDirectory(dir).call())
def dirty() = andThis(Files.write(dir.toPath.resolve("f.txt"), Seq("1").asJava, CREATE, APPEND))
def dirty() = {
// We randomize the content added otherwise we will get the same git hash for two separate commits
// because our commits are made at almost the same time
andThis(Files.write(dir.toPath.resolve("f.txt"), Seq(scala.util.Random.nextString(10)).asJava, CREATE, APPEND))
}
def tag(n: String) = andThis(git.tag().setName(n).call())

def commit() = andThis {
Expand All @@ -35,13 +44,39 @@ object RepoStates {
sha = git.commit().setMessage("1").call().abbreviate(8).name()
}

def branch(branchName: String) = andThis {
git.branchCreate().setName(branchName).call()
git.checkout().setName(branchName).call()
}

def checkout(branchName: String) = andThis {
git.checkout().setName(branchName).call()
sha = getCurrentHeadSHA
}

def merge(branchName: String) = andThis {
git.merge()
.include(git.getRepository.findRef(branchName))
.setCommit(true)
.setFastForward(FastForwardMode.NO_FF)
.setStrategy(MergeStrategy.OURS)
.call()
sha = getCurrentHeadSHA
}

def version() = dynver.version(date).replaceAllLiterally(sha, "1234abcd")
def sonatypeVersion() = dynver.sonatypeVersion(date).replaceAllLiterally(sha, "1234abcd")
def previousVersion() = dynver.previousVersion
def isSnapshot() = dynver.isSnapshot()
def isVersionStable() = dynver.isVersionStable()

private def doalso[A, U](x: A)(xs: U*) = x
private def doto[A, U](x: A)(f: A => U) = doalso(x)(f(x))
private def andThis[U](x: U) = this

private def getCurrentHeadSHA = {
git.getRepository.findRef("HEAD").getObjectId.abbreviate(8).name()
}
}

}