diff --git a/main/src/main/scala/sbt/Defaults.scala b/main/src/main/scala/sbt/Defaults.scala index 07e22218f..92d02078e 100644 --- a/main/src/main/scala/sbt/Defaults.scala +++ b/main/src/main/scala/sbt/Defaults.scala @@ -191,6 +191,7 @@ object Defaults extends BuildCommon with DefExtra { apiURL := None, releaseNotesURL := None, javaHome :== None, + forkWorkingDirectory :== None, discoveredJavaHomes := CrossJava.discoverJavaHomes, javaHomes :== ListMap.empty, fullJavaHomes := CrossJava.expandJavaHomes(discoveredJavaHomes.value ++ javaHomes.value), @@ -1405,6 +1406,8 @@ object Defaults extends BuildCommon with DefExtra { ) ) } + + /** The forked process inherits sbt's working directory unless `forkWorkingDirectory` is set. */ def forkOptionsTask: Initialize[Task[ForkOptions]] = Def.task { val canUseArgumentsFile = sys.props @@ -1415,7 +1418,7 @@ object Defaults extends BuildCommon with DefExtra { outputStrategy = outputStrategy.value, // bootJars is empty by default because only jars on the user's classpath should be on the boot classpath bootJars = Vector(), - workingDirectory = Some(baseDirectory.value), + workingDirectory = forkWorkingDirectory.value, runJVMOptions = javaOptions.value.toVector, connectInput = connectInput.value, envVars = envVars.value, diff --git a/main/src/main/scala/sbt/Keys.scala b/main/src/main/scala/sbt/Keys.scala index 0f16c5fca..68c64555d 100644 --- a/main/src/main/scala/sbt/Keys.scala +++ b/main/src/main/scala/sbt/Keys.scala @@ -354,6 +354,7 @@ object Keys { val fork = settingKey[Boolean]("If true, forks a new JVM when running. If false, runs in the same JVM as the build.").withRank(ASetting) val forkOptions = taskKey[ForkOptions]("Configures JVM forking.").withRank(DSetting) + val forkWorkingDirectory = settingKey[Option[File]]("Working directory of forked processes started by run, test, and console. If None, the forked process inherits the working directory of the sbt process.").withRank(BSetting) val outputStrategy = settingKey[Option[sbt.OutputStrategy]]("Selects how to log output when running a main class.").withRank(DSetting) val connectInput = settingKey[Boolean]("If true, connects standard input when running a main class forked.").withRank(CSetting) val javaHome = settingKey[Option[File]]("Selects the Java installation used for compiling and forking. If None, uses the Java installation running the build.").withRank(ASetting) diff --git a/main/src/main/scala/sbt/internal/Compiler.scala b/main/src/main/scala/sbt/internal/Compiler.scala index 020f9e7bc..49b9b8d08 100644 --- a/main/src/main/scala/sbt/internal/Compiler.scala +++ b/main/src/main/scala/sbt/internal/Compiler.scala @@ -621,6 +621,7 @@ object Compiler: val termEnv = sys.env.get("TERM").getOrElse("xterm-256color") ForkOptions() .withConnectInput(true) + .withWorkingDirectory((Keys.console / Keys.forkWorkingDirectory).value) .withRunJVMOptions( jo ++ Vector( s"-Dorg.jline.terminal.type=$termEnv", diff --git a/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala b/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala index 24d43b197..54c489d84 100644 --- a/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala +++ b/main/src/main/scala/sbt/internal/server/BuildServerProtocol.scala @@ -343,7 +343,7 @@ object BuildServerProtocol { JavacOptionsItem(target, javacOptions, classpath, classDirectory.toURI) }, bspBuildTargetJVMRunEnvironment := bspInputTask { (_, filter) => - val items = bspBuildTargetJvmEnvironmentItem.result.all(filter).value + val items = (run / bspBuildTargetJvmEnvironmentItem).result.all(filter).value val successfulItems = anyOrThrow(items) val result = JvmRunEnvironmentResult(successfulItems.toVector, None) state.value.respondEvent(result) @@ -354,7 +354,8 @@ object BuildServerProtocol { val result = JvmTestEnvironmentResult(successfulItems.toVector, None) state.value.respondEvent(result) }.evaluated, - bspBuildTargetJvmEnvironmentItem := jvmEnvironmentItem().value, + bspBuildTargetJvmEnvironmentItem := jvmEnvironmentItem(forkOptions).value, + run / bspBuildTargetJvmEnvironmentItem := jvmEnvironmentItem(run / forkOptions).value, bspInternalDependencyConfigurations := internalDependencyConfigurationsSetting.value, bspScalaTestClassesItem := scalaTestClassesTask.value, bspScalaMainClassesItem := scalaMainClassesTask.value, @@ -793,7 +794,12 @@ object BuildServerProtocol { Def.task(taskImpl(workspace, filter)) } - private def jvmEnvironmentItem(): Initialize[Task[JvmEnvironmentItem]] = Def.task { + private def bspEnvironmentVariables(opts: ForkOptions): Vector[String] = + opts.envVars.map { (k, v) => s"$k=$v" }.toVector + + private def jvmEnvironmentItem( + forkOptions: Initialize[Task[ForkOptions]] + ): Initialize[Task[JvmEnvironmentItem]] = Def.task { val target = Keys.bspTargetIdentifier.value val converter = fileConverter.value val classpath = Keys.fullClasspath.value @@ -801,16 +807,17 @@ object BuildServerProtocol { .map(converter.toPath) .map(_.toFile.toURI) .toVector - val jvmOptions = Keys.javaOptions.value.toVector - val baseDir = Keys.baseDirectory.value.getAbsolutePath - val env = envVars.value + val opts = forkOptions.value + val workingDir = opts.workingDirectory + .getOrElse(new File(sys.props("user.dir"))) + .getAbsolutePath JvmEnvironmentItem( target, classpath, - jvmOptions, - baseDir, - env + opts.runJVMOptions, + workingDir, + opts.envVars ) } @@ -947,7 +954,8 @@ object BuildServerProtocol { val json = jsonParser.parsed val runParams = json.flatMap(Converter.fromJson[RunParams]).get val defaultClass = Keys.mainClass.value - val defaultJvmOptions = Keys.javaOptions.value + val defaultOpts = (run / forkOptions).value + val defaultEnv = bspEnvironmentVariables(defaultOpts) val mainClass = runParams.dataKind match { case Some("scala-main-class") => @@ -959,9 +967,7 @@ object BuildServerProtocol { e.getMessage ) case Success(value) => - value.withEnvironmentVariables( - envVars.value.map { (k, v) => s"$k=$v" }.toVector ++ value.environmentVariables - ) + value.withEnvironmentVariables(defaultEnv ++ value.environmentVariables) } case Some(dataKind) => @@ -979,8 +985,8 @@ object BuildServerProtocol { ) ), runParams.arguments, - defaultJvmOptions.toVector, - envVars.value.map { (k, v) => s"$k=$v" }.toVector + defaultOpts.runJVMOptions, + defaultEnv ) } runMainClassTask(mainClass, runParams.originId) @@ -1038,21 +1044,18 @@ object BuildServerProtocol { val state = Keys.state.value val logger = Keys.streams.value.log val classpath = Attributed.data(fullClasspath.value) - val forkOpts = ForkOptions( - javaHome = javaHome.value, - outputStrategy = outputStrategy.value, - // bootJars is empty by default because only jars on the user's classpath should be on the boot classpath - bootJars = Vector(), - workingDirectory = Some(baseDirectory.value), - runJVMOptions = mainClass.jvmOptions, - connectInput = connectInput.value, - envVars = mainClass.environmentVariables - .flatMap(_.split("=", 2).toList match { - case key :: value :: Nil => Some(key -> value) - case _ => None - }) - .toMap - ) + // connectInput is disabled so non-interactive BSP output is captured as log messages + val forkOpts = (run / forkOptions).value + .withConnectInput(false) + .withRunJVMOptions(mainClass.jvmOptions) + .withEnvVars( + mainClass.environmentVariables + .flatMap(_.split("=", 2).toList match { + case key :: value :: Nil => Some(key -> value) + case _ => None + }) + .toMap + ) val runner = new ForkRun(forkOpts) val converter = fileConverter.value val cp = classpath.map(converter.toPath) @@ -1122,13 +1125,14 @@ object BuildServerProtocol { } private def scalaMainClassesTask: Initialize[Task[ScalaMainClassesItem]] = Def.task { - val jvmOptions = Keys.javaOptions.value.toVector + val opts = (run / forkOptions).value + val env = bspEnvironmentVariables(opts) val mainClasses = Keys.discoveredMainClasses.value.map( ScalaMainClass( _, Vector(), - jvmOptions, - envVars.value.map { (k, v) => s"$k=$v" }.toVector + opts.runJVMOptions, + env ) ) ScalaMainClassesItem( diff --git a/notes/2.0.0/fork-working-directory.md b/notes/2.0.0/fork-working-directory.md new file mode 100644 index 000000000..a7b148ce1 --- /dev/null +++ b/notes/2.0.0/fork-working-directory.md @@ -0,0 +1,23 @@ +### Forked run and test start in sbt's working directory + +Previously, forked `run` and `test` set the forked JVM's working directory to the +project's `baseDirectory`, while non-forked `run` and `test` executed in the directory +sbt itself was started from. In a multi-project build, toggling `fork` silently changed +the directory that relative paths resolved against. + +sbt 2.x makes forked `run`, `test`, and `console` inherit sbt's own working directory by +default, consistent with non-forked execution. A new setting, +`forkWorkingDirectory: Option[File]`, configures the working directory of forked +processes. To restore the sbt 1.x behavior: + +```scala +Test / forkWorkingDirectory := Some(baseDirectory.value) +run / forkWorkingDirectory := Some(baseDirectory.value) +``` + +The BSP `buildTarget/jvmRunEnvironment` and `buildTarget/jvmTestEnvironment` responses +report the same working directory so that IDEs follow the same contract. + +This addresses [#1032][i1032]. + +[i1032]: https://github.com/sbt/sbt/issues/1032 diff --git a/notes/2.0.0/migration.md b/notes/2.0.0/migration.md index 18dd31853..92acc32ec 100644 --- a/notes/2.0.0/migration.md +++ b/notes/2.0.0/migration.md @@ -1,5 +1,15 @@ +## Forked run/test working directory + +Forked `run` and `test` no longer run in the project's `baseDirectory`; they inherit +sbt's working directory, matching non-forked behavior. To restore the sbt 1.x behavior: + +```scala +run / forkWorkingDirectory := Some(baseDirectory.value) +Test / forkWorkingDirectory := Some(baseDirectory.value) +``` + ## files extension on Classpath ```scala diff --git a/sbt-app/src/sbt-test/run/fork/test b/sbt-app/src/sbt-test/run/fork/test index 773af0776..a6f9aee70 100644 --- a/sbt-app/src/sbt-test/run/fork/test +++ b/sbt-app/src/sbt-test/run/fork/test @@ -1,18 +1,29 @@ -> run fork +# non-forked run executes in sbt's working directory +> run $ exists flag $ delete flag -$ mkdir forked +# forked run inherits sbt's working directory by default, +# even when run / baseDirectory points elsewhere (#1032) > set fork := true > set run / baseDirectory := baseDirectory(_ / "forked").value +> run +$ exists flag +$ absent forked/flag +$ delete flag -> run forked +# forkWorkingDirectory restores forking in a specific directory +> session clear +> set fork := true +> set run / forkWorkingDirectory := Some(baseDirectory.value / "forked") +$ mkdir forked +> run $ exists forked/flag $ absent flag $ delete forked/flag > set envVars += ("flag.name" -> "env.flag") -> run forked +> run $ exists forked/env.flag $ absent flag $ absent forked/flag diff --git a/sbt-app/src/sbt-test/tests/fork-working-directory/build.sbt b/sbt-app/src/sbt-test/tests/fork-working-directory/build.sbt new file mode 100644 index 000000000..dbf929bb1 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-working-directory/build.sbt @@ -0,0 +1,11 @@ +val scalatest = "org.scalatest" %% "scalatest" % "3.2.19" + +ThisBuild / scalaVersion := "3.8.4" + +lazy val root = (project in file(".")) + +lazy val sub = project + .settings( + Test / fork := true, + libraryDependencies += scalatest % Test, + ) diff --git a/sbt-app/src/sbt-test/tests/fork-working-directory/changes/forkdir.sbt b/sbt-app/src/sbt-test/tests/fork-working-directory/changes/forkdir.sbt new file mode 100644 index 000000000..2e431967a --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-working-directory/changes/forkdir.sbt @@ -0,0 +1 @@ +Test / forkWorkingDirectory := Some(baseDirectory.value) diff --git a/sbt-app/src/sbt-test/tests/fork-working-directory/sub/src/test/scala/CwdSpec.scala b/sbt-app/src/sbt-test/tests/fork-working-directory/sub/src/test/scala/CwdSpec.scala new file mode 100644 index 000000000..79887d224 --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-working-directory/sub/src/test/scala/CwdSpec.scala @@ -0,0 +1,8 @@ +import org.scalatest.funsuite.AnyFunSuite + +class CwdSpec extends AnyFunSuite { + test("create marker in the forked working directory") { + val marker = new java.io.File("cwd-marker").getAbsoluteFile + assert(marker.createNewFile() || marker.exists()) + } +} diff --git a/sbt-app/src/sbt-test/tests/fork-working-directory/test b/sbt-app/src/sbt-test/tests/fork-working-directory/test new file mode 100644 index 000000000..142dc92ce --- /dev/null +++ b/sbt-app/src/sbt-test/tests/fork-working-directory/test @@ -0,0 +1,12 @@ +# a forked test's working directory is sbt's working directory by default (#1032) +> sub/testFull +$ exists cwd-marker +$ absent sub/cwd-marker +$ delete cwd-marker + +# Test / forkWorkingDirectory restores forking in the project directory +$ copy-file changes/forkdir.sbt sub/forkdir.sbt +> reload +> sub/testFull +$ exists sub/cwd-marker +$ absent cwd-marker diff --git a/server-test/src/server-test/buildserver/build.sbt b/server-test/src/server-test/buildserver/build.sbt index 62465c8e5..23f7b3803 100644 --- a/server-test/src/server-test/buildserver/build.sbt +++ b/server-test/src/server-test/buildserver/build.sbt @@ -9,6 +9,7 @@ lazy val runAndTest = project.in(file("run-and-test")) libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.8" % "test", Compile / javaOptions := Vector("Xmx256M"), Compile / envVars := Map("KEY" -> "VALUE"), + run / forkWorkingDirectory := Some(baseDirectory.value), Test / javaOptions := Vector("Xmx512M"), Test / envVars := Map("KEY_TEST" -> "VALUE_TEST"), diff --git a/server-test/src/test/scala/testpkg/BuildServerTest.scala b/server-test/src/test/scala/testpkg/BuildServerTest.scala index 679f83f0a..8624d2095 100644 --- a/server-test/src/test/scala/testpkg/BuildServerTest.scala +++ b/server-test/src/test/scala/testpkg/BuildServerTest.scala @@ -472,8 +472,12 @@ class BuildServerTest extends AbstractServerTest { val id = sendRequest("buildTarget/scalaMainClasses", ScalaMainClassesParams(targets, None)) val res = svr.session.waitForResultInResponseMsg[ScalaMainClassesResult](30.seconds, id).get - val classes = res.items.flatMap(_.classes.map(_.`class`)) - assert(classes.contains("main.Main")) + val mainClasses = res.items.flatMap(_.classes) + assert(mainClasses.map(_.`class`).contains("main.Main")) + // JVM options and environment are derived from run / forkOptions + val main = mainClasses.find(_.`class` == "main.Main").get + assert(main.jvmOptions.contains("Xmx256M")) + assert(main.environmentVariables.contains("KEY=VALUE")) } test("buildTarget/run") { @@ -509,7 +513,8 @@ class BuildServerTest extends AbstractServerTest { ) assert(item.jvmOptions.contains("Xmx256M")) assert(item.environmentVariables == Map("KEY" -> "VALUE")) - assert(item.workingDirectory.contains("/buildserver/run-and-test")) + // run / forkWorkingDirectory is honored for the run environment + assert(item.workingDirectory.endsWith("/run-and-test")) } test("buildTarget/jvmTestEnvironment") { @@ -529,6 +534,9 @@ class BuildServerTest extends AbstractServerTest { ) assert(item.jvmOptions.contains("Xmx512M")) assert(item.environmentVariables == Map("KEY_TEST" -> "VALUE_TEST")) + // with no Test / forkWorkingDirectory, the test environment inherits sbt's working directory + assert(item.workingDirectory.endsWith("/buildserver")) + assert(!item.workingDirectory.contains("run-and-test")) } test("buildTarget/scalaTestClasses") {