diff --git a/README.md b/README.md index 9bed22a..6872284 100644 --- a/README.md +++ b/README.md @@ -31,32 +31,45 @@ additionally, use one of the older README.md files: (https://github.com/sbt/sbt- JGit is a Java interface to git that allows some git operations to be performed in the JVM without invoking an external git executable. By default, -this plugin uses JGit for read-only operations such as inspecting HEAD; for -write operations, it assumes a git executable is present and on the PATH and -it uses that. +this plugin prefers the system `git` executable for both read-only metadata +such as inspecting HEAD and for explicit git operations such as clone, pull, +push, and the sbt `git` command. Read-only metadata falls back to JGit if +system git is not available or cannot read the build's Git metadata. Explicit +git operations fall back to JGit only when system git is not available. -In certain circumstances you may want to force the use of JGit or an -executable for both read-only and read-write operations; for example, if no -git executable is present (e.g. you use windows and you haven't installed git -or it's not on your PATH) you need to disable the console interface, or if -you rely on a git feature that JGit does not support (e.g. worktrees) you need -to disable the JGit interface. +In certain circumstances you may want deterministic behavior instead of the +default fallback. For example, CI can force system git so the build fails if +the executable is missing, while restricted environments can force JGit so the +plugin never shells out. -The following settings will force the use of only JGit or a git executable, -respectively: +The following settings control read-only operations: -* `useJGit` -* `useReadableConsoleGit` +* `useSystemGitFirstForReads` - prefer system git and fall back to JGit. This + is the default. +* `useSystemGitOnlyForReads` - use system git only. +* `useJGitOnlyForReads` - use JGit only. + +The following settings control explicit git operations: + +* `useSystemGitFirstForOperations` - prefer system git and fall back to JGit. + This is the default. +* `useSystemGitOnlyForOperations` - use system git only. +* `useJGitOnlyForOperations` - use JGit only. + +The older `useJGit` helper remains available and now selects JGit for both +read-only metadata and explicit git operations. The older +`useReadableConsoleGit` helper remains available as a compatibility alias for +forcing read-only metadata to use system git. These settings can be included in your project's `git.sbt` or in -`~/.sbt/1.0/git.sbt` -- for example, if no git executable is installed, -either file can have the following contents: +`~/.sbt/1.0/git.sbt` -- for example, to force JGit and prevent sbt-git from +shelling out, either file can have the following contents: useJGit Or you can `set` the appropriate setting in the sbt prompt: - > set useReadableConsoleGit + > set useSystemGitOnlyForReads [info] Reapplying settings... [info] Set current project to scala-arm (in build file:...) > session save diff --git a/build.sbt b/build.sbt index f673c9b..3f98b8b 100644 --- a/build.sbt +++ b/build.sbt @@ -15,8 +15,15 @@ crossScalaVersions := Seq(scala212, scala3) enablePlugins(GitVersioning, SbtPlugin) git.baseVersion := "1.0" +lazy val jgitVersion = Def.setting { + scalaBinaryVersion.value match { + case "2.12" => "5.13.5.202508271544-r" + case _ => "7.7.0.202606012155-r" + } +} + libraryDependencies ++= Seq( - "org.eclipse.jgit" % "org.eclipse.jgit" % "5.13.5.202508271544-r", + "org.eclipse.jgit" % "org.eclipse.jgit" % jgitVersion.value, "com.michaelpollmeier" % "versionsort" % "1.0.17", "org.scalameta" %% "munit" % "1.3.3" % Test ) diff --git a/src/main/scala/com/github/sbt/git/ConsoleGitReadableOnly.scala b/src/main/scala/com/github/sbt/git/ConsoleGitReadableOnly.scala index 0df62b0..0128992 100644 --- a/src/main/scala/com/github/sbt/git/ConsoleGitReadableOnly.scala +++ b/src/main/scala/com/github/sbt/git/ConsoleGitReadableOnly.scala @@ -5,28 +5,43 @@ import scala.util.Try import sbt.{File, Logger} class ConsoleGitReadableOnly(git: GitRunner, cwd: File, log: Logger) extends GitReadonlyInterface { - def branch: String = git("rev-parse", "--abbrev-ref", "HEAD")(cwd, log) + private def splitOutput(output: String): Seq[String] = + output.split("\\s+").toSeq.filter(_.nonEmpty) - def headCommitSha: Option[String] = Try(git("rev-parse", "HEAD")(cwd, log)).toOption + private def describeArgs(patterns: Seq[String]): Seq[String] = + Seq("describe", "--tags") ++ patterns.flatMap(Seq("--match", _)) - def headCommitDate: Option[String] = Try(git("log", """--pretty="%cI"""", "-n", "1")(cwd, log)).toOption + def branch: String = + Try(git("symbolic-ref", "--short", "-q", "HEAD")(cwd, log)) + .orElse(Try(git("rev-parse", "--abbrev-ref", "HEAD")(cwd, log))) + .getOrElse("") - def currentTags: Seq[String] = Try(git("tag", "--points-at", "HEAD")(cwd, log).split("\\s+").toSeq).getOrElse(Seq()) + def headCommitSha: Option[String] = Try(git("rev-parse", "--verify", "--quiet", "HEAD")(cwd, log)).toOption - def describedVersion: Option[String] = Try(git("describe", "--tags")(cwd, log).split("\\s+").headOption).toOption.flatten + def headCommitDate: Option[String] = + headCommitSha.flatMap(_ => Try(git("log", "--date=format:%Y-%m-%dT%H:%M:%S%z", "--pretty=%cd", "-n", "1")(cwd, log)).toOption) + + def currentTags: Seq[String] = + headCommitSha + .map(_ => Try(splitOutput(git("tag", "--points-at", "HEAD")(cwd, log))).getOrElse(Seq())) + .getOrElse(Seq()) + + def describedVersion: Option[String] = + headCommitSha.flatMap(_ => Try(splitOutput(git("describe", "--tags")(cwd, log)).headOption).toOption.flatten) override def describedVersion(patterns: Seq[String]): Option[String] = - patterns.headOption.fold(describedVersion)(pat => - Try(git("describe", "--tags", "--match", pat)(cwd, log).split("\\s+").headOption).toOption.flatten + patterns.headOption.fold(describedVersion)(_ => + headCommitSha.flatMap(_ => Try(splitOutput(git(describeArgs(patterns)*)(cwd, log)).headOption).toOption.flatten) ) - def hasUncommittedChanges: Boolean = Try(!git("status", "-s")(cwd, log).trim.isEmpty).getOrElse(true) + def hasUncommittedChanges: Boolean = Try(!git("status", "-s", "--untracked-files=no")(cwd, log).trim.isEmpty).getOrElse(true) - def branches: Seq[String] = Try(git("branch", "--list")(cwd, log).split("\\s+").toSeq).getOrElse(Seq()) + def branches: Seq[String] = Try(splitOutput(git("branch", "--list", "--format=%(refname:short)")(cwd, log))).getOrElse(Seq()) - def remoteBranches: Seq[String] = Try(git("branch", "-l", "--remotes")(cwd, log).split("\\s+").toSeq).getOrElse(Seq()) + def remoteBranches: Seq[String] = Try(splitOutput(git("branch", "--remotes", "--format=%(refname:short)")(cwd, log))).getOrElse(Seq()) - def remoteOrigin: String = git("ls-remote", "--get-url", "origin")(cwd, log) + def remoteOrigin: String = Try(git("ls-remote", "--get-url", "origin")(cwd, log)).getOrElse("origin") - def headCommitMessage: Option[String] = Try(git("log", "--pretty=%s\n\n%b", "-n", "1")(cwd, log)).toOption + def headCommitMessage: Option[String] = + headCommitSha.flatMap(_ => Try(git("log", "--pretty=%B", "-n", "1")(cwd, log)).toOption) } diff --git a/src/main/scala/com/github/sbt/git/ConsoleGitRunner.scala b/src/main/scala/com/github/sbt/git/ConsoleGitRunner.scala index a713725..0c5abfb 100644 --- a/src/main/scala/com/github/sbt/git/ConsoleGitRunner.scala +++ b/src/main/scala/com/github/sbt/git/ConsoleGitRunner.scala @@ -39,6 +39,12 @@ object ConsoleGitRunner extends GitRunner { result } + private[sbt] def succeeds(args: String*)(cwd: File): Boolean = + Try { + IO.createDirectory(cwd) + Process(cmd ++ args, cwd, colorSupport*) ! ProcessLogger(_ => (), _ => ()) + }.toOption.contains(0) + override def toString = "git" // reduce log level for git process private class GitLogger(log: Logger) extends ProcessLogger { diff --git a/src/main/scala/com/github/sbt/git/GitBackend.scala b/src/main/scala/com/github/sbt/git/GitBackend.scala new file mode 100644 index 0000000..beaea72 --- /dev/null +++ b/src/main/scala/com/github/sbt/git/GitBackend.scala @@ -0,0 +1,26 @@ +package com.github.sbt.git + +/** Selects which Git implementation sbt-git should use. + * + * System Git means the `git` executable available to the sbt process, + * normally through PATH. JGit means the Java implementation on the plugin + * classpath. + */ +sealed trait GitBackend extends Product with Serializable + +object GitBackend { + /** Prefer the system `git` executable and fall back to JGit if it is not + * available. + */ + case object SystemGitFirst extends GitBackend + + /** Always use the system `git` executable. Builds fail if it is not + * available or cannot run the requested operation. + */ + case object SystemGitOnly extends GitBackend + + /** Always use JGit. Builds fail if JGit cannot handle the requested + * repository layout or operation. + */ + case object JGitOnly extends GitBackend +} diff --git a/src/main/scala/com/github/sbt/git/GitPlugin.scala b/src/main/scala/com/github/sbt/git/GitPlugin.scala index 7f03d00..f43e32f 100644 --- a/src/main/scala/com/github/sbt/git/GitPlugin.scala +++ b/src/main/scala/com/github/sbt/git/GitPlugin.scala @@ -8,7 +8,6 @@ object SbtGit { object GitKeys { // Read-only git settings and values for use in other build settings. - // Note: These are all grabbed using jgit currently. val gitReader = SettingKey[ReadableGit]("git-reader", "This gives us a read-only view of the git repository.") val gitBranch = SettingKey[Option[String]]("git-branch", "Target branch of a git operation") val gitCurrentBranch = SettingKey[String]("git-current-branch", "The current branch for this project.") @@ -20,9 +19,24 @@ object SbtGit { val gitDescribedVersion = SettingKey[Option[String]]("git-described-version", "Version as returned by `git describe --tags`.") val gitUncommittedChanges = SettingKey[Boolean]("git-uncommitted-changes", "Whether there are uncommitted changes.") + @transient + val gitReadBackend = SettingKey[GitBackend]( + "git-read-backend", + "Selects the Git implementation used to read repository metadata such as HEAD, tags, branch, and dirty status." + ) + // A Mechanism to run Git directly. @transient val gitRunner = TaskKey[GitRunner]("git-runner", "The mechanism used to run git in the current build.") + @transient + val gitOperationBackend = SettingKey[GitBackend]( + "git-operation-backend", + "Selects the Git implementation used for explicit git operations such as clone, pull, push, and the sbt git command." + ) + private[sbt] val systemGitAvailableOverride = SettingKey[Option[Boolean]]( + "git-system-git-available-override", + "Internal test hook for overriding whether system git is detected." + ) // Keys associated with setting a version number. val useGitDescribe = SettingKey[Boolean]("use-git-describe", "Get version by calling `git describe` on the repository") @@ -44,8 +58,10 @@ object SbtGit { // The remote repository we're using. val gitRemoteRepo = SettingKey[String]("git-remote-repo", "The remote git repository associated with this project") - // Git worktree workaround - val useConsoleForROGit = SettingKey[Boolean]("console-ro-runner", "Whether to shell out to git for ro ops in the current build.") + val useConsoleForROGit = SettingKey[Boolean]( + "console-ro-runner", + "Deprecated compatibility setting that forces read-only operations to use the system git executable." + ) } object GitCommand { @@ -116,12 +132,26 @@ object SbtGit { // Build settings. import GitKeys.* def buildSettings = Seq( + gitReadBackend := GitBackend.SystemGitFirst, + gitOperationBackend := GitBackend.SystemGitFirst, + systemGitAvailableOverride := None, useConsoleForROGit := sys.env.contains("SBT_GIT_USE_CONSOLE_FOR_RO_GIT"), - gitReader := new DefaultReadableGit( - baseDirectory.value, - if (useConsoleForROGit.value) Some(new ConsoleGitReadableOnly(ConsoleGitRunner, file("."), sLog.value)) else None - ), - gitRunner := ConsoleGitRunner, + gitReader := { + val base = baseDirectory.value + val log = sLog.value + val available = detectedSystemGitAvailable(systemGitAvailableOverride.value, base) + val readable = available && isSystemGitReadable(base) + new DefaultReadableGit( + base, + if (useSystemGitForReads(useConsoleForROGit.value, gitReadBackend.value, readable)) + Some(new ConsoleGitReadableOnly(ConsoleGitRunner, base, log)) + else None + ) + }, + gitRunner := { + val available = detectedSystemGitAvailable(systemGitAvailableOverride.value, baseDirectory.value) + selectGitRunner(gitOperationBackend.value, available) + }, gitHeadCommit := gitReader.value.withGit(_.headCommitSha), gitHeadMessage := gitReader.value.withGit(_.headCommitMessage), gitHeadCommitDate := gitReader.value.withGit(_.headCommitDate), @@ -135,6 +165,29 @@ object SbtGit { ThisBuild / gitUncommittedChanges := gitReader.value.withGit(_.hasUncommittedChanges), scmInfo := parseScmInfo(gitReader.value.withGit(_.remoteOrigin)) ) + + private[sbt] def isSystemGitAvailable(dir: File): Boolean = + ConsoleGitRunner.succeeds("--version")(dir) + + private[sbt] def detectedSystemGitAvailable(overrideValue: Option[Boolean], dir: File): Boolean = + overrideValue.getOrElse(isSystemGitAvailable(dir)) + + private[sbt] def isSystemGitReadable(dir: File): Boolean = + ConsoleGitRunner.succeeds("rev-parse", "--git-dir")(dir) + + private[sbt] def useSystemGitForReads(forceSystemGit: Boolean, backend: GitBackend, systemGitAvailable: Boolean): Boolean = + forceSystemGit || useSystemGit(backend, systemGitAvailable) + + private[sbt] def selectGitRunner(backend: GitBackend, systemGitAvailable: Boolean): GitRunner = + if (useSystemGit(backend, systemGitAvailable)) ConsoleGitRunner else JGitRunner + + private[sbt] def useSystemGit(backend: GitBackend, systemGitAvailable: Boolean): Boolean = + backend match { + case GitBackend.SystemGitFirst => systemGitAvailable + case GitBackend.SystemGitOnly => true + case GitBackend.JGitOnly => false + } + private[sbt] def parseScmInfo(remoteOrigin: String): Option[ScmInfo] = { val user = """(?:[^@\/]+@)?""" val domain = """([^\/]+)""" @@ -175,8 +228,51 @@ object SbtGit { } ) - /** A Predefined setting to use JGit runner for git. */ - def useJGit: Setting[?] = ThisBuild / gitRunner := JGitRunner + /** Use the system git executable for read-only repository metadata. + * + * This is deterministic: if system git is unavailable, the build fails + * instead of falling back to JGit. + */ + def useSystemGitOnlyForReads: Setting[?] = + ThisBuild / gitReadBackend := GitBackend.SystemGitOnly + + /** Use JGit for read-only repository metadata. + * + * This is deterministic: sbt-git will not shell out to system git for + * reads, even if JGit cannot handle the repository layout. + */ + def useJGitOnlyForReads: Setting[?] = + ThisBuild / gitReadBackend := GitBackend.JGitOnly + + /** Prefer system git for read-only repository metadata, falling back to JGit + * if system git is unavailable. This is the default. + */ + def useSystemGitFirstForReads: Setting[?] = + ThisBuild / gitReadBackend := GitBackend.SystemGitFirst + + /** Use the system git executable for explicit git operations. + * + * This is deterministic: if system git is unavailable, the build fails + * instead of falling back to JGit. + */ + def useSystemGitOnlyForOperations: Setting[?] = + ThisBuild / gitOperationBackend := GitBackend.SystemGitOnly + + /** Use JGit for explicit git operations such as clone, pull, push, and the + * sbt git command. + */ + def useJGitOnlyForOperations: Setting[?] = + ThisBuild / gitOperationBackend := GitBackend.JGitOnly + + /** Prefer system git for explicit git operations, falling back to JGit if + * system git is unavailable. This is the default. + */ + def useSystemGitFirstForOperations: Setting[?] = + ThisBuild / gitOperationBackend := GitBackend.SystemGitFirst + + /** Predefined settings to use JGit for reads and explicit git operations. */ + def useJGit: Seq[Setting[?]] = + Seq(useJGitOnlyForReads, useJGitOnlyForOperations) /** Setting to use console git for readable ops, to allow working with git worktrees */ def useReadableConsoleGit: Setting[?] = ThisBuild / useConsoleForROGit := true @@ -267,7 +363,9 @@ object SbtGit { object git { val remoteRepo = GitKeys.gitRemoteRepo val branch = GitKeys.gitBranch + val readBackend = ThisBuild / GitKeys.gitReadBackend val runner = ThisBuild / GitKeys.gitRunner + val operationBackend = ThisBuild / GitKeys.gitOperationBackend val gitHeadCommit = ThisBuild / GitKeys.gitHeadCommit val gitHeadMessage = ThisBuild / GitKeys.gitHeadMessage val gitHeadCommitDate = ThisBuild / GitKeys.gitHeadCommitDate @@ -344,9 +442,17 @@ object GitPlugin extends AutoPlugin { // Note: In an attempt to pretend we are binary compatible, we current add this as an after thought. // In 1.0, we should deprecate/move the other means of getting these values. object autoImport { + type GitBackend = com.github.sbt.git.GitBackend + val GitBackend = com.github.sbt.git.GitBackend val git = SbtGit.git def versionWithGit = SbtGit.versionWithGit def versionProjectWithGit = SbtGit.versionProjectWithGit + def useSystemGitOnlyForReads = SbtGit.useSystemGitOnlyForReads + def useJGitOnlyForReads = SbtGit.useJGitOnlyForReads + def useSystemGitFirstForReads = SbtGit.useSystemGitFirstForReads + def useSystemGitOnlyForOperations = SbtGit.useSystemGitOnlyForOperations + def useJGitOnlyForOperations = SbtGit.useJGitOnlyForOperations + def useSystemGitFirstForOperations = SbtGit.useSystemGitFirstForOperations def useJGit = SbtGit.useJGit def useReadableConsoleGit = SbtGit.useReadableConsoleGit def showCurrentGitBranch = SbtGit.showCurrentGitBranch diff --git a/src/main/scala/com/github/sbt/git/ReadableGit.scala b/src/main/scala/com/github/sbt/git/ReadableGit.scala index 98d36a9..41f93ad 100644 --- a/src/main/scala/com/github/sbt/git/ReadableGit.scala +++ b/src/main/scala/com/github/sbt/git/ReadableGit.scala @@ -30,15 +30,16 @@ trait GitReadonlyInterface { def headCommitMessage: Option[String] } -/** Our default readable git uses JGit instead of a process-forking and reading, for speed/safety. However, we allow - * overriding, since JGit doesn't currently work with git worktrees - * */ +/** ReadableGit implementation backed by either the configured override, such + * as system git, or JGit as the fallback implementation. + */ final class DefaultReadableGit(base: sbt.File, gitOverride: Option[GitReadonlyInterface]) extends ReadableGit { // TODO - Should we cache git, or just create on each request? // For now, let's cache. private val git = gitOverride getOrElse JGit(base) /** Use the git read-only interface. */ def withGit[A](f: GitReadonlyInterface => A): A = - // JGit has concurrency issues so we synchronize access to it. + // JGit has concurrency issues, so keep access synchronized even when the + // configured backend is not JGit. synchronized(f(git)) } diff --git a/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/init.sh b/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/init.sh new file mode 100755 index 0000000..2325ead --- /dev/null +++ b/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/init.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -eux + +# Build this scripted layout: +# +# . +# |-- main/ primary Git repository +# `-- project/ linked worktree containing the sbt build under test +# +# The directory name "project" is intentional. In sbt, `project/` is the +# meta-build of the scripted root. The scripted test runs `reload plugins` +# so subsequent commands execute from inside this linked worktree. +mkdir -p main + +cd main +git init +git config user.email "test@jsuereth.com" +git config user.name "Tester" +git commit --allow-empty --no-gpg-sign -m "Initial commit" +rm -rf ../project +git worktree add -b wt ../project +cd - + +cp -r template-build/. project/ +cd project +git add .gitignore build.sbt project/plugins.sbt +git commit --no-gpg-sign -m "Add scripted project" +echo "# dirty" >> .gitignore diff --git a/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/template-build/.gitignore b/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/template-build/.gitignore new file mode 100644 index 0000000..f2b6ab5 --- /dev/null +++ b/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/template-build/.gitignore @@ -0,0 +1,4 @@ +target +project/project +project/target +*.class diff --git a/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/template-build/build.sbt b/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/template-build/build.sbt new file mode 100644 index 0000000..13ac864 --- /dev/null +++ b/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/template-build/build.sbt @@ -0,0 +1,39 @@ +enablePlugins(GitVersioning) + +ThisBuild / com.github.sbt.git.SbtGit.GitKeys.gitReadBackend := { + if (sbtVersion.value.startsWith("2.")) com.github.sbt.git.GitBackend.JGitOnly + else com.github.sbt.git.GitBackend.SystemGitFirst +} + +val expectJGitOnlyBackend = + taskKey[Unit]("checks the forced JGit read backend in a linked worktree") + +expectJGitOnlyBackend := { + if (sbtVersion.value.startsWith("2.")) { + val expectedReadBackend = com.github.sbt.git.GitBackend.JGitOnly + val actualReadBackend = + (ThisBuild / com.github.sbt.git.SbtGit.GitKeys.gitReadBackend).value + assert( + actualReadBackend == expectedReadBackend, + s"Expected gitReadBackend=$expectedReadBackend, found $actualReadBackend" + ) + } +} + +val expectWorktreeReadableValues = + taskKey[Unit]("checks JGit readable git values in a linked worktree") + +expectWorktreeReadableValues := { + if (sbtVersion.value.startsWith("2.")) { + assert(git.gitHeadCommit.value.nonEmpty, "Expected gitHeadCommit to be defined") + assert( + git.gitHeadMessage.value.map(_.trim).contains("Add scripted project"), + s"Unexpected head message: ${git.gitHeadMessage.value}" + ) + assert( + git.gitCurrentBranch.value == "wt", + s"Unexpected current branch: ${git.gitCurrentBranch.value}" + ) + assert(git.gitUncommittedChanges.value, "Expected dirty linked worktree") + } +} diff --git a/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/template-build/project/plugins.sbt b/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/template-build/project/plugins.sbt new file mode 100644 index 0000000..5510398 --- /dev/null +++ b/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/template-build/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.github.sbt" % "sbt-git" % sys.props("project.version")) diff --git a/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/test b/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/test new file mode 100644 index 0000000..4f5ef92 --- /dev/null +++ b/src/sbt-test/git-versioning/jgit-only-sbt2-worktree/test @@ -0,0 +1,6 @@ +$ exec ./init.sh +# Switch to the scripted root's meta-build. That meta-build is the generated +# project/ directory, which init.sh made a linked Git worktree. +> reload plugins +> expectJGitOnlyBackend +> expectWorktreeReadableValues diff --git a/src/sbt-test/git-versioning/legacy-backend-helpers/build.sbt b/src/sbt-test/git-versioning/legacy-backend-helpers/build.sbt new file mode 100644 index 0000000..f9d030a --- /dev/null +++ b/src/sbt-test/git-versioning/legacy-backend-helpers/build.sbt @@ -0,0 +1,27 @@ +enablePlugins(GitVersioning) + +useJGit + +val checkUseJGit = taskKey[Unit]("checks the legacy useJGit helper") + +checkUseJGit := { + val readBackend = + (ThisBuild / com.github.sbt.git.SbtGit.GitKeys.gitReadBackend).value + val operationBackend = + (ThisBuild / com.github.sbt.git.SbtGit.GitKeys.gitOperationBackend).value + val runner = + (ThisBuild / com.github.sbt.git.SbtGit.GitKeys.gitRunner).value + + assert( + readBackend == com.github.sbt.git.GitBackend.JGitOnly, + s"Expected useJGit to set gitReadBackend=JGitOnly, found $readBackend" + ) + assert( + operationBackend == com.github.sbt.git.GitBackend.JGitOnly, + s"Expected useJGit to set gitOperationBackend=JGitOnly, found $operationBackend" + ) + assert( + runner == com.github.sbt.git.JGitRunner, + s"Expected useJGit to select JGitRunner, found $runner" + ) +} diff --git a/src/sbt-test/git-versioning/legacy-backend-helpers/project/plugins.sbt b/src/sbt-test/git-versioning/legacy-backend-helpers/project/plugins.sbt new file mode 100644 index 0000000..5510398 --- /dev/null +++ b/src/sbt-test/git-versioning/legacy-backend-helpers/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.github.sbt" % "sbt-git" % sys.props("project.version")) diff --git a/src/sbt-test/git-versioning/legacy-backend-helpers/test b/src/sbt-test/git-versioning/legacy-backend-helpers/test new file mode 100644 index 0000000..519bd27 --- /dev/null +++ b/src/sbt-test/git-versioning/legacy-backend-helpers/test @@ -0,0 +1 @@ +> checkUseJGit diff --git a/src/sbt-test/git-versioning/legacy-readable-console-helper/build.sbt b/src/sbt-test/git-versioning/legacy-readable-console-helper/build.sbt new file mode 100644 index 0000000..ae5aabe --- /dev/null +++ b/src/sbt-test/git-versioning/legacy-readable-console-helper/build.sbt @@ -0,0 +1,16 @@ +enablePlugins(GitVersioning) + +useReadableConsoleGit + +val checkUseReadableConsoleGit = + taskKey[Unit]("checks the legacy useReadableConsoleGit helper") + +checkUseReadableConsoleGit := { + val forceConsoleReads = + (ThisBuild / com.github.sbt.git.SbtGit.GitKeys.useConsoleForROGit).value + + assert( + forceConsoleReads, + "Expected useReadableConsoleGit to set the legacy console read flag" + ) +} diff --git a/src/sbt-test/git-versioning/legacy-readable-console-helper/project/plugins.sbt b/src/sbt-test/git-versioning/legacy-readable-console-helper/project/plugins.sbt new file mode 100644 index 0000000..5510398 --- /dev/null +++ b/src/sbt-test/git-versioning/legacy-readable-console-helper/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.github.sbt" % "sbt-git" % sys.props("project.version")) diff --git a/src/sbt-test/git-versioning/legacy-readable-console-helper/test b/src/sbt-test/git-versioning/legacy-readable-console-helper/test new file mode 100644 index 0000000..05e0734 --- /dev/null +++ b/src/sbt-test/git-versioning/legacy-readable-console-helper/test @@ -0,0 +1 @@ +> checkUseReadableConsoleGit diff --git a/src/sbt-test/git-versioning/system-git-fallback/build.sbt b/src/sbt-test/git-versioning/system-git-fallback/build.sbt new file mode 100644 index 0000000..aae6bd1 --- /dev/null +++ b/src/sbt-test/git-versioning/system-git-fallback/build.sbt @@ -0,0 +1,14 @@ +enablePlugins(GitVersioning) + +com.github.sbt.TestHooks.forceSystemGitUnavailable + +val expectJGitFallback = taskKey[Unit]("checks SystemGitFirst falls back to JGit when system git is unavailable") +expectJGitFallback := { + val readBackend = (ThisBuild / com.github.sbt.git.SbtGit.GitKeys.gitReadBackend).value + val operationBackend = (ThisBuild / com.github.sbt.git.SbtGit.GitKeys.gitOperationBackend).value + val runner = (ThisBuild / com.github.sbt.git.SbtGit.GitKeys.gitRunner).value + + assert(readBackend == com.github.sbt.git.GitBackend.SystemGitFirst, s"Unexpected read backend: $readBackend") + assert(operationBackend == com.github.sbt.git.GitBackend.SystemGitFirst, s"Unexpected operation backend: $operationBackend") + assert(runner == com.github.sbt.git.JGitRunner, s"Expected JGitRunner fallback, found $runner") +} diff --git a/src/sbt-test/git-versioning/system-git-fallback/init.sh b/src/sbt-test/git-versioning/system-git-fallback/init.sh new file mode 100755 index 0000000..e205c3d --- /dev/null +++ b/src/sbt-test/git-versioning/system-git-fallback/init.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -eux + +git init +git config user.email "test@jsuereth.com" +git config user.name "Tester" +git add build.sbt project/plugins.sbt +git commit --no-gpg-sign -m "Add scripted project" diff --git a/src/sbt-test/git-versioning/system-git-fallback/project/TestHooks.scala b/src/sbt-test/git-versioning/system-git-fallback/project/TestHooks.scala new file mode 100644 index 0000000..4e6a80c --- /dev/null +++ b/src/sbt-test/git-versioning/system-git-fallback/project/TestHooks.scala @@ -0,0 +1,8 @@ +package com.github.sbt + +import com.github.sbt.git.SbtGit + +object TestHooks { + val forceSystemGitUnavailable = + _root_.sbt.ThisBuild / SbtGit.GitKeys.systemGitAvailableOverride := Some(false) +} diff --git a/src/sbt-test/git-versioning/system-git-fallback/project/plugins.sbt b/src/sbt-test/git-versioning/system-git-fallback/project/plugins.sbt new file mode 100644 index 0000000..5510398 --- /dev/null +++ b/src/sbt-test/git-versioning/system-git-fallback/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.github.sbt" % "sbt-git" % sys.props("project.version")) diff --git a/src/sbt-test/git-versioning/system-git-fallback/test b/src/sbt-test/git-versioning/system-git-fallback/test new file mode 100644 index 0000000..4c3fd30 --- /dev/null +++ b/src/sbt-test/git-versioning/system-git-fallback/test @@ -0,0 +1,2 @@ +$ exec ./init.sh +> expectJGitFallback diff --git a/src/sbt-test/git-versioning/system-git-worktree/init.sh b/src/sbt-test/git-versioning/system-git-worktree/init.sh new file mode 100755 index 0000000..2325ead --- /dev/null +++ b/src/sbt-test/git-versioning/system-git-worktree/init.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -eux + +# Build this scripted layout: +# +# . +# |-- main/ primary Git repository +# `-- project/ linked worktree containing the sbt build under test +# +# The directory name "project" is intentional. In sbt, `project/` is the +# meta-build of the scripted root. The scripted test runs `reload plugins` +# so subsequent commands execute from inside this linked worktree. +mkdir -p main + +cd main +git init +git config user.email "test@jsuereth.com" +git config user.name "Tester" +git commit --allow-empty --no-gpg-sign -m "Initial commit" +rm -rf ../project +git worktree add -b wt ../project +cd - + +cp -r template-build/. project/ +cd project +git add .gitignore build.sbt project/plugins.sbt +git commit --no-gpg-sign -m "Add scripted project" +echo "# dirty" >> .gitignore diff --git a/src/sbt-test/git-versioning/system-git-worktree/template-build/.gitignore b/src/sbt-test/git-versioning/system-git-worktree/template-build/.gitignore new file mode 100644 index 0000000..f2b6ab5 --- /dev/null +++ b/src/sbt-test/git-versioning/system-git-worktree/template-build/.gitignore @@ -0,0 +1,4 @@ +target +project/project +project/target +*.class diff --git a/src/sbt-test/git-versioning/system-git-worktree/template-build/build.sbt b/src/sbt-test/git-versioning/system-git-worktree/template-build/build.sbt new file mode 100644 index 0000000..fc24c9d --- /dev/null +++ b/src/sbt-test/git-versioning/system-git-worktree/template-build/build.sbt @@ -0,0 +1,30 @@ +enablePlugins(GitVersioning) + +val expectSystemGitFirstBackend = + taskKey[Unit]("checks the default read backend in a linked worktree") + +expectSystemGitFirstBackend := { + val expectedReadBackend = com.github.sbt.git.GitBackend.SystemGitFirst + val actualReadBackend = + (ThisBuild / com.github.sbt.git.SbtGit.GitKeys.gitReadBackend).value + assert( + actualReadBackend == expectedReadBackend, + s"Expected gitReadBackend=$expectedReadBackend, found $actualReadBackend" + ) +} + +val expectWorktreeReadableValues = + taskKey[Unit]("checks readable git values in a linked worktree") + +expectWorktreeReadableValues := { + assert(git.gitHeadCommit.value.nonEmpty, "Expected gitHeadCommit to be defined") + assert( + git.gitHeadMessage.value.map(_.trim).contains("Add scripted project"), + s"Unexpected head message: ${git.gitHeadMessage.value}" + ) + assert( + git.gitCurrentBranch.value == "wt", + s"Unexpected current branch: ${git.gitCurrentBranch.value}" + ) + assert(git.gitUncommittedChanges.value, "Expected dirty linked worktree") +} diff --git a/src/sbt-test/git-versioning/system-git-worktree/template-build/project/plugins.sbt b/src/sbt-test/git-versioning/system-git-worktree/template-build/project/plugins.sbt new file mode 100644 index 0000000..5510398 --- /dev/null +++ b/src/sbt-test/git-versioning/system-git-worktree/template-build/project/plugins.sbt @@ -0,0 +1 @@ +addSbtPlugin("com.github.sbt" % "sbt-git" % sys.props("project.version")) diff --git a/src/sbt-test/git-versioning/system-git-worktree/test b/src/sbt-test/git-versioning/system-git-worktree/test new file mode 100644 index 0000000..1dbec11 --- /dev/null +++ b/src/sbt-test/git-versioning/system-git-worktree/test @@ -0,0 +1,6 @@ +$ exec ./init.sh +# Switch to the scripted root's meta-build. That meta-build is the generated +# project/ directory, which init.sh made a linked Git worktree. +> reload plugins +> expectSystemGitFirstBackend +> expectWorktreeReadableValues diff --git a/src/test/scala/com/github/sbt/git/SbtGitSuite.scala b/src/test/scala/com/github/sbt/git/SbtGitSuite.scala index 2cc0f59..8a9b634 100644 --- a/src/test/scala/com/github/sbt/git/SbtGitSuite.scala +++ b/src/test/scala/com/github/sbt/git/SbtGitSuite.scala @@ -1,8 +1,12 @@ package com.github.sbt.git import sbt.ScmInfo +import sbt.IO +import sbt.io.syntax.* import sbt.url +import scala.sys.process.Process + class SbtGitSuite extends munit.FunSuite { val expectedScmInfo = Some( ScmInfo( @@ -24,4 +28,128 @@ class SbtGitSuite extends munit.FunSuite { test("a https URL without the .git postfix") { assertEquals(SbtGit.parseScmInfo("https://github.com/akka/akka"), expectedScmInfo) } + + test("SystemGitFirst uses system git only when it is available") { + assert(SbtGit.useSystemGit(GitBackend.SystemGitFirst, systemGitAvailable = true)) + assertEquals(SbtGit.useSystemGit(GitBackend.SystemGitFirst, systemGitAvailable = false), false) + } + + test("SystemGitOnly always selects system git") { + assert(SbtGit.useSystemGit(GitBackend.SystemGitOnly, systemGitAvailable = true)) + assert(SbtGit.useSystemGit(GitBackend.SystemGitOnly, systemGitAvailable = false)) + } + + test("JGitOnly never selects system git") { + assertEquals(SbtGit.useSystemGit(GitBackend.JGitOnly, systemGitAvailable = true), false) + assertEquals(SbtGit.useSystemGit(GitBackend.JGitOnly, systemGitAvailable = false), false) + } + + test("legacy read-only console flag forces system git reads") { + assert(SbtGit.useSystemGitForReads(forceSystemGit = true, GitBackend.JGitOnly, systemGitAvailable = false)) + } + + test("system git availability override takes precedence over detection") { + assert(SbtGit.detectedSystemGitAvailable(Some(true), sbt.file("."))) + assertEquals(SbtGit.detectedSystemGitAvailable(Some(false), sbt.file(".")), false) + } + + test("system git readability probe handles non-git directories") { + IO.withTemporaryDirectory { dir => + assertEquals(SbtGit.isSystemGitReadable(dir), false) + } + } + + test("operation backend selects the expected runner") { + assertEquals(SbtGit.selectGitRunner(GitBackend.SystemGitFirst, systemGitAvailable = true), ConsoleGitRunner) + assertEquals(SbtGit.selectGitRunner(GitBackend.SystemGitFirst, systemGitAvailable = false), JGitRunner) + assertEquals(SbtGit.selectGitRunner(GitBackend.SystemGitOnly, systemGitAvailable = false), ConsoleGitRunner) + assertEquals(SbtGit.selectGitRunner(GitBackend.JGitOnly, systemGitAvailable = true), JGitRunner) + } + + test("console reader handles a repository without commits") { + IO.withTemporaryDirectory { dir => + runGit(dir, "init") + + val reader = new ConsoleGitReadableOnly(ConsoleGitRunner, dir, sbt.util.Logger.Null) + assert(reader.branch.nonEmpty) + assertEquals(reader.headCommitSha, None) + assertEquals(reader.currentTags, Seq.empty) + assertEquals(reader.headCommitMessage, None) + } + } + + test("console reader dirty check ignores untracked files") { + IO.withTemporaryDirectory { dir => + runGit(dir, "init") + runGit(dir, "config", "user.email", "test@example.com") + runGit(dir, "config", "user.name", "Tester") + IO.write(dir / "README.md", "clean\n") + runGit(dir, "add", "README.md") + runGit(dir, "commit", "--no-gpg-sign", "-m", "Initial commit") + + val reader = new ConsoleGitReadableOnly(ConsoleGitRunner, dir, sbt.util.Logger.Null) + IO.write(dir / "build.sbt", "untracked\n") + assertEquals(reader.hasUncommittedChanges, false) + + IO.write(dir / "README.md", "dirty\n") + assert(reader.hasUncommittedChanges) + } + } + + test("console reader returns undecorated branch names") { + IO.withTemporaryDirectory { dir => + initCommittedRepo(dir) + runGit(dir, "branch", "feature") + runGit(dir, "update-ref", "refs/remotes/origin/main", "HEAD") + + val reader = new ConsoleGitReadableOnly(ConsoleGitRunner, dir, sbt.util.Logger.Null) + assert(reader.branches.contains("feature")) + assert(!reader.branches.contains("*")) + assert(reader.remoteBranches.contains("origin/main")) + assert(!reader.remoteBranches.contains("*")) + } + } + + test("console reader applies all describe match patterns") { + IO.withTemporaryDirectory { dir => + initCommittedRepo(dir) + runGit(dir, "tag", "release-1.0") + + val reader = new ConsoleGitReadableOnly(ConsoleGitRunner, dir, sbt.util.Logger.Null) + assertEquals(reader.describedVersion(Seq("ignored-*", "release-*")), Some("release-1.0")) + } + } + + test("console reader commit date is not shell quoted") { + IO.withTemporaryDirectory { dir => + initCommittedRepo(dir) + + val reader = new ConsoleGitReadableOnly(ConsoleGitRunner, dir, sbt.util.Logger.Null) + val date = reader.headCommitDate.getOrElse(fail("expected a commit date")) + assert(date.matches("""\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{4}""")) + } + } + + test("console reader falls back to origin when no origin remote is configured") { + IO.withTemporaryDirectory { dir => + initCommittedRepo(dir) + + val reader = new ConsoleGitReadableOnly(ConsoleGitRunner, dir, sbt.util.Logger.Null) + assertEquals(reader.remoteOrigin, "origin") + } + } + + private def initCommittedRepo(dir: sbt.File): Unit = { + runGit(dir, "init") + runGit(dir, "config", "user.email", "test@example.com") + runGit(dir, "config", "user.name", "Tester") + IO.write(dir / "README.md", "initial\n") + runGit(dir, "add", "README.md") + runGit(dir, "commit", "--no-gpg-sign", "-m", "Initial commit") + } + + private def runGit(dir: sbt.File, args: String*): Unit = { + val exitCode = Process("git" +: args, dir).! + assertEquals(exitCode, 0) + } }