diff --git a/main/src/main/scala/sbt/internal/Load.scala b/main/src/main/scala/sbt/internal/Load.scala index 619ef2529..a7cf2aa31 100755 --- a/main/src/main/scala/sbt/internal/Load.scala +++ b/main/src/main/scala/sbt/internal/Load.scala @@ -15,7 +15,7 @@ import sbt.Keys.* import sbt.Project.inScope import sbt.ProjectExtra.{ prefixConfigs, setProject, showLoadingKey, structure } import sbt.Scope.GlobalScope -import sbt.ScopeAxis.{ Select, Zero } +import sbt.ScopeAxis.{ Select, This, Zero } import sbt.SlashSyntax0.* import sbt.internal.BuildStreams.* import sbt.internal.inc.classpath.ClasspathUtil @@ -1069,6 +1069,11 @@ private[sbt] object Load { log = log, ) + // Excludes settings already scoped explicitly (ThisBuild, Global); those apply once (#9668). + def thisProjectScoped(settings: Seq[Setting[?]]): Seq[Setting[?]] = + settings.filter: s => + s.key.scope.project == This || s.key.scope.project == Select(ThisProject) + // load all relevant configuration files (.sbt, as .scala already exists at this point) def discover(base: File): DiscoveredProjects = { val auto = @@ -1132,7 +1137,9 @@ private[sbt] object Load { val newAcc = acc :+ finalRoot val newGenerated = generated ++ generatedConfigClassFiles // only root-level settings are build-wide; a submodule's own settings must not leak to siblings (#9517). - val cs = if isRootPath(p.base, buildBase) then finalRoot.commonSettings else commonSettings + val cs = + if isRootPath(p.base, buildBase) then thisProjectScoped(finalRoot.commonSettings) + else commonSettings loadTransitive1(newProjects, newAcc, newGenerated, cs) } @@ -1168,7 +1175,9 @@ private[sbt] object Load { acc = acc, generated = Nil, commonSettings0 = commonSettings - ++ expandCommonSettingsPerBase1(buildBase.getCanonicalFile()), + ++ thisProjectScoped( + expandCommonSettingsPerBase1(buildBase.getCanonicalFile()) + ), ) val existingIds = otherProjects.projects.map(_.id) val refs = existingIds.map(id => ProjectRef(buildUri, id)) @@ -1181,7 +1190,12 @@ private[sbt] object Load { val newAcc = finalRoot +: (acc ++ otherProjects.projects) val newGenerated = generated ++ otherProjects.generatedConfigClassFiles ++ generatedConfigClassFiles - loadTransitive1(newProjects, newAcc, newGenerated, finalRoot.commonSettings) + loadTransitive1( + newProjects, + newAcc, + newGenerated, + thisProjectScoped(finalRoot.commonSettings) + ) case Nil => val projectIds = acc.map(_.id).mkString("(", ", ", ")") log.debug(s"[Loading] Done in $buildBase, returning: $projectIds") diff --git a/sbt-app/src/sbt-test/project/common-settings-synthetic-root/build.sbt b/sbt-app/src/sbt-test/project/common-settings-synthetic-root/build.sbt index 8a7a122e4..ff9984941 100644 --- a/sbt-app/src/sbt-test/project/common-settings-synthetic-root/build.sbt +++ b/sbt-app/src/sbt-test/project/common-settings-synthetic-root/build.sbt @@ -5,6 +5,16 @@ scalaVersion := scala212 val o = "com.example" organization := o +lazy val aa = settingKey[Seq[String]]("") +Global / aa := Seq("initial-value") +// explicitly ThisBuild-scoped settings must apply exactly once, not once per project (#9668) +ThisBuild / aa += "added-value" + +lazy val bb = settingKey[Seq[String]]("") +Global / bb := Seq("initial-value") +// ThisProject means "current project", so it must still apply once per project, unlike ThisBuild +ThisProject / bb += "added-value" + lazy val foo = project lazy val bar = project .settings( @@ -24,5 +34,17 @@ check := { assert((bar / organization).value == "com.example.bar", s"unexpected bar / organization = {(bar / organization).value}") // Test that baz/build.sbt bare settings get loaded assert((baz / organization).value == "com.example.baz", s"unexpected baz/organization") + + // Test that ThisBuild / aa += is applied exactly once, not once per project (#9668) + val expectedAa = Seq("initial-value", "added-value") + assert((foo / aa).value == expectedAa, s"(foo / aa).value: ${(foo / aa).value}") + assert((bar / aa).value == expectedAa, s"(bar / aa).value: ${(bar / aa).value}") + assert((baz / aa).value == expectedAa, s"(baz / aa).value: ${(baz / aa).value}") + + // Test that ThisProject / bb += still applies once per project (#9668) + val expectedBb = Seq("initial-value", "added-value") + assert((foo / bb).value == expectedBb, s"(foo / bb).value: ${(foo / bb).value}") + assert((bar / bb).value == expectedBb, s"(bar / bb).value: ${(bar / bb).value}") + assert((baz / bb).value == expectedBb, s"(baz / bb).value: ${(baz / bb).value}") } check / aggregate := false diff --git a/sbt-app/src/sbt-test/project/common-settings/build.sbt b/sbt-app/src/sbt-test/project/common-settings/build.sbt index d734f3509..2c79c2ea5 100644 --- a/sbt-app/src/sbt-test/project/common-settings/build.sbt +++ b/sbt-app/src/sbt-test/project/common-settings/build.sbt @@ -6,6 +6,16 @@ scalaVersion := scala212 val o = "com.example" organization := o +lazy val aa = settingKey[Seq[String]]("") +Global / aa := Seq("initial-value") +// explicitly ThisBuild-scoped settings must apply exactly once, not once per project (#9668) +ThisBuild / aa += "added-value" + +lazy val bb = settingKey[Seq[String]]("") +Global / bb := Seq("initial-value") +// ThisProject means "current project", so it must still apply once per project, unlike ThisBuild +ThisProject / bb += "added-value" + lazy val root = rootProject .autoAggregate @@ -34,5 +44,21 @@ LocalRootProject / check := { assert((baz / organization).value == "com.example.baz") // Test that baz/build.sbt settings don't leak onto qux, processed right after it (#9517) assert((qux / organization).value == o, s"(qux / organization).value: ${(qux / organization).value}") + + // Test that ThisBuild / aa += is applied exactly once, not once per project (#9668) + val expectedAa = Seq("initial-value", "added-value") + assert((root / aa).value == expectedAa, s"(root / aa).value: ${(root / aa).value}") + assert((foo / aa).value == expectedAa, s"(foo / aa).value: ${(foo / aa).value}") + assert((bar / aa).value == expectedAa, s"(bar / aa).value: ${(bar / aa).value}") + assert((baz / aa).value == expectedAa, s"(baz / aa).value: ${(baz / aa).value}") + assert((qux / aa).value == expectedAa, s"(qux / aa).value: ${(qux / aa).value}") + + // Test that ThisProject / bb += still applies once per project (#9668) + val expectedBb = Seq("initial-value", "added-value") + assert((root / bb).value == expectedBb, s"(root / bb).value: ${(root / bb).value}") + assert((foo / bb).value == expectedBb, s"(foo / bb).value: ${(foo / bb).value}") + assert((bar / bb).value == expectedBb, s"(bar / bb).value: ${(bar / bb).value}") + assert((baz / bb).value == expectedBb, s"(baz / bb).value: ${(baz / bb).value}") + assert((qux / bb).value == expectedBb, s"(qux / bb).value: ${(qux / bb).value}") } check / aggregate := false