From 16cfc6615238a4f0df50b9c3e4ffdac4f79a9551 Mon Sep 17 00:00:00 2001 From: calm329 Date: Tue, 20 Jan 2026 14:18:05 -0800 Subject: [PATCH 1/2] fix: Invalidate update cache across commands when dependencies change --- main/src/main/scala/sbt/Defaults.scala | 2 +- .../sbt/internal/LibraryManagement.scala | 23 +++++++++++++++---- .../a/src/main/scala/A.scala | 2 ++ .../build.sbt | 17 ++++++++++++++ .../changes/build.sbt | 16 +++++++++++++ .../itTests/src/test/scala/Test.scala | 2 ++ .../i8357-transitive-update-invalidation/test | 20 ++++++++++++++++ 7 files changed, 76 insertions(+), 6 deletions(-) create mode 100644 sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/a/src/main/scala/A.scala create mode 100644 sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/build.sbt create mode 100644 sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/changes/build.sbt create mode 100644 sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/itTests/src/test/scala/Test.scala create mode 100644 sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/test diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 1b149e855..594e9efb7 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -3827,7 +3827,7 @@ object Classpaths { substituteScalaFiles(scalaOrganization.value, _)(providedScalaJars), skip = (update / skip).value, force = shouldForce, - depsUpdated = transitiveUpdate.value.exists(!_.stats.cached), + transitiveUpdates = transitiveUpdate.value, uwConfig = (update / unresolvedWarningConfiguration).value, evictionLevel = evictionErrorLevel.value, versionSchemeOverrides = libraryDependencySchemes.value, diff --git a/main/src/main/scala/sbt/internal/LibraryManagement.scala b/main/src/main/scala/sbt/internal/LibraryManagement.scala index 750a51c9f..c50609ed8 100644 --- a/main/src/main/scala/sbt/internal/LibraryManagement.scala +++ b/main/src/main/scala/sbt/internal/LibraryManagement.scala @@ -26,7 +26,8 @@ import scala.compat.Platform.EOL private[sbt] object LibraryManagement { implicit val linter: sbt.dsl.LinterLevel.Ignore.type = sbt.dsl.LinterLevel.Ignore - private type UpdateInputs = (Long, ModuleSettings, UpdateConfiguration) + // The fourth element is transitive dependency stamps for cross-command cache invalidation + private type UpdateInputs = (Long, ModuleSettings, UpdateConfiguration, Vector[Long]) def cachedUpdate( lm: DependencyResolution, @@ -37,7 +38,7 @@ private[sbt] object LibraryManagement { transform: UpdateReport => UpdateReport, skip: Boolean, force: Boolean, - depsUpdated: Boolean, + transitiveUpdates: Seq[UpdateReport], uwConfig: UnresolvedWarningConfiguration, evictionLevel: Level.Value, versionSchemeOverrides: Seq[ModuleID], @@ -102,8 +103,9 @@ private[sbt] object LibraryManagement { /* Check if a update report is still up to date or we must resolve again. */ def upToDate(inChanged: Boolean, out: UpdateReport): Boolean = { + // Transitive dependency stamps are now part of UpdateInputs, so inChanged + // will be true if any transitive stamp changed (cross-command invalidation). !force && - !depsUpdated && !inChanged && out.allFiles.forall(f => fileUptodate(f, out.stamps, log)) && fileUptodate(out.cachedDescriptor, out.stamps, log) @@ -166,7 +168,18 @@ private[sbt] object LibraryManagement { val handler = if (skip && !force) skipResolve(outStore)(_) else doResolve(outStore) // Remove clock for caching purpose val withoutClock = updateConfig.withLogicalClock(LogicalClock.unknown) - handler((extraInputHash, settings, withoutClock)) + // Collect transitive stamps for cross-command cache invalidation. + // Hash the resolved module IDs (org, name, revision) from each transitive update. + // This changes when any transitive dependency's resolved versions change, + // enabling correct cache invalidation across commands. + val transitiveStamps = transitiveUpdates.map { ur => + ur.configurations + .flatMap(_.modules.map(mr => (mr.module.organization, mr.module.name, mr.module.revision))) + .toSet + .hashCode + .toLong + }.toVector + handler((extraInputHash, settings, withoutClock, transitiveStamps)) } private[this] def fileUptodate(file: File, stamps: Map[File, Long], log: Logger): Boolean = { @@ -299,7 +312,7 @@ private[sbt] object LibraryManagement { identity, skip = (update / skip).value, force = shouldForce, - depsUpdated = transitiveUpdate.value.exists(!_.stats.cached), + transitiveUpdates = transitiveUpdate.value, uwConfig = (update / unresolvedWarningConfiguration).value, evictionLevel = Level.Debug, versionSchemeOverrides = Nil, diff --git a/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/a/src/main/scala/A.scala b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/a/src/main/scala/A.scala new file mode 100644 index 000000000..6c207ebdf --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/a/src/main/scala/A.scala @@ -0,0 +1,2 @@ +package a +object A diff --git a/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/build.sbt b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/build.sbt new file mode 100644 index 000000000..f4f04f021 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/build.sbt @@ -0,0 +1,17 @@ +// Test for https://github.com/sbt/sbt/issues/8357 +// Verifies that transitiveUpdate correctly invalidates across command invocations +// when a dependency's dependencies change. + +ThisBuild / scalaVersion := "2.12.21" + +// Use a setting to control library version - this can be changed via reload +lazy val catsVersion = settingKey[String]("Cats version") + +lazy val a = project.in(file("a")) + .settings( + catsVersion := "2.8.0", + libraryDependencies += "org.typelevel" %% "cats-core" % catsVersion.value, + ) + +lazy val itTests = project.in(file("itTests")) + .dependsOn(a % "test->test") diff --git a/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/changes/build.sbt b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/changes/build.sbt new file mode 100644 index 000000000..e5f859578 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/changes/build.sbt @@ -0,0 +1,16 @@ +// Test for https://github.com/sbt/sbt/issues/8357 +// This is the changed build.sbt with different library version + +ThisBuild / scalaVersion := "2.12.21" + +lazy val catsVersion = settingKey[String]("Cats version") + +lazy val a = project.in(file("a")) + .settings( + // Changed from 2.8.0 to 2.9.0 + catsVersion := "2.9.0", + libraryDependencies += "org.typelevel" %% "cats-core" % catsVersion.value, + ) + +lazy val itTests = project.in(file("itTests")) + .dependsOn(a % "test->test") diff --git a/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/itTests/src/test/scala/Test.scala b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/itTests/src/test/scala/Test.scala new file mode 100644 index 000000000..ff241cbaa --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/itTests/src/test/scala/Test.scala @@ -0,0 +1,2 @@ +package itTests +class Test diff --git a/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/test b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/test new file mode 100644 index 000000000..4dc449c11 --- /dev/null +++ b/sbt-app/src/sbt-test/dependency-management/i8357-transitive-update-invalidation/test @@ -0,0 +1,20 @@ +# Test for https://github.com/sbt/sbt/issues/8357 +# Verifies that transitiveUpdate correctly invalidates across command invocations + +# Step 1: Run itTests/update to establish baseline with cats 2.8.0 +> itTests/update + +# Step 2: Change a's dependency version (cats 2.8.0 -> 2.9.0) +$ copy-file changes/build.sbt build.sbt + +# Step 3: Reload to pick up the new settings +> reload + +# Step 4: Run a/update in a SEPARATE command +> a/update + +# Step 5: Now run itTests/update - should re-resolve with new dependencies +> itTests/update + +# Compile to verify all dependencies are available +> itTests/compile From f5f7015d938936a96bafbf2556a97356838d4664 Mon Sep 17 00:00:00 2001 From: calm329 Date: Tue, 20 Jan 2026 14:35:47 -0800 Subject: [PATCH 2/2] fix: Update cache-update test for new UpdateInputs type --- .../cache-update/build.sbt | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/sbt-app/src/sbt-test/dependency-management/cache-update/build.sbt b/sbt-app/src/sbt-test/dependency-management/cache-update/build.sbt index 4e14962ef..258eeb186 100644 --- a/sbt-app/src/sbt-test/dependency-management/cache-update/build.sbt +++ b/sbt-app/src/sbt-test/dependency-management/cache-update/build.sbt @@ -46,16 +46,27 @@ lazy val root = (project in file(".")) .withMetadataDirectory(dependencyCacheDirectory.value) import sbt.librarymanagement.{ ModuleSettings, UpdateConfiguration, LibraryManagementCodec } - type In = (Long, ModuleSettings, UpdateConfiguration) + // The fourth element is transitive dependency stamps for cross-command cache invalidation + type In = (Long, ModuleSettings, UpdateConfiguration, Vector[Long]) import LibraryManagementCodec._ + // Compute transitive stamps the same way as LibraryManagement.cachedUpdate + val transitiveStamps0 = transitiveUpdate.value.map { ur => + ur.configurations + .flatMap(_.modules.map(mr => (mr.module.organization, mr.module.name, mr.module.revision))) + .toSet + .hashCode + .toLong + }.toVector + val f: In => Unit = Tracked.inputChanged(cacheStoreFactory make "inputs") { (inChanged: Boolean, in: In) => val extraInputHash1 = in._1 val moduleSettings1 = in._2 val inline1 = moduleSettings1 match { case x: InlineConfiguration => x } val updateConfig1 = in._3 + val transitiveStamps1 = in._4 if (inChanged) { sys.error(s""" @@ -82,11 +93,19 @@ $updateConfig1 updateConfig0 $updateConfig0 +----- +transitiveStamps1 == transitiveStamps0: ${transitiveStamps1 == transitiveStamps0} + +transitiveStamps1: +$transitiveStamps1 + +transitiveStamps0 +$transitiveStamps0 """) } } - f((extraInputHash0, (inline0: ModuleSettings), updateConfig0)) + f((extraInputHash0, (inline0: ModuleSettings), updateConfig0, transitiveStamps0)) }, // https://github.com/sbt/sbt/issues/3226