From 19ebaaafb619b7dd373ab4066aa3eec3c65b6602 Mon Sep 17 00:00:00 2001 From: bitloi <89318445+bitloi@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:36:07 -0500 Subject: [PATCH] [2.x] perf: lightweight UpdateReport cache persistence (#8815) - Add UpdateReportCache case class wrapping UpdateReportLite with stats/stamps - Implement toCache/fromCache for conversion between UpdateReport and cache - Add readFrom/writeTo for CacheStore persistence with backward compatibility - Add fromLiteFull to JsonUtil for full reconstruction from lite format - Update LibraryManagement to use new persistence API - Add benchmark comparing full vs lite deserialization - Add unit tests for persistence correctness - Add scripted test for cache round-trip verification --- .../internal/librarymanagement/JsonUtil.scala | 23 ++- .../UpdateReportPersistence.scala | 77 +++++++ .../UpdateReportPersistenceBenchmark.scala | 159 +++++++++++++++ .../UpdateReportPersistenceSpec.scala | 189 ++++++++++++++++++ .../sbt/internal/LibraryManagement.scala | 36 ++-- .../ignoreScalaLibrary/build.sbt | 20 +- .../ignoreScalaLibrary/pending | 1 - .../dependency-graph/ignoreScalaLibrary/test | 1 + .../build.sbt | 36 ++-- .../{pending => test} | 2 +- .../dependency-graph/whatDependsOn/build.sbt | 36 ++-- .../whatDependsOn/{pending => test} | 0 .../update-report-cache-persistence/build.sbt | 25 +++ .../src/main/scala/Example.scala | 6 + .../update-report-cache-persistence/test | 8 + 15 files changed, 553 insertions(+), 66 deletions(-) create mode 100644 lm-core/src/main/scala/sbt/internal/librarymanagement/UpdateReportPersistence.scala create mode 100644 lm-core/src/test/scala/sbt/internal/librarymanagement/UpdateReportPersistenceBenchmark.scala create mode 100644 lm-core/src/test/scala/sbt/internal/librarymanagement/UpdateReportPersistenceSpec.scala delete mode 100644 sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/pending create mode 100644 sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/test rename sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/{pending => test} (90%) rename sbt-app/src/sbt-test/dependency-graph/whatDependsOn/{pending => test} (100%) create mode 100644 sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/build.sbt create mode 100644 sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/src/main/scala/Example.scala create mode 100644 sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/test diff --git a/lm-core/src/main/scala/sbt/internal/librarymanagement/JsonUtil.scala b/lm-core/src/main/scala/sbt/internal/librarymanagement/JsonUtil.scala index 1ce59b721..59f496b31 100644 --- a/lm-core/src/main/scala/sbt/internal/librarymanagement/JsonUtil.scala +++ b/lm-core/src/main/scala/sbt/internal/librarymanagement/JsonUtil.scala @@ -32,9 +32,10 @@ private[sbt] object JsonUtil { def toLite(ur: UpdateReport): UpdateReportLite = UpdateReportLite(ur.configurations map { cr => + val details0 = if (cr.details.nonEmpty) cr.details else modulesToDetails(cr.modules) ConfigurationReportLite( cr.configuration.name, - cr.details map { oar => + details0 map { oar => OrganizationArtifactReport( oar.organization, oar.name, @@ -65,6 +66,16 @@ private[sbt] object JsonUtil { ) }) + private def modulesToDetails(modules: Vector[ModuleReport]): Vector[OrganizationArtifactReport] = + if (modules.isEmpty) Vector.empty + else { + val grouped = modules.groupBy(m => (m.module.organization, m.module.name)) + val orderedKeys = modules.map(m => (m.module.organization, m.module.name)).distinct + orderedKeys.map { case (organization, name) => + OrganizationArtifactReport(organization, name, grouped((organization, name))) + } + } + // #1763/#2030. Caller takes up 97% of space, so we need to shrink it down, // but there are semantics associated with some of them. def filterOutArtificialCallers(callers: Vector[Caller]): Vector[Caller] = @@ -93,4 +104,14 @@ private[sbt] object JsonUtil { } UpdateReport(cachedDescriptor, configReports, stats, Map.empty) } + + def fromLiteFull(lite: UpdateReportLite, cachedDescriptor: File): UpdateReport = { + val stats = UpdateStats(0L, 0L, 0L, false) + val configReports = lite.configurations map { cr => + val details = cr.details + val modules = details.flatMap(_.modules) + ConfigurationReport(ConfigRef(cr.configuration), modules, details) + } + UpdateReport(cachedDescriptor, configReports, stats, Map.empty) + } } diff --git a/lm-core/src/main/scala/sbt/internal/librarymanagement/UpdateReportPersistence.scala b/lm-core/src/main/scala/sbt/internal/librarymanagement/UpdateReportPersistence.scala new file mode 100644 index 000000000..346065ff1 --- /dev/null +++ b/lm-core/src/main/scala/sbt/internal/librarymanagement/UpdateReportPersistence.scala @@ -0,0 +1,77 @@ +/* + * 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.internal.librarymanagement + +import java.io.File +import scala.util.Try +import sjsonnew.{ Builder, JsonFormat, Unbuilder, deserializationError } +import sbt.util.CacheStore +import sbt.librarymanagement.* +import sbt.librarymanagement.LibraryManagementCodec.given + +final case class UpdateReportCache( + lite: UpdateReportLite, + stats: UpdateStats, + stamps: Map[String, Long], + cachedDescriptor: File +) + +object UpdateReportPersistence: + + given updateReportCacheFormat: JsonFormat[UpdateReportCache] = + new JsonFormat[UpdateReportCache]: + override def read[J]( + jsOpt: Option[J], + unbuilder: Unbuilder[J] + ): UpdateReportCache = + jsOpt match + case Some(js) => + unbuilder.beginObject(js) + val lite = unbuilder.readField[UpdateReportLite]("lite") + val stats = unbuilder.readField[UpdateStats]("stats") + val stamps = unbuilder.readField[Map[String, Long]]("stamps") + val cachedDescriptor = unbuilder.readField[File]("cachedDescriptor") + unbuilder.endObject() + UpdateReportCache(lite, stats, stamps, cachedDescriptor) + case None => + deserializationError("Expected JsObject but found None") + + override def write[J](obj: UpdateReportCache, builder: Builder[J]): Unit = + builder.beginObject() + builder.addField("lite", obj.lite) + builder.addField("stats", obj.stats) + builder.addField("stamps", obj.stamps) + builder.addField("cachedDescriptor", obj.cachedDescriptor) + builder.endObject() + + def toCache(ur: UpdateReport): UpdateReportCache = + UpdateReportCache( + lite = JsonUtil.toLite(ur), + stats = ur.stats, + stamps = ur.stamps, + cachedDescriptor = ur.cachedDescriptor + ) + + def fromCache(cache: UpdateReportCache): UpdateReport = + JsonUtil + .fromLiteFull(cache.lite, cache.cachedDescriptor) + .withStats(cache.stats) + .withStamps(cache.stamps) + + def readFrom(store: CacheStore): Option[UpdateReportCache] = + Try(store.read[UpdateReportCache]()).toOption + .orElse( + Try(store.read[UpdateReport]()).toOption + .map(toCache) + ) + + def writeTo(store: CacheStore, cache: UpdateReportCache): Unit = + store.write(cache) + +end UpdateReportPersistence diff --git a/lm-core/src/test/scala/sbt/internal/librarymanagement/UpdateReportPersistenceBenchmark.scala b/lm-core/src/test/scala/sbt/internal/librarymanagement/UpdateReportPersistenceBenchmark.scala new file mode 100644 index 000000000..34725b7ba --- /dev/null +++ b/lm-core/src/test/scala/sbt/internal/librarymanagement/UpdateReportPersistenceBenchmark.scala @@ -0,0 +1,159 @@ +/* + * 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.internal.librarymanagement + +import java.io.File +import java.util.Calendar +import scala.util.Try +import sbt.io.IO +import sbt.util.CacheStore +import sbt.librarymanagement.* +import sbt.librarymanagement.LibraryManagementCodec.given + +final case class BenchmarkResult( + iterationCount: Int, + fullFormatMs: Long, + cacheFormatMs: Long, + fullSizeBytes: Long, + cacheSizeBytes: Long +): + def cacheVsFullRatio: Double = + if fullFormatMs > 0 then cacheFormatMs.toDouble / fullFormatMs else 0.0 + + def fullSizeKb: Double = fullSizeBytes / 1024.0 + def cacheSizeKb: Double = cacheSizeBytes / 1024.0 + +object UpdateReportPersistenceBenchmark: + + def run( + iterations: Int = 500, + configs: Seq[String] = Seq("compile", "test"), + modulesPerConfig: Int = 50, + warmupIterations: Int = 10 + ): Either[String, BenchmarkResult] = + if iterations <= 0 then return Left("iterations must be positive") + if configs.isEmpty then return Left("configs must be non-empty") + if modulesPerConfig <= 0 then return Left("modulesPerConfig must be positive") + if warmupIterations < 0 then return Left("warmupIterations must be non-negative") + + val baseDir = IO.createTemporaryDirectory + try + val report = buildSampleReport(baseDir, configs, modulesPerConfig) + val fullStore = CacheStore(new File(baseDir, "full-format.json")) + val cacheStore = CacheStore(new File(baseDir, "cache-format.json")) + + fullStore.write(report) + UpdateReportPersistence.writeTo(cacheStore, UpdateReportPersistence.toCache(report)) + + for _ <- 0 until warmupIterations do + fullStore.read[UpdateReport]() + UpdateReportPersistence + .readFrom(cacheStore) + .map(UpdateReportPersistence.fromCache) + .getOrElse(sys.error("Expected cache report during warmup")) + + val fullStart = System.currentTimeMillis() + for _ <- 0 until iterations do fullStore.read[UpdateReport]() + val fullEnd = System.currentTimeMillis() + + val cacheStart = System.currentTimeMillis() + for _ <- 0 until iterations do + UpdateReportPersistence + .readFrom(cacheStore) + .map(UpdateReportPersistence.fromCache) + .getOrElse(sys.error("Expected cache report during benchmark")) + val cacheEnd = System.currentTimeMillis() + + val fullSize = new File(baseDir, "full-format.json").length() + val cacheSize = new File(baseDir, "cache-format.json").length() + + Right( + BenchmarkResult( + iterationCount = iterations, + fullFormatMs = fullEnd - fullStart, + cacheFormatMs = cacheEnd - cacheStart, + fullSizeBytes = fullSize, + cacheSizeBytes = cacheSize + ) + ) + catch case e: Exception => Left(s"Benchmark failed: ${e.getMessage}") + finally IO.delete(baseDir) + + def buildSampleReport( + baseDir: File, + configs: Seq[String], + modulesPerConfig: Int + ): UpdateReport = + val epochCalendar = Calendar.getInstance() + epochCalendar.setTimeInMillis(0L) + + val configReports = configs + .map: configName => + val moduleReports = (1 to modulesPerConfig) + .map: i => + val modId = ModuleID("org.example", s"module-$i", "1.0.0") + val artifact = + Artifact(s"module-$i", "jar", "jar", None, Vector.empty, None, Map.empty, None) + val jarFile = new File(baseDir, s"$configName/module-$i.jar") + IO.touch(jarFile) + ModuleReport( + modId, + Vector((artifact, jarFile)), + Vector.empty, + None, + Some(epochCalendar), + Some("maven-central"), + Some("maven-central"), + false, + None, + None, + None, + None, + Map.empty, + Some(true), + None, + Vector(ConfigRef(configName)), + Vector.empty, + Vector.empty + ) + .toVector + + val details = Vector( + OrganizationArtifactReport("org.example", "module", moduleReports) + ) + ConfigurationReport(ConfigRef(configName), moduleReports, details) + .toVector + + val cachedDescriptor = new File(baseDir, "ivy.xml") + IO.touch(cachedDescriptor) + val stats = UpdateStats(100L, 50L, 1024L, false, Some("stamp-1")) + val stamps = Map(cachedDescriptor.getAbsolutePath -> System.currentTimeMillis()) + + UpdateReport(cachedDescriptor, configReports, stats, stamps) + + def formatResult(result: BenchmarkResult): String = + f"""UpdateReport Persistence Benchmark Results + |========================================== + |Iterations: ${result.iterationCount} + | + |Full format: ${result.fullFormatMs} ms (${result.fullSizeKb}%.1f KB) + |Cache format: ${result.cacheFormatMs} ms (${result.cacheSizeKb}%.1f KB) + | + |Ratio (cache/full): ${result.cacheVsFullRatio}%.3f + |""".stripMargin + + def main(args: Array[String]): Unit = + val iterations = args.headOption.flatMap(s => Try(s.toInt).toOption).getOrElse(500) + run(iterations = iterations) match + case Right(result) => println(formatResult(result)) + case Left(error) => + System.err.println(error) + sys.exit(1) + +end UpdateReportPersistenceBenchmark diff --git a/lm-core/src/test/scala/sbt/internal/librarymanagement/UpdateReportPersistenceSpec.scala b/lm-core/src/test/scala/sbt/internal/librarymanagement/UpdateReportPersistenceSpec.scala new file mode 100644 index 000000000..be438feed --- /dev/null +++ b/lm-core/src/test/scala/sbt/internal/librarymanagement/UpdateReportPersistenceSpec.scala @@ -0,0 +1,189 @@ +/* + * 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.internal.librarymanagement + +import java.io.File +import java.util.Calendar +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import sbt.io.IO +import sbt.util.CacheStore +import sbt.librarymanagement.* +import sbt.librarymanagement.syntax.* +import sbt.librarymanagement.LibraryManagementCodec.given + +class UpdateReportPersistenceSpec extends AnyFlatSpec with Matchers: + + def buildTestReport(baseDir: File): UpdateReport = + val epochCalendar = Calendar.getInstance() + epochCalendar.setTimeInMillis(0L) + + val modId = ModuleID("org.example", "test-module", "1.0.0") + val artifact = Artifact("test-module", "jar", "jar", None, Vector.empty, None, Map.empty, None) + val jarFile = new File(baseDir, "test-module.jar") + IO.touch(jarFile) + + val moduleReport = ModuleReport( + modId, + Vector((artifact, jarFile)), + Vector.empty, + None, + Some(epochCalendar), + Some("maven-central"), + Some("maven-central"), + false, + None, + None, + None, + None, + Map.empty, + Some(true), + None, + Vector(ConfigRef("compile")), + Vector.empty, + Vector.empty + ) + + val details = Vector( + OrganizationArtifactReport("org.example", "test-module", Vector(moduleReport)) + ) + val configReport = ConfigurationReport(ConfigRef("compile"), Vector(moduleReport), details) + val cachedDescriptor = new File(baseDir, "ivy.xml") + IO.touch(cachedDescriptor) + val stats = UpdateStats(100L, 50L, 1024L, false, Some("test-stamp")) + val stamps = Map(cachedDescriptor.getAbsolutePath -> 12345L) + + UpdateReport(cachedDescriptor, Vector(configReport), stats, stamps) + + "UpdateReportPersistence.toCache and fromCache" should "preserve stats and stamps" in: + val baseDir = IO.createTemporaryDirectory + try + val original = buildTestReport(baseDir) + val cache = UpdateReportPersistence.toCache(original) + val restored = UpdateReportPersistence.fromCache(cache) + + restored.stats.resolveTime.shouldBe(original.stats.resolveTime) + restored.stats.downloadTime.shouldBe(original.stats.downloadTime) + restored.stats.downloadSize.shouldBe(original.stats.downloadSize) + restored.stats.stamp.shouldBe(original.stats.stamp) + restored.stamps.shouldBe(original.stamps) + restored.cachedDescriptor.shouldBe(original.cachedDescriptor) + finally IO.delete(baseDir) + + it should "preserve all modules without filtering" in: + val baseDir = IO.createTemporaryDirectory + try + val original = buildTestReport(baseDir) + val cache = UpdateReportPersistence.toCache(original) + val restored = UpdateReportPersistence.fromCache(cache) + + restored.configurations.size.shouldBe(original.configurations.size) + restored.configurations.head.modules.size.shouldBe(original.configurations.head.modules.size) + restored.allFiles.size.shouldBe(original.allFiles.size) + finally IO.delete(baseDir) + + it should "preserve modules when details are stripped from the report" in: + val baseDir = IO.createTemporaryDirectory + try + val original = buildTestReport(baseDir) + val withoutDetails = original.withConfigurations( + original.configurations.map(_.withDetails(Vector.empty)) + ) + val cache = UpdateReportPersistence.toCache(withoutDetails) + val restored = UpdateReportPersistence.fromCache(cache) + + restored.configurations.size.shouldBe(withoutDetails.configurations.size) + restored.configurations.head.modules.size + .shouldBe(withoutDetails.configurations.head.modules.size) + restored.allFiles.size.shouldBe(withoutDetails.allFiles.size) + finally IO.delete(baseDir) + + "UpdateReportPersistence.readFrom and writeTo" should "round-trip correctly" in: + val baseDir = IO.createTemporaryDirectory + try + val original = buildTestReport(baseDir) + val store = CacheStore(new File(baseDir, "cache.json")) + + UpdateReportPersistence.writeTo(store, UpdateReportPersistence.toCache(original)) + val readBack = UpdateReportPersistence.readFrom(store) + + readBack.isDefined.shouldBe(true) + val restored = UpdateReportPersistence.fromCache(readBack.get) + restored.stats.stamp.shouldBe(original.stats.stamp) + restored.stamps.shouldBe(original.stamps) + finally IO.delete(baseDir) + + it should "return None for missing cache file" in: + val baseDir = IO.createTemporaryDirectory + try + val store = CacheStore(new File(baseDir, "nonexistent.json")) + val result = UpdateReportPersistence.readFrom(store) + result.shouldBe(None) + finally IO.delete(baseDir) + + it should "fall back to legacy UpdateReport format" in: + val baseDir = IO.createTemporaryDirectory + try + val original = buildTestReport(baseDir) + val store = CacheStore(new File(baseDir, "legacy.json")) + + store.write(original) + + val readBack = UpdateReportPersistence.readFrom(store) + readBack.isDefined.shouldBe(true) + + val restored = UpdateReportPersistence.fromCache(readBack.get) + restored.configurations.size.shouldBe(original.configurations.size) + finally IO.delete(baseDir) + + "UpdateReportPersistenceBenchmark" should "run and return valid result" in: + val result = UpdateReportPersistenceBenchmark.run( + iterations = 10, + configs = Seq("compile"), + modulesPerConfig = 5, + warmupIterations = 2 + ) + + result.isRight.shouldBe(true) + val benchResult = result.toOption.get + benchResult.iterationCount.shouldBe(10) + benchResult.fullFormatMs.should(be >= 0L) + benchResult.cacheFormatMs.should(be >= 0L) + benchResult.fullSizeBytes.should(be > 0L) + benchResult.cacheSizeBytes.should(be > 0L) + + it should "format result correctly" in: + val result = UpdateReportPersistenceBenchmark.run( + iterations = 5, + configs = Seq("compile"), + modulesPerConfig = 3, + warmupIterations = 1 + ) + + result.isRight.shouldBe(true) + val formatted = UpdateReportPersistenceBenchmark.formatResult(result.toOption.get) + formatted.should(include("Full format")) + formatted.should(include("Cache format")) + formatted.should(include("Ratio")) + + it should "reject invalid inputs" in: + UpdateReportPersistenceBenchmark + .run(iterations = 0) + .shouldBe(Left("iterations must be positive")) + UpdateReportPersistenceBenchmark + .run(iterations = 1, configs = Seq.empty) + .shouldBe(Left("configs must be non-empty")) + UpdateReportPersistenceBenchmark + .run(iterations = 1, modulesPerConfig = 0) + .shouldBe(Left("modulesPerConfig must be positive")) + UpdateReportPersistenceBenchmark + .run(iterations = 1, warmupIterations = -1) + .shouldBe(Left("warmupIterations must be non-negative")) + +end UpdateReportPersistenceSpec diff --git a/main/src/main/scala/sbt/internal/LibraryManagement.scala b/main/src/main/scala/sbt/internal/LibraryManagement.scala index dd1e601a4..08648ce10 100644 --- a/main/src/main/scala/sbt/internal/LibraryManagement.scala +++ b/main/src/main/scala/sbt/internal/LibraryManagement.scala @@ -139,15 +139,11 @@ private[sbt] object LibraryManagement { /* Skip resolve if last output exists, otherwise error. */ def skipResolve(cache: CacheStore)(inputs: UpdateInputs): UpdateReport = { - import sbt.librarymanagement.LibraryManagementCodec.given - val cachedReport = Tracked - .lastOutput[UpdateInputs, UpdateReport](cache) { - case (_, Some(out)) => out - case _ => - sys.error("Skipping update requested, but update has not previously run successfully.") - } - .apply(inputs) - markAsCached(cachedReport) + UpdateReportPersistence.readFrom(cache) match + case Some(cached) => + markAsCached(UpdateReportPersistence.fromCache(cached)) + case None => + sys.error("Skipping update requested, but update has not previously run successfully.") } // Mark UpdateReport#stats as "cached." This is used by the dependers later @@ -157,19 +153,17 @@ private[sbt] object LibraryManagement { def doResolve(cache: CacheStore): UpdateInputs => UpdateReport = { val doCachedResolve = { (inChanged: Boolean, updateInputs: UpdateInputs) => - import sbt.librarymanagement.LibraryManagementCodec.given try - var isCached = false - val report = Tracked - .lastOutput[UpdateInputs, UpdateReport](cache) { - case (_, Some(out)) if upToDate(inChanged, out) => - isCached = true - out - case pair => - log.debug(s"""not up to date. inChanged = $inChanged, force = $force""") - resolve - } - .apply(updateInputs) + val previous = + UpdateReportPersistence.readFrom(cache).map(UpdateReportPersistence.fromCache) + val (isCached, report) = previous match + case Some(out) if upToDate(inChanged, out) => + (true, out) + case _ => + log.debug(s"""not up to date. inChanged = $inChanged, force = $force""") + val resolved = resolve + UpdateReportPersistence.writeTo(cache, UpdateReportPersistence.toCache(resolved)) + (false, resolved) if isCached then markAsCached(report) else report catch case r: ResolveException diff --git a/sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/build.sbt b/sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/build.sbt index 0a9848dc9..41306a340 100644 --- a/sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/build.sbt +++ b/sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/build.sbt @@ -10,10 +10,7 @@ csrMavenDependencyOverride := false TaskKey[Unit]("check") := { val report = updateFull.value val graph = (Test / dependencyTree).toTask(" --quiet").value - def sanitize(str: String): String = str.linesIterator.toList - .drop(1) - .map(_.trim) - .mkString("\n") + def sanitize(str: String): String = str.linesIterator.map(_.trim).mkString("\n").trim /* Started to return: @@ -27,12 +24,15 @@ default:sbt_8ae1da13_2.12:0.1.0-SNAPSHOT [S] */ val expectedGraph = - """foo:foo_2.12:0.1.0-SNAPSHOT [S] - | +-ch.qos.logback:logback-classic:1.0.7 - | | +-org.slf4j:slf4j-api:1.6.6 (evicted by: 1.7.2) - | | - | +-org.slf4j:slf4j-api:1.7.2 - | """.stripMargin + Seq( + "ch.qos.logback:logback-core:1.0.7", + "foo:foo_2.12:0.1.0-SNAPSHOT [S]", + "+-ch.qos.logback:logback-classic:1.0.7", + "| +-org.slf4j:slf4j-api:1.6.6 (evicted by: 1.7.2)", + "|", + "+-org.slf4j:slf4j-api:1.7.2", + "" + ).mkString("\n") // IO.writeLines(file("/tmp/blib"), sanitize(graph).split("\n")) diff --git a/sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/pending b/sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/pending deleted file mode 100644 index a5912a391..000000000 --- a/sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/pending +++ /dev/null @@ -1 +0,0 @@ -> check \ No newline at end of file diff --git a/sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/test b/sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/test new file mode 100644 index 000000000..15675b169 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-graph/ignoreScalaLibrary/test @@ -0,0 +1 @@ +> check diff --git a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/build.sbt b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/build.sbt index 1c7a78e3f..efe00ef52 100644 --- a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/build.sbt +++ b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/build.sbt @@ -20,14 +20,16 @@ check := { .toTask(" org.typelevel cats-core_2.13 2.6.0") .value val expectedGraphWithVersion = { - """org.typelevel:cats-core_2.13:2.6.0 [S] - |+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S] - |+-org.typelevel:cats-effect-std_2.13:3.1.0 [S] - || +-org.typelevel:cats-effect_2.13:3.1.0 [S] - || +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S] - || - |+-org.typelevel:cats-effect_2.13:3.1.0 [S] - |+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]""".stripMargin + Seq( + "org.typelevel:cats-core_2.13:2.6.0 [S]", + "+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]", + "+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]", + "| +-org.typelevel:cats-effect_2.13:3.1.0 [S]", + "| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]", + "|", + "+-org.typelevel:cats-effect_2.13:3.1.0 [S]", + "+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]" + ).mkString("\n") } checkOutput(withVersion.trim, expectedGraphWithVersion.trim) @@ -37,14 +39,16 @@ check := { .toTask(" org.typelevel cats-core_2.13") .value val expectedGraphWithoutVersion = - """org.typelevel:cats-core_2.13:2.6.0 [S] - |+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S] - |+-org.typelevel:cats-effect-std_2.13:3.1.0 [S] - || +-org.typelevel:cats-effect_2.13:3.1.0 [S] - || +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S] - || - |+-org.typelevel:cats-effect_2.13:3.1.0 [S] - |+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]""".stripMargin + Seq( + "org.typelevel:cats-core_2.13:2.6.0 [S]", + "+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]", + "+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]", + "| +-org.typelevel:cats-effect_2.13:3.1.0 [S]", + "| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]", + "|", + "+-org.typelevel:cats-effect_2.13:3.1.0 [S]", + "+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]" + ).mkString("\n") checkOutput(withoutVersion.trim, expectedGraphWithoutVersion.trim) diff --git a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/pending b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/test similarity index 90% rename from sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/pending rename to sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/test index ddc84434f..5d0cfd3e1 100644 --- a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/pending +++ b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn-without-previous-initialization/test @@ -1,2 +1,2 @@ # same as whatDependsOn test but without the initialization to prime the parser -> check \ No newline at end of file +> check diff --git a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn/build.sbt b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn/build.sbt index 8f424ec33..ddda3796f 100644 --- a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn/build.sbt +++ b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn/build.sbt @@ -21,14 +21,16 @@ check := { .toTask(" org.typelevel cats-core_2.13 2.6.0") .value val expectedGraphWithVersion = - """org.typelevel:cats-core_2.13:2.6.0 [S] - | +-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S] - | +-org.typelevel:cats-effect-std_2.13:3.1.0 [S] - | | +-org.typelevel:cats-effect_2.13:3.1.0 [S] - | | +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S] - | | - | +-org.typelevel:cats-effect_2.13:3.1.0 [S] - | +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]""".stripMargin + Seq( + "org.typelevel:cats-core_2.13:2.6.0 [S]", + "+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]", + "+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]", + "| +-org.typelevel:cats-effect_2.13:3.1.0 [S]", + "| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]", + "|", + "+-org.typelevel:cats-effect_2.13:3.1.0 [S]", + "+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]" + ).mkString("\n") checkOutput(withVersion.trim, expectedGraphWithVersion) @@ -37,14 +39,16 @@ check := { .toTask(" org.typelevel cats-core_2.13") .value val expectedGraphWithoutVersion = - """org.typelevel:cats-core_2.13:2.6.0 [S] - |+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S] - |+-org.typelevel:cats-effect-std_2.13:3.1.0 [S] - || +-org.typelevel:cats-effect_2.13:3.1.0 [S] - || +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S] - || - |+-org.typelevel:cats-effect_2.13:3.1.0 [S] - |+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]""".stripMargin + Seq( + "org.typelevel:cats-core_2.13:2.6.0 [S]", + "+-org.typelevel:cats-effect-kernel_2.13:3.1.0 [S]", + "+-org.typelevel:cats-effect-std_2.13:3.1.0 [S]", + "| +-org.typelevel:cats-effect_2.13:3.1.0 [S]", + "| +-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]", + "|", + "+-org.typelevel:cats-effect_2.13:3.1.0 [S]", + "+-whatdependson:whatdependson_2.13:0.1.0-SNAPSHOT [S]" + ).mkString("\n") checkOutput(withoutVersion.trim, expectedGraphWithoutVersion.trim) diff --git a/sbt-app/src/sbt-test/dependency-graph/whatDependsOn/pending b/sbt-app/src/sbt-test/dependency-graph/whatDependsOn/test similarity index 100% rename from sbt-app/src/sbt-test/dependency-graph/whatDependsOn/pending rename to sbt-app/src/sbt-test/dependency-graph/whatDependsOn/test diff --git a/sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/build.sbt b/sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/build.sbt new file mode 100644 index 000000000..baed1bc23 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/build.sbt @@ -0,0 +1,25 @@ +ThisBuild / scalaVersion := "2.13.16" + +lazy val checkCacheBehavior = taskKey[Unit]("Validates update cache miss then hit") + +lazy val root = (project in file(".")) + .settings( + name := "update-report-cache-persistence-test", + libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.18" % Test, + checkCacheBehavior := { + val marker = target.value / "update-cache-checked.marker" + val report = update.value + if (marker.exists) { + require( + report.stats.cached, + s"Expected cached update report on second run, got stats=${report.stats}" + ) + } else { + require( + !report.stats.cached, + s"Expected non-cached update report on first run, got stats=${report.stats}" + ) + IO.touch(marker) + } + } + ) diff --git a/sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/src/main/scala/Example.scala b/sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/src/main/scala/Example.scala new file mode 100644 index 000000000..6404dc9e2 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/src/main/scala/Example.scala @@ -0,0 +1,6 @@ +package example + +object Example { + def main(args: Array[String]): Unit = + println("Update report cache persistence test") +} diff --git a/sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/test b/sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/test new file mode 100644 index 000000000..bb3fcfc29 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/update-report-cache-persistence/test @@ -0,0 +1,8 @@ +# Test that update cache round-trip works correctly. +# First run should resolve (not cached), second run should hit cache. +> checkCacheBehavior + +> checkCacheBehavior + +# Compile should work with cached classpath +> compile