[2.x] feat: updating remote vcs projects

Closes #1284
This commit is contained in:
seroperson 2026-03-07 16:45:27 +03:00
parent ab503ca51e
commit c9fe1635f4
No known key found for this signature in database
GPG Key ID: 0B3C08C5FD554A20
38 changed files with 763 additions and 70 deletions

View File

@ -42,6 +42,12 @@ object BuildPaths:
val globalZincDirectory =
AttributeKey[File]("global-zinc-directory", "The base directory for Zinc internals.", DSetting)
val repositoryUpdateCompleted = AttributeKey[Boolean](
"repository-update-completed",
"Indicates that remote project repositories have been updated.",
DSetting
)
import sbt.io.syntax.*
def getGlobalBase(state: State): File = {

View File

@ -123,7 +123,7 @@ import xsbti.compile.{
TransactionalManagerType
}
object Defaults extends BuildCommon {
object Defaults extends BuildCommon with DefExtra {
final val CacheDirectoryName = "cache"
def configSrcSub(key: SettingKey[File]): Initialize[File] =
@ -324,6 +324,7 @@ object Defaults extends BuildCommon {
else TaskCancellationStrategy.Null
},
envVars :== Map.empty,
repositoryUpdateStrategy :== RepositoryUpdateStrategy.Manual,
sbtVersion := appConfiguration.value.provider.id.version,
sbtBinaryVersion := binarySbtVersion(sbtVersion.value),
pluginCrossBuild / sbtVersion := sbtVersion.value,
@ -2511,7 +2512,23 @@ object Defaults extends BuildCommon {
else ClassLoaderLayeringStrategy.ScalaLibrary
},
publishLocal / skip := (publish / skip).value,
publishM2 / skip := (publish / skip).value
publishM2 / skip := (publish / skip).value,
fetchSource := Def.uncached {
val uri = thisProjectRef.value.build
val log = streams.value.log
RetrieveUnit(uri) match {
case None => ()
case Some(vcs) =>
val strategy = repositoryUpdateStrategy.value
val lb = Project.extract(state.value).get(Keys.loadedBuild)
val vcsRoot = lb.units.get(uri).map(_.localBase).getOrElse(baseDirectory.value)
if (Resolvers.shouldUpdate(vcsRoot, strategy)) {
log.info(s"Updating remote project: $vcsRoot ...")
if (Resolvers.updateRepository(vcsRoot, vcs, log))
log.warn("Remote dependencies updated. Run `reload` to pick up changes.")
}
}
},
)
// build.sbt is treated a Scala source of metabuild, so to enable deprecation flag on build.sbt we set the option here.
lazy val deprecationSettings: Seq[Setting[?]] =
@ -3317,12 +3334,19 @@ object Classpaths {
dependencyPositions.value
)
),
maybeUpdateRemoteProjects := Def.uncached(Def.taskDyn {
val buildDeps = buildDependencies.value
val thisRef = thisProjectRef.value
val lb = Project.extract(state.value).get(Keys.loadedBuild)
val vcsRootRefs = Resolvers.transitiveVcsRootRefs(thisRef, buildDeps, lb)
if (vcsRootRefs.isEmpty) Def.task(())
else Def.sequential(vcsRootRefs.map(_ / fetchSource).toSeq)
}.value),
updateFull := Def.uncached(updateTask.value),
update := Def.uncached(updateWithoutDetails("update").value),
update := Def.uncached {
val report = update.value
val log = streams.value.log
ConflictWarning(conflictWarning.value, report, log)
val _ = maybeUpdateRemoteProjects.value
val report = updateWithoutDetails("update").value
ConflictWarning(conflictWarning.value, report, streams.value.log)
report
},
update / evictionWarningOptions := evictionWarningOptions.value,

View File

@ -537,6 +537,10 @@ object Keys {
val ivySbt = taskKey[Any]("Provides the sbt interface to Ivy.").withRank(CTask)
val ivyModule = taskKey[Any]("Provides the sbt interface to a configured Ivy module.").withRank(CTask)
val updateCacheName = taskKey[String]("Defines the directory name used to store the update cache files (inside the streams cacheDirectory).").withRank(DTask)
val repositoryUpdateStrategy = settingKey[RepositoryUpdateStrategy]("Strategy for updating remote VCS project dependencies.").withRank(CSetting)
val fetchSource = taskKey[Unit]("Fetches or updates the VCS source for this project if it is backed by a remote VCS repository.").withRank(DTask)
val updateRemoteProjects = taskKey[Unit]("Force-updates all remote VCS project dependencies, ignoring strategy.").withRank(BTask)
val maybeUpdateRemoteProjects = taskKey[Unit]("Updates remote VCS project dependencies according to repositoryUpdateStrategy.").withRank(DTask)
val update = taskKey[UpdateReport]("Resolves and optionally retrieves dependencies, producing a report.").withRank(ATask)
val updateFull = taskKey[UpdateReport]("Resolves and optionally retrieves dependencies, producing a full report with callers.").withRank(CTask)
val evicted = taskKey[EvictionWarning]("Display detailed eviction warnings.").withRank(CTask)

View File

@ -387,6 +387,7 @@ object BuiltinCommands {
act,
continuous,
clearCaches,
updateRemoteProjectsCommand,
Clean.cleanFull,
NetworkChannel.disconnect,
waitCmd,
@ -1045,6 +1046,35 @@ object BuiltinCommands {
def registerCompilerCache(s: State): State = Clean.registerCompilerCache(s)
def clearCaches: Command = Clean.clearCaches
def updateRemoteProjectsCommand: Command =
Command.command(
"updateRemoteProjects",
Help.more(
"updateRemoteProjects",
"Force-updates all remote VCS project dependencies."
)
) { s =>
if (s.get(BuildPaths.repositoryUpdateCompleted).getOrElse(false)) {
s.remove(BuildPaths.repositoryUpdateCompleted)
} else {
val log = s.log
val extracted = Project.extract(s)
val isUpdated = Resolvers.updateLoadedBuild(
lb = extracted.get(Keys.loadedBuild),
log = log,
extracted = extracted,
force = true
)
if (isUpdated) {
log.warn("Remote dependencies updated. Reloading...")
"reload" :: s.put(BuildPaths.repositoryUpdateCompleted, true)
} else {
s
}
}
}
private[sbt] def waitCmd: Command =
Command.arb(_ =>
ContinuousCommands.waitWatch.examples() ~> " ".examples() ~> matched(any.*).examples()

View File

@ -0,0 +1,35 @@
/*
* sbt
* Copyright 2023, Scala center
* Copyright 2011 - 2022, Lightbend, Inc.
* Copyright 2008 - 2010, Mark Harrah
* Licensed under Apache License 2.0 (see LICENSE)
*/
package sbt
import scala.concurrent.duration.FiniteDuration
/**
* Strategy for updating remote VCS project dependencies.
*
* Used with the `repositoryUpdateStrategy` setting key to control when
* remote project dependencies are updated from their upstream repositories.
*/
sealed trait RepositoryUpdateStrategy
object RepositoryUpdateStrategy {
/**
* Never update automatically.
* Users must manually delete staging directories or run `updateRemoteProjects`.
*/
case object Manual extends RepositoryUpdateStrategy
/** Update on every `update` task invocation. */
case object Always extends RepositoryUpdateStrategy
/** Update at most once per the given interval. */
final case class Every(interval: FiniteDuration) extends RepositoryUpdateStrategy
}

View File

@ -19,11 +19,24 @@ import BuildLoader.ResolveInfo
import RichURI.fromURI
import java.util.Locale
import scala.sys.process.Process
import scala.sys.process.{ BasicIO, Process }
import scala.util.control.NonFatal
import sbt.internal.util.Util
import sbt.internal.{ BuildDependencies, LoadedBuild }
import sbt.util.Logger
import sbt.internal.RetrieveUnit
object Resolvers {
private[sbt] sealed trait RemoteVcs
private[sbt] object RemoteVcs {
case class Git(uri: URI) extends RemoteVcs
case class Hg(uri: URI) extends RemoteVcs
case class Svn(uri: URI) extends RemoteVcs
}
private val LastUpdatedFileName = ".sbt-last-updated"
type Resolver = BuildLoader.Resolver
val local: Resolver = (info: ResolveInfo) => {
@ -47,10 +60,8 @@ object Resolvers {
}
val subversion: Resolver = (info: ResolveInfo) => {
def normalized(uri: URI) = uri.copy(scheme = "svn")
val uri = info.uri.withoutMarkerScheme
val localCopy = uniqueSubdirectoryFor(normalized(uri), in = info.staging)
val localCopy = uniqueSubdirectoryFor(uri.copy(scheme = "svn"), in = info.staging)
val from = uri.withoutFragment.toASCIIString
val to = localCopy.getAbsolutePath
@ -58,28 +69,37 @@ object Resolvers {
val revision = uri.getFragment
Some { () =>
creates(localCopy) {
run("svn", "checkout", "-q", "-r", revision, from, to)
run(cwd = None, log = None, "svn", "checkout", "-q", "-r", revision, from, to)
}
}
} else
Some { () =>
creates(localCopy) {
run("svn", "checkout", "-q", from, to)
run(cwd = None, log = None, "svn", "checkout", "-q", from, to)
}
}
}
val mercurial: Resolver = new DistributedVCS {
override val scheme = "hg"
val mercurial: Resolver = (info: ResolveInfo) => {
val uri = info.uri.withoutMarkerScheme
val localCopy = uniqueSubdirectoryFor(uri.copy(scheme = "hg"), in = info.staging)
val from = uri.withoutFragment.toASCIIString
override def clone(from: String, to: File): Unit = {
run("hg", "clone", "-q", from, to.getAbsolutePath)
}
override def checkout(branch: String, in: File): Unit = {
run(Some(in), "hg", "checkout", "-q", branch)
}
}.toResolver
if (uri.hasFragment) {
val branch = uri.getFragment
Some { () =>
creates(localCopy) {
run(cwd = None, log = None, "hg", "clone", "-q", from, localCopy.getAbsolutePath)
run(Some(localCopy), log = None, "hg", "checkout", "-q", branch)
}
}
} else
Some { () =>
creates(localCopy) {
run(cwd = None, log = None, "hg", "clone", "-q", from, localCopy.getAbsolutePath)
}
}
}
val git: Resolver = (info: ResolveInfo) => {
val uri = info.uri.withoutMarkerScheme
@ -90,61 +110,46 @@ object Resolvers {
val branch = uri.getFragment
Some { () =>
creates(localCopy) {
run("git", "clone", from, localCopy.getAbsolutePath)
run(Some(localCopy), "git", "checkout", "-q", branch)
run(cwd = None, log = None, "git", "clone", from, localCopy.getAbsolutePath)
run(Some(localCopy), log = None, "git", "checkout", "-q", branch)
}
}
} else
Some { () =>
creates(localCopy) {
run("git", "clone", "--depth", "1", from, localCopy.getAbsolutePath)
run(
cwd = None,
log = None,
"git",
"clone",
"--depth",
"1",
from,
localCopy.getAbsolutePath
)
}
}
}
abstract class DistributedVCS {
val scheme: String
def clone(from: String, to: File): Unit
def checkout(branch: String, in: File): Unit
def toResolver: Resolver = (info: ResolveInfo) => {
val uri = info.uri.withoutMarkerScheme
val localCopy = uniqueSubdirectoryFor(normalized(uri), in = info.staging)
val from = uri.withoutFragment.toASCIIString
if (uri.hasFragment) {
val branch = uri.getFragment
Some { () =>
creates(localCopy) {
clone(from, to = localCopy)
checkout(branch, in = localCopy)
}
}
} else
Some { () =>
creates(localCopy) { clone(from, to = localCopy) }
}
}
private def normalized(uri: URI) = uri.copy(scheme = scheme)
}
def run(command: String*): Unit =
run(None, command*)
def run(cwd: Option[File], command: String*): Unit = {
val result = Process(
private def run(cwd: Option[File], log: Option[Logger], command: String*): Unit = {
val process = Process(
if (Util.isNonCygwinWindows) "cmd" +: "/c" +: command
else command,
cwd
).!
)
val result = (log match {
case Some(log) =>
val io = BasicIO(false, log).withInput(_.close())
process.run(io).exitValue()
case None =>
process.run().exitValue()
})
if (result != 0)
sys.error("Nonzero exit code (" + result + "): " + command.mkString(" "))
}
def creates(file: File)(f: => Unit) = {
private def creates(file: File)(f: => Unit): File = {
if (!file.exists)
try {
f
@ -156,7 +161,7 @@ object Resolvers {
file
}
def uniqueSubdirectoryFor(uri: URI, in: File) = {
private def uniqueSubdirectoryFor(uri: URI, in: File): File = {
in.mkdirs()
val base = new File(in, Hash.halfHashString(uri.normalize.toASCIIString))
val last = shortName(uri) match {
@ -175,4 +180,162 @@ object Resolvers {
private def dropExtensions(name: String): String = name.takeWhile(_ != '.')
private def markUpdated(dir: File): Unit = {
val marker = new File(dir, LastUpdatedFileName)
IO.write(marker, System.currentTimeMillis().toString)
}
private[sbt] def shouldUpdate(dir: File, strategy: RepositoryUpdateStrategy): Boolean =
strategy match {
case RepositoryUpdateStrategy.Manual => false
case RepositoryUpdateStrategy.Always => true
case RepositoryUpdateStrategy.Every(interval) =>
val marker = new File(dir, LastUpdatedFileName)
if (!marker.exists()) true
else {
try {
val lastUpdated = IO.read(marker).trim.toLong
val elapsed = System.currentTimeMillis() - lastUpdated
elapsed >= interval.toMillis
} catch {
case NonFatal(_) => true
}
}
}
private[sbt] def updateLoadedBuild(
lb: LoadedBuild,
log: Logger,
extracted: Extracted,
force: Boolean
): Boolean = {
log.info("Updating remote repos")
val repos = (for {
(uri, unit) <- lb.units if unit.localBase.exists()
vcs <- RetrieveUnit(uri) if (force || {
val rootProjectId = unit.rootProjects.headOption.getOrElse("root")
val projectRef = ProjectRef(uri, rootProjectId)
val strategy = extracted
.getOpt(projectRef / Keys.repositoryUpdateStrategy)
.getOrElse(RepositoryUpdateStrategy.Manual)
shouldUpdate(unit.localBase, strategy)
})
} yield (unit.localBase, vcs))
val isUpdated = repos.foldLeft(false) { case (acc, (repo, vcs)) =>
log.info(s"Updating remote project: $repo ...")
acc || Resolvers.updateRepository(repo, vcs, log)
}
isUpdated
}
private[sbt] def transitiveVcsRootRefs(
startRef: ProjectRef,
buildDependencies: BuildDependencies,
lb: LoadedBuild
): Set[ProjectRef] =
for {
uri <- collectTransitiveRemoteBuildURIs(startRef, buildDependencies)
if RetrieveUnit(uri).isDefined
unit <- lb.units.get(uri)
rootId = unit.rootProjects.headOption.getOrElse("root")
} yield ProjectRef(uri, rootId)
private def collectTransitiveRemoteBuildURIs(
startRef: ProjectRef,
buildDeps: BuildDependencies
): Set[URI] = {
@annotation.tailrec
def go(
queue: List[ProjectRef],
visited: Set[ProjectRef],
result: Set[URI]
): Set[URI] =
queue match {
case Nil => result
case ref :: rest =>
if (visited.contains(ref)) go(rest, visited, result)
else {
val classpathDeps = buildDeps.classpath.getOrElse(ref, Nil).map(_.project)
val aggregateDeps = buildDeps.aggregate.getOrElse(ref, Nil)
val allDeps = classpathDeps ++ aggregateDeps
val newResult = allDeps.foldLeft(result) { (acc, dep) =>
if (dep.build != startRef.build) acc + dep.build else acc
}
go(allDeps.toList ::: rest, visited + ref, newResult)
}
}
go(List(startRef), Set.empty, Set.empty)
}
private[sbt] def updateRepository(localCopy: File, uri: URI, log: Logger): Boolean =
RetrieveUnit(uri).exists(updateRepository(localCopy, _, log))
private[sbt] def updateRepository(localCopy: File, vcs: RemoteVcs, log: Logger): Boolean =
vcs match {
case RemoteVcs.Git(uri) => updateGit(localCopy, uri, log)
case RemoteVcs.Hg(uri) => updateMercurial(localCopy, uri, log)
case RemoteVcs.Svn(uri) => updateSubversion(localCopy, uri, log)
}
private def updateGit(localCopy: File, uri: URI, log: Logger): Boolean =
try {
val headBefore = captureOutput(Some(localCopy), "git", "rev-parse", "HEAD")
val ref = if (fromURI(uri).hasFragment) uri.getFragment else "HEAD"
run(Some(localCopy), Some(log), "git", "fetch", "origin", ref)
run(Some(localCopy), Some(log), "git", "reset", "--hard", "FETCH_HEAD")
markUpdated(localCopy)
captureOutput(Some(localCopy), "git", "rev-parse", "HEAD") != headBefore
} catch {
case NonFatal(e) =>
log.error(
s"Failed to update git repository at $localCopy: ${e.getMessage}"
)
throw e
}
private def captureOutput(cwd: Option[File], command: String*): String =
Process(
if (Util.isNonCygwinWindows) "cmd" +: "/c" +: command else command,
cwd
).!!.trim
private def updateMercurial(localCopy: File, uri: URI, log: Logger): Boolean =
try {
if (fromURI(uri).hasFragment) {
val branch = uri.getFragment
run(Some(localCopy), Some(log), "hg", "pull")
run(Some(localCopy), Some(log), "hg", "update", branch)
} else {
run(Some(localCopy), Some(log), "hg", "pull", "-u")
}
markUpdated(localCopy)
true
} catch {
case NonFatal(e) =>
log.error(
s"Failed to update mercurial repository at $localCopy: ${e.getMessage}"
)
throw e
}
private def updateSubversion(localCopy: File, uri: URI, log: Logger): Boolean =
try {
if (fromURI(uri).hasFragment) {
val revision = uri.getFragment
run(Some(localCopy), Some(log), "svn", "update", "-q", "-r", revision)
} else {
run(Some(localCopy), Some(log), "svn", "update", "-q")
}
markUpdated(localCopy)
true
} catch {
case NonFatal(e) =>
log.error(
s"Failed to update subversion working copy at $localCopy: ${e.getMessage}"
)
throw e
}
}

View File

@ -12,17 +12,30 @@ package internal
import java.io.File
import java.net.URI
import sbt.internal.BuildLoader.ResolveInfo
import sbt.Resolvers.RemoteVcs
object RetrieveUnit {
def apply(info: ResolveInfo): Option[() => File] = {
info.uri match {
case Scheme("svn") | Scheme("svn+ssh") => Resolvers.subversion(info)
case Scheme("hg") => Resolvers.mercurial(info)
case Scheme("git") => Resolvers.git(info)
case Path(path) if path.endsWith(".git") => Resolvers.git(info)
case Scheme("http") | Scheme("https") | Scheme("ftp") => Resolvers.remote(info)
case Scheme("file") => Resolvers.local(info)
case _ => None
apply(info.uri) match {
case Some(RemoteVcs.Svn(_)) => Resolvers.subversion(info)
case Some(RemoteVcs.Hg(_)) => Resolvers.mercurial(info)
case Some(RemoteVcs.Git(_)) => Resolvers.git(info)
case _ =>
info.uri match {
case Scheme("http") | Scheme("https") | Scheme("ftp") => Resolvers.remote(info)
case Scheme("file") => Resolvers.local(info)
case _ => None
}
}
}
def apply(uri: URI): Option[RemoteVcs] = {
uri match {
case Scheme("svn") | Scheme("svn+ssh") => Some(RemoteVcs.Svn(uri))
case Scheme("hg") => Some(RemoteVcs.Hg(uri))
case Scheme("git") => Some(RemoteVcs.Git(uri))
case Path(path) if path.endsWith(".git") => Some(RemoteVcs.Git(uri))
case _ => None
}
}

View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
cd upstream-repo
git config commit.gpgsign false
cat > src/main/scala/upstream/NewService.scala << 'EOF'
package upstream
object NewService {
def provide: String = "new-service"
}
EOF
git add .
git commit -m "add NewService"

View File

@ -0,0 +1 @@
lazy val root = project in file(".")

View File

@ -0,0 +1,13 @@
object Main {
def main(args: Array[String]): Unit = {
val cls = Class.forName("upstream.Service")
val value = cls.getMethod("provide").invoke(null)
assert(value == "service", s"Unexpected Service.provide: '$value'")
println(s"Service loaded: $value")
val newCls = Class.forName("upstream.NewService")
val newValue = newCls.getMethod("provide").invoke(null)
assert(newValue == "new-service", s"Unexpected NewService.provide: '$newValue'")
println(s"NewService loaded: $newValue")
}
}

View File

@ -0,0 +1,8 @@
lazy val a = RootProject(uri(s"git:file://${System.getProperty("user.dir")}/upstream-repo"))
lazy val b = (project in file("b")).dependsOn(a)
lazy val c = (project in file("c")).dependsOn(a)
lazy val d = (project in file("d")).dependsOn(b, c)
lazy val root = (project in file(".")).aggregate(b, c, d).settings(
a / repositoryUpdateStrategy := RepositoryUpdateStrategy.Always
)

View File

@ -0,0 +1,8 @@
object Main {
def main(args: Array[String]): Unit = {
val cls = Class.forName("upstream.Service")
val value = cls.getMethod("provide").invoke(null)
assert(value == "service", s"Unexpected Service.provide: '$value'")
println(s"Service loaded: $value")
}
}

View File

@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -e
mkdir -p upstream-repo/src/main/scala/upstream
cd upstream-repo
git init
git config user.email "test@test.com"
git config user.name "Test"
git config commit.gpgsign false
cat > build.sbt << 'EOF'
name := "upstream-dep"
organization := "upstream"
version := "0.1.0"
EOF
cat > src/main/scala/upstream/Service.scala << 'EOF'
package upstream
object Service {
def provide: String = "service"
}
EOF
git add .
git commit -m "initial commit"

View File

@ -0,0 +1,14 @@
# Checks that `d` project:
# - Receives changes from a transitive dependency
# - Calls `git pull` on a indirectly depended VCS project
$ exec bash setup.sh
$ copy-file changes/build-with-deps.sbt build.sbt
> reload
> d/run
$ exec bash add-newservice.sh
$ copy-file changes/MainWithNew.scala d/src/main/scala/Main.scala
> d/update
> reload
> d/run

View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
cd upstream-repo
git config commit.gpgsign false
cat > src/main/scala/upstream/BranchOnly.scala << 'EOF'
package upstream
object BranchOnly {
def value: String = "branch-only"
}
EOF
git add .
git commit -m "add BranchOnly on feature branch"

View File

@ -0,0 +1 @@
lazy val root = project in file(".")

View File

@ -0,0 +1,13 @@
object Main {
def main(args: Array[String]): Unit = {
val cls = Class.forName("upstream.Greeter")
val value = cls.getMethod("greet").invoke(null)
assert(value == "hello-from-feature", s"Expected 'hello-from-feature' but got '$value'")
println(s"Greeter loaded: $value")
val branchCls = Class.forName("upstream.BranchOnly")
val branchValue = branchCls.getMethod("value").invoke(null)
assert(branchValue == "branch-only", s"Expected 'branch-only' but got '$branchValue'")
println(s"BranchOnly loaded: $branchValue")
}
}

View File

@ -0,0 +1,5 @@
lazy val dep = RootProject(uri(s"git:file://${System.getProperty("user.dir")}/upstream-repo#feature"))
lazy val root = (project in file(".")).dependsOn(dep).settings(
dep / repositoryUpdateStrategy := RepositoryUpdateStrategy.Always
)

View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -e
mkdir -p upstream-repo/src/main/scala/upstream
cd upstream-repo
git init
git config user.email "test@test.com"
git config user.name "Test"
git config commit.gpgsign false
cat > build.sbt << 'EOF'
name := "upstream-dep"
organization := "upstream"
version := "0.1.0"
EOF
cat > src/main/scala/upstream/Placeholder.scala << 'EOF'
package upstream
EOF
git add .
git commit -m "initial commit"
# Create feature branch with Greeter class
git checkout -b feature
cat > src/main/scala/upstream/Greeter.scala << 'EOF'
package upstream
object Greeter {
def greet: String = "hello-from-feature"
}
EOF
git add .
git commit -m "add Greeter on feature branch"

View File

@ -0,0 +1,8 @@
object Main {
def main(args: Array[String]): Unit = {
val cls = Class.forName("upstream.Greeter")
val value = cls.getMethod("greet").invoke(null)
assert(value == "hello-from-feature", s"Expected 'hello-from-feature' but got '$value'")
println(s"Greeter loaded: $value")
}
}

View File

@ -0,0 +1,11 @@
$ exec bash setup.sh
$ copy-file changes/build-with-branch.sbt build.sbt
> reload
> run
# Ensures that update fetches from the correct branch
$ exec bash add-branch-class.sh
$ copy-file changes/MainWithBranchOnly.scala src/main/scala/Main.scala
> update
> reload
> run

View File

@ -0,0 +1 @@
lazy val root = project in file(".")

View File

@ -0,0 +1,6 @@
lazy val aProject = ProjectRef(
uri(s"git:file://${System.getProperty("user.dir")}/upstream-repo"),
"a"
)
lazy val root = (project in file(".")).dependsOn(aProject)

View File

@ -0,0 +1,35 @@
#!/usr/bin/env bash
set -e
mkdir -p upstream-repo/a/src/main/scala/upstream
mkdir -p upstream-repo/b/src/main/scala/upstream
cd upstream-repo
git init
git config user.email "test@test.com"
git config user.name "Test"
git config commit.gpgsign false
cat > build.sbt << 'EOF'
lazy val b = project.in(file("b"))
lazy val a = project.in(file("a")).dependsOn(b)
EOF
cat > b/src/main/scala/upstream/BService.scala << 'EOF'
package upstream
object BService {
def provide: String = "from-B"
}
EOF
cat > a/src/main/scala/upstream/AService.scala << 'EOF'
package upstream
object AService {
def provide: String = s"from-A-via-${BService.provide}"
}
EOF
git add .
git commit -m "initial commit"

View File

@ -0,0 +1,13 @@
object Main {
def main(args: Array[String]): Unit = {
val aClass = Class.forName("upstream.AService")
val aValue = aClass.getMethod("provide").invoke(null)
assert(aValue == "from-A-via-from-B", s"Unexpected AService.provide: '$aValue'")
println(s"AService loaded: $aValue")
val bClass = Class.forName("upstream.BService")
val bValue = bClass.getMethod("provide").invoke(null)
assert(bValue == "from-B", s"Unexpected BService.provide: '$bValue'")
println(s"BService loaded: $bValue")
}
}

View File

@ -0,0 +1,10 @@
# Given:
# - VCS project `b`
# - VCS project `a`, which depends on `b`
# - Project `root`, which depends on `a`
# Ensures that `root` has access to `b`
$ exec bash setup.sh
$ copy-file changes/build-with-dep.sbt build.sbt
> reload
> run

View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
cd upstream-repo
git config commit.gpgsign false
cat > src/main/scala/upstream/Helper.scala << 'EOF'
package upstream
object Helper {
def help: String = "helped"
}
EOF
git add .
git commit -m "add Helper class"

View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
cd upstream-repo
git config commit.gpgsign false
cat > src/main/scala/upstream/ManualClass.scala << 'EOF'
package upstream
object ManualClass {
def value: String = "manual"
}
EOF
git add .
git commit -m "add ManualClass"

View File

@ -0,0 +1,16 @@
#!/usr/bin/env bash
set -e
cd upstream-repo
git config commit.gpgsign false
cat > src/main/scala/upstream/Utils.scala << 'EOF'
package upstream
object Utils {
def util: String = "utility"
}
EOF
git add .
git commit -m "add Utils class"

View File

@ -0,0 +1 @@
lazy val root = project in file(".")

View File

@ -0,0 +1,28 @@
object Main {
def main(args: Array[String]): Unit = {
val greeterClass = Class.forName("upstream.Greeter")
val greet = greeterClass.getMethod("greet").invoke(null)
assert(greet == "hello", s"Expected 'hello' but got '$greet'")
println(s"Greeter loaded: $greet")
val helperClass = Class.forName("upstream.Helper")
val help = helperClass.getMethod("help").invoke(null)
assert(help == "helped", s"Expected 'helped' but got '$help'")
println(s"Helper loaded: $help")
val utilsClass = Class.forName("upstream.Utils")
val util = utilsClass.getMethod("util").invoke(null)
assert(util == "utility", s"Expected 'utility' but got '$util'")
println(s"Utils loaded: $util")
try {
Class.forName("upstream.ManualClass")
sys.error("upstream.ManualClass found on classpath")
} catch {
case _: ClassNotFoundException =>
()
case e =>
sys.error(s"Unknown error: ${e}")
}
}
}

View File

@ -0,0 +1,13 @@
object Main {
def main(args: Array[String]): Unit = {
val greeterClass = Class.forName("upstream.Greeter")
val greet = greeterClass.getMethod("greet").invoke(null)
assert(greet == "hello", s"Expected 'hello' but got '$greet'")
println(s"Greeter loaded: $greet")
val helperClass = Class.forName("upstream.Helper")
val help = helperClass.getMethod("help").invoke(null)
assert(help == "helped", s"Expected 'helped' but got '$help'")
println(s"Helper loaded: $help")
}
}

View File

@ -0,0 +1,18 @@
object Main {
def main(args: Array[String]): Unit = {
val greeterClass = Class.forName("upstream.Greeter")
val greet = greeterClass.getMethod("greet").invoke(null)
assert(greet == "hello", s"Expected 'hello' but got '$greet'")
println(s"Greeter loaded: $greet")
val helperClass = Class.forName("upstream.Helper")
val help = helperClass.getMethod("help").invoke(null)
assert(help == "helped", s"Expected 'helped' but got '$help'")
println(s"Helper loaded: $help")
val utilsClass = Class.forName("upstream.Utils")
val util = utilsClass.getMethod("util").invoke(null)
assert(util == "utility", s"Expected 'utility' but got '$util'")
println(s"Utils loaded: $util")
}
}

View File

@ -0,0 +1,4 @@
lazy val dep = RootProject(uri(s"git:file://${System.getProperty("user.dir")}/upstream-repo"))
// Uses the default Manual strategy
lazy val root = (project in file(".")).dependsOn(dep)

View File

@ -0,0 +1,5 @@
lazy val dep = RootProject(uri(s"git:file://${System.getProperty("user.dir")}/upstream-repo"))
lazy val root = (project in file(".")).dependsOn(dep).settings(
dep / repositoryUpdateStrategy := RepositoryUpdateStrategy.Always
)

View File

@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -e
mkdir -p upstream-repo/src/main/scala/upstream
cd upstream-repo
git init
git config user.email "test@test.com"
git config user.name "Test"
git config commit.gpgsign false
cat > build.sbt << 'EOF'
name := "upstream-dep"
organization := "upstream"
version := "0.1.0"
EOF
cat > src/main/scala/upstream/Greeter.scala << 'EOF'
package upstream
object Greeter {
def greet: String = "hello"
}
EOF
git add .
git commit -m "initial commit"

View File

@ -0,0 +1,8 @@
object Main {
def main(args: Array[String]): Unit = {
val greeterClass = Class.forName("upstream.Greeter")
val greet = greeterClass.getMethod("greet").invoke(null)
assert(greet == "hello", s"Expected 'hello' but got '$greet'")
println(s"Greeter loaded: $greet")
}
}

View File

@ -0,0 +1,26 @@
# Ensures that initial fetch works correctly
$ exec bash setup.sh
$ copy-file changes/build-with-dep.sbt build.sbt
> reload
> run
# Ensures that `updateRemoteProjects` works correctly
$ exec bash add-helper.sh
$ copy-file changes/MainWithHelper.scala src/main/scala/Main.scala
> updateRemoteProjects
> run
# Ensures update with `Always` strategy
$ exec bash add-utils.sh
$ copy-file changes/MainWithUtils.scala src/main/scala/Main.scala
> update
> reload
> run
# Ensures that `Manual` strategy does nothing on `update`
$ copy-file changes/build-with-dep-manual.sbt build.sbt
> reload
$ exec bash add-manual-class.sh
$ copy-file changes/MainCheckManual.scala src/main/scala/Main.scala
> update
> run