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