com.rallyhealth.sbt.versioning.GitDriver.scala Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of bleep-plugin-git-versioning_3 Show documentation
Show all versions of bleep-plugin-git-versioning_3 Show documentation
A bleeping fast scala build tool!
The newest version!
package bleep.plugin.versioning
import java.io.File
import scala.sys.process.*
/** Driver that allows executing common commands against a git working directory.
*
* See also [[GitFetcher]].
*/
trait GitDriver {
/** Used to find the previous version and to check whether the HEAD and version commit are the same */
def branchState: GitBranchState
/** Used to determine whether the current version is dirty or not. */
def workingState: GitWorkingState
/** Returns the number of commits in this repository, up to but not including the optional limit.
*
* @param hash
* A commit hash to stop counting. This will NOT be included in the count. If None this will count all commits back to the first commit.
*/
def getCommitCount(hash: Option[String]): Int
/** Returns the current version of the code from the branch version AND git's working state. (Including the current state might seem weird but is technically
* part of the version -- the version includes not just the prior version but also the contents of the index of the working directory. Without this we
* couldn't tell dirty or not.)
*
* @param ignoreDirty
* Forces clean builds, i.e. when true this will not add '-dirty' to the version (nor force creating a [[SnapshotVersion]] from a [[ReleaseVersion]]).
*/
def calcCurrentVersion(ignoreDirty: Boolean): SemanticVersion = {
val currVersion: SemanticVersion = branchState match {
case GitBranchStateTwoReleases(_, headVersion, _, _) =>
headVersion
case GitBranchStateOneReleaseNotHead(head, _, version) =>
SnapshotVersion.createAfterRelease(version, head)
case GitBranchStateOneReleaseHead(_, version) =>
version
case GitBranchStateNoReleases(head) =>
SnapshotVersion.createInitialVersion(head)
case GitBranchStateNoCommits =>
ReleaseVersion.initialVersion
}
if (ignoreDirty)
currVersion.setDirty(value = false)
else
currVersion.setDirty(workingState.isDirty)
}
}
/** Generic utilities for dealing with Git.
*
* See also [[GitFetcher]].
*
* @param dir
* Directory where we can query Git for information. This will be checked to confirm that it is an actual git clone.
*/
class GitDriverImpl(dir: File) extends GitDriver {
require(isGitRepo(dir), "Must be in a git repository")
require(isGitCompatible, "Must be git version 2.X.X or greater")
private class GitException(msg: String) extends Exception(msg)
// Validate that the git version is over 2.X.X
protected def isGitCompatible: Boolean = {
val outputLogger = new BufferingProcessLogger
val exitCode: Int = Process(s"""git --version""", dir) ! outputLogger
// git --version returns: git version x.y.z
val gitSemver = """git version (\d+)\.(\d+)\.(\d+).*""".r
exitCode match {
case 0 =>
val gitVersion = outputLogger.stdout.mkString("").trim.toLowerCase
gitVersion match {
case gitSemver(major, minor @ _, patch @ _) =>
major.toInt > 1
case _ =>
throw new GitException(s"""Version output was not of the form 'git version x.y.z'
|version was '${gitVersion}'""".stripMargin)
}
case unexpected =>
throw new GitException(
s"""Unexpected git exit status: $unexpected
|stderr:
|${outputLogger.stderr.mkString("\n")}
|stdout:
|${outputLogger.stdout.mkString("\n")}""".stripMargin
)
}
}
private def isGitRepo(dir: File): Boolean = {
// this does NOT use runCommand() because that uses this method to check if the directory is a git directory
val outputLogger = new BufferingProcessLogger
// http://stackoverflow.com/a/16925062
val exitCode: Int = Process(s"""git rev-parse --is-inside-work-tree""", dir) ! outputLogger
exitCode match {
case 0 => outputLogger.stdout.mkString("").trim.toLowerCase == "true"
case 128 => false // https://stackoverflow.com/a/19441790
case unexpected =>
throw new GitException(
s"""Unexpected git exit status: $unexpected
|stderr:
|${outputLogger.stderr.mkString("\n")}
|stdout:
|${outputLogger.stdout.mkString("\n")}""".stripMargin
)
}
}
override val branchState: GitBranchState =
gitLog("--max-count=1").headOption match {
case Some(headCommit) =>
// we only care about the RELEASE commits so let's get them in order from reflog
val releaseRefs: Seq[(GitCommit, ReleaseVersion)] = gitForEachRef.collect { case gc @ ReleaseVersion(rv) => (gc, rv) }
// We only care about the current release and previous release so let's take the top two.
// Then we want to find out what which git log commit is associated to the reflog sha.
// Note: gitForEachRef will return return reference shas and the tags associated with them.
// the reference shas are not always the same as the commit shas associated with git log.
// That is why we have to run the command here to find the correct sha
// Note: do not move git log into gitForEachRef as on long revisions it will take a LOT of time
val releases: Seq[(GitCommit, ReleaseVersion)] = releaseRefs.take(2).map(tp => (gitLog(s"${tp._1.fullHash} --max-count=1").head, tp._2))
val maybeCurrRelease = releases.headOption
val maybePrevRelease = releases.drop(1).headOption
(maybeCurrRelease, maybePrevRelease) match {
case (Some((currCommit, _)), Some((prevCommit, _))) if currCommit == prevCommit =>
throw new IllegalStateException(s"currCommit=$currCommit cannot equal prevCommit=$prevCommit")
case (Some((currCommit, currVersion)), Some((prevCommit, prevVersion))) if currCommit == headCommit =>
GitBranchStateTwoReleases(currCommit, currVersion, prevCommit, prevVersion)
case (Some((currCommit, currVersion)), None) if currCommit == headCommit =>
GitBranchStateOneReleaseHead(currCommit, currVersion)
case (Some((currCommit, currVersion)), _) =>
val headCommitWithCount = GitCommitWithCount(headCommit, getCommitCount(Some(currCommit.fullHash)))
GitBranchStateOneReleaseNotHead(headCommitWithCount, currCommit, currVersion)
case (None, _) =>
GitBranchStateNoReleases(GitCommitWithCount(headCommit, getCommitCount(None)))
}
case None =>
GitBranchStateNoCommits
}
override def workingState: GitWorkingState =
GitWorkingState(!checkClean())
override def getCommitCount(hash: Option[String]): Int = {
val limitStr = hash.map("^" + _).getOrElse("")
val (_, output) = runCommand(s"""git rev-list --first-parent --count HEAD $limitStr""".trim)
output.mkString("").trim.toInt
}
/** Executes git rev-parse to determine the current branch/HEAD commit
*/
private def gitBranch: String = {
val cmd = s"git rev-parse --abbrev-ref HEAD"
val (exitCode, output) = runCommand(cmd, throwIfNonZero = false)
exitCode match {
// you get 128 when you run a git cmd in a dir not under git vcs
case 0 =>
val res = output.map { line =>
line
}
res.head
case 128 =>
throw new IllegalStateException(s"Error 128: a git cmd was run in a dir that is not under git vcs or git rev-parse failed to run.")
}
}
/** Returns an ordered list of versions that are merged into your branch.
*/
private def gitForEachRef: Seq[GitCommit] = {
require(isGitRepo(dir), "Must be in a git repository")
require(isGitCompatible, "Must be git version 2.X.X or greater")
// Note: nested shell commands, piping and redirection will not work with runCommand since it is just
// invoking an OS process. You could invoke a shell and pass expressions if needed.
val cmd = s"git for-each-ref --sort=-v:refname refs/tags --merged=${gitBranch}"
/** Example output:
* {{{
* 686623c25b52e40fe6270ab57419551b88e89dfe tag refs/tags/v1.0.0
* fb22d49dd7d7bf5b5f130c4ff3b66667d97bc308 commit refs/tags/v0.0.3
* 5ca402250fd63e6ac3a9b51d457b89c092195098 commit refs/tags/v0.0.2
* }}}
*/
// val (exitCode, output) = runCommand(cmd, throwIfNonZero = false)
val (exitCode, output) = runCommand(cmd, throwIfNonZero = false)
exitCode match {
// you get 128 when you run 'git log' on a repository with no commits
case 0 | 128 =>
val abbreviatedHashLength = findAbbreviatedHashLength()
output map { line =>
GitCommit.fromGitRef(line, abbreviatedHashLength)
}
case ret => throw new IllegalStateException(s"Non-zero exit code when running git log: $ret")
}
}
/** Executes a single "git log" command.
*/
private def gitLog(arguments: String): Seq[GitCommit] = {
require(isGitRepo(dir), "Must be in a git repository")
require(isGitCompatible, "Must be git version 2.X.X or greater")
// originally this used "git describe", but that doesn't always work the way you want. its definition of "nearest"
// tag is not always what you think it means: it does NOT search backward to the root, it will search other
// branches too. See http://www.xerxesb.com/2010/12/20/git-describe-and-the-tale-of-the-wrong-commits/
// The old command was the following:
// git log --oneline --decorate=short --first-parent --simplify-by-decoration --no-abbrev-commit
// which has the argument --first-parent. The problem is that first-parent will hide release that are done in in another
// branch and merged into master.
val cmd = s"git log --oneline --decorate=short --simplify-by-decoration --no-abbrev-commit $arguments"
/** Example output:
* {{{
* 34f0ea0e25cf4c57bd0b732c03d19fc18492b827 (HEAD -> tagging-test, tag: rawr) [CODE-2] Add tests for tagging.
* a204a6127c290305c12a417eb1c6ac5490da86ae (upstream/master, master) [CODE-2] Remove "latest.integration" recommendation. (#43)
* 69a1739e82f9c31e0a3a6a70e006b6d063ba403a (rcmurphy/master) NOJIRA: Adding contribution guidelines
* 77edaa07f4552602a18f45b99e92c9f2f45e2eee (tag: v1.0.0-m0) Fix ivy artifactory resolver pattern. (#36)
* }}}
*/
val (exitCode, output) = runCommand(cmd, throwIfNonZero = false)
exitCode match {
// you get 128 when you run 'git log' on a repository with no commits
case 0 | 128 =>
val abbreviatedHashLength = findAbbreviatedHashLength()
output map { line =>
GitCommit.fromGitLog(line, abbreviatedHashLength)
}
case ret => throw new IllegalStateException(s"Non-zero exit code when running git log: $ret")
}
}
private def checkClean(): Boolean = {
// I changed from using "git diff-index" because that returns an error when there are no commits
// We only check tracked files (files that have been "git add"-ed before) because un-tracked files are likely
// temporary junk created by build tools (e.g. Jenkins, Docker, npm, etc.)
val (_, output) = runCommand("git status --porcelain --untracked-files=no")
output.mkString("").isEmpty
}
/** Returns the abbreviated hash length for a Git hash object. The length of a hash is variable depending on the number of commits.
*
* See [[https://stackoverflow.com/questions/18134627/how-much-of-a-git-sha-is-generally-considered-necessary-to-uniquely-identify-a]] and
* [[https://stackoverflow.com/questions/16413373/git-get-short-hash-from-regular-hash]] and
* [[https://stackoverflow.com/questions/32405922/in-my-repo-how-long-must-the-longest-hash-prefix-be-to-prevent-any-overlap]] for more information.
*/
private def findAbbreviatedHashLength(): Int = {
val (exitCode, output) = runCommand("git rev-parse --short HEAD", throwIfNonZero = false)
exitCode match {
// you get 128 when you run 'git rev-parse' on a repository with no commits
case 0 | 128 =>
// 7 is the default hash length, so let's use that if the commit length is shorter OR we have zero commits
math.max(output.mkString("").trim.length, 7)
case ret =>
throw new IllegalStateException(s"Non-zero exit code when running git rev-parse: $ret")
}
}
/** Executes a single shell command, typically a 'git' command.
*
* @param throwIfNonZero
* If true throws an exception if the exit code is non-zero. Otherwise returns it. True can simplify your logic if the exit code is only for failure
* status.
* @return
* ( Exit code, standard output )
*/
private def runCommand(cmd: String, throwIfNonZero: Boolean = true): (Int, Seq[String]) = {
require(isGitRepo(dir), "Must be in a git repository")
require(isGitCompatible, "Must be git version 2.X.X or greater")
val outputLogger = new BufferingProcessLogger
val exitCode: Int = Process(cmd, dir) ! outputLogger
val result = (exitCode, outputLogger.stdout.toSeq)
if (throwIfNonZero && exitCode != 0)
throw new IllegalStateException(s"Non-zero exit code when running '$cmd': $exitCode")
result
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy