Skip to content
Open
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
45 changes: 29 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
39 changes: 27 additions & 12 deletions src/main/scala/com/github/sbt/git/ConsoleGitReadableOnly.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think swapping the default to symbolic-ref first might return literal HEAD where previously the SHA of the detached branch's HEAD would have been returned

.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)
}
6 changes: 6 additions & 0 deletions src/main/scala/com/github/sbt/git/ConsoleGitRunner.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions src/main/scala/com/github/sbt/git/GitBackend.scala
Original file line number Diff line number Diff line change
@@ -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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might fall into "who want to force either backend" naïvely, but my gut feel is that:

  1. We should either use system git (aka console git) or JGit, no halfway
  2. Current regime uses GitRunner's class inheritance (?) to denote the different backend, so having this sealed trait feels duplicated. Especially we if go with system vs JGit dichotomy all we need is Boolean?

@mkurz mkurz Jul 6, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. We should either use system git (aka console git) or JGit, no halfway

I see the concern, but I would like to keep the meaning of SystemGitFirst because it preserves the low-friction setup story: for most users sbt-git will use the installed git command, but a build can still load in environments where only Java/sbt are available but no git command.

For read operations, sbt-git currently defaults to JGit, and that generally works fine, especially for sbt-dynver sbt-ci-release, which is probably how many users encounter sbt-git in the first place. The main reason to prefer system git now is that JGit 5 does not support linked worktrees, while JGit 7 does but requires Java 17. Since the sbt 1 artifact still supports Java 8, system git is the only backend that can make worktrees work out of the box there.

The fallback to JGit keeps existing non-worktree environments working when the git executable is not available. The known remaining edge case is: sbt 1, linked worktree, no system git, and no manual JGit 7 override.

That matters for projects like Play Framework where the contributor story is ideally "install Java and sbt and start hacking." Most developers will have system git, but some environments may not: minimal containers, restricted CI images, machines where Git is accessed through an IDE/UI integration, or setups where git is installed outside PATH.

  1. Current regime uses GitRunner's class inheritance (?) to denote the different backend, so having this sealed trait feels duplicated. Especially we if go with system vs JGit dichotomy all we need is Boolean?

I agree that we should avoid unnecessary backend complexity. I think there are two possible directions:

First, if we want to give users explicit control, I do not think this is just a Boolean. There are three useful user intents:

  • SystemGitFirst: prefer system git, fall back to JGit
  • SystemGitOnly: require system git and fail if unavailable
  • JGitOnly: never shell out

The first one is the default for developer experience. The Only modes are for users or CI setups that want deterministic behavior.

Second, we could decide not to expose backend choice at all and just make SystemGitFirst the fixed behavior.

My preference is still the first option. Even if we removed the explicit JGitOnly / SystemGitOnly cases, we would still have the same concepts because the legacy helpers already express them: useJGit means "use JGit, do not shell out", and useReadableConsoleGit means "use system git for reads." Keeping the enum cases makes those existing intents explicit instead of encoding them as special cases elsewhere.


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
}
126 changes: 116 additions & 10 deletions src/main/scala/com/github/sbt/git/GitPlugin.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
Expand All @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the readme:

restricted environments "can force JGit so the plugin never shells out"

But when discerning readable here I believe it'll still shell out regardless of the JGitOnly flag

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),
Expand All @@ -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 = """([^\/]+)"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 5 additions & 4 deletions src/main/scala/com/github/sbt/git/ReadableGit.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Loading