[2.0.x] fix: Make forked run inherit sbt's working directory (#9442)

forked run used the project's baseDirectory as the working directory, while non-forked execution inherits sbt's own working directory — so toggling fork silently changed how relative paths resolved.

forked run (and forked console) now inherit sbt's working directory, consistent with non-forked execution and `sbtn` expectations.
This commit is contained in:
Jozef Koval
2026-07-26 15:49:44 -04:00
committed by Eugene Yokota
parent 3155bde5ab
commit ff2a76e769
10 changed files with 135 additions and 41 deletions
+8 -3
View File
@@ -1345,6 +1345,7 @@ object Defaults extends BuildCommon {
)
)
}
def forkOptionsTask: Initialize[Task[ForkOptions]] =
Def.task {
val canUseArgumentsFile = sys.props
@@ -1363,6 +1364,10 @@ object Defaults extends BuildCommon {
)
}
/** Fork options for run-like tasks: the forked process inherits sbt's working directory. */
private[sbt] def runForkOptionsTask: Initialize[Task[ForkOptions]] =
Def.task(forkOptionsTask.value.withWorkingDirectory(None))
def testExecutionTask(task: Scoped): Initialize[Task[Tests.Execution]] =
Def.task {
new Tests.Execution(
@@ -2591,7 +2596,7 @@ object Defaults extends BuildCommon {
private lazy val newRunnerSettings: Seq[Setting[?]] =
Seq(
runner := Def.uncached(ClassLoaders.runner.value),
forkOptions := Def.uncached(forkOptionsTask.value)
forkOptions := Def.uncached(runForkOptionsTask.value)
)
lazy val baseTasks: Seq[Setting[?]] = projectTasks ++ packageBase
@@ -4950,7 +4955,7 @@ trait BuildExtra extends BuildCommon with DefExtra {
}
}
}.evaluated
) ++ inTask(scoped)((config / forkOptions) := Def.uncached(forkOptionsTask.value))
) ++ inTask(scoped)((config / forkOptions) := Def.uncached(runForkOptionsTask.value))
}
// public API
@@ -4972,7 +4977,7 @@ trait BuildExtra extends BuildCommon with DefExtra {
r.run(mainClass, cp.files, arguments, s.log).get
}
}.value
) ++ inTask(scoped)((config / forkOptions) := Def.uncached(forkOptionsTask.value))
) ++ inTask(scoped)((config / forkOptions) := Def.uncached(runForkOptionsTask.value))
def initScoped[T](sk: ScopedKey[?], i: Initialize[T]): Initialize[T] =
initScope(fillTaskAxis(sk.scope, sk.key), i)
@@ -327,7 +327,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)
@@ -338,7 +338,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,
@@ -770,7 +771,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
@@ -778,16 +784,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
)
}
@@ -898,7 +905,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") =>
@@ -910,9 +918,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) =>
@@ -930,8 +936,8 @@ object BuildServerProtocol {
)
),
runParams.arguments,
defaultJvmOptions.toVector,
envVars.value.map { (k, v) => s"$k=$v" }.toVector
defaultOpts.runJVMOptions,
defaultEnv
)
}
runMainClassTask(mainClass, runParams.originId)
@@ -989,21 +995,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)
@@ -1073,13 +1076,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(
+25
View File
@@ -0,0 +1,25 @@
### Forked run starts in sbt's working directory
Previously, forked `run` set the forked JVM's working directory to the project's
`baseDirectory`, while non-forked `run` 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` (and forked `console`) inherit sbt's own working
directory by default, consistent with non-forked execution and with `sbtn`
expectations. Forked `test` is unchanged and keeps the project's `baseDirectory`
as its working directory. The working directory of any forked process can be
configured via `forkOptions`:
```scala
Compile / run / forkOptions := Def.uncached(
(Compile / run / forkOptions).value.withWorkingDirectory(Some(baseDirectory.value))
)
```
The BSP `buildTarget/jvmRunEnvironment` response reports the same working
directory that `run` uses.
This addresses [#1032][i1032] for `run`.
[i1032]: https://github.com/sbt/sbt/issues/1032
+12
View File
@@ -1,5 +1,17 @@
## Forked run working directory
Forked `run` no longer runs in the project's `baseDirectory`; it inherits sbt's
working directory, matching non-forked behavior. Forked `test` is unchanged. To
restore the sbt 1.x behavior:
```scala
Compile / run / forkOptions := Def.uncached(
(Compile / run / forkOptions).value.withWorkingDirectory(Some(baseDirectory.value))
)
```
## files extension on Classpath
```scala
+16 -5
View File
@@ -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 baseDirectory in run := baseDirectory(_ / "forked").value
> set run / baseDirectory := baseDirectory(_ / "forked").value
> run
$ exists flag
$ absent forked/flag
$ delete flag
> run forked
# run / forkOptions configures the forked working directory
> session clear
> set fork := true
> set Compile / run / forkOptions := Def.uncached((Compile / run / forkOptions).value.withWorkingDirectory(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
@@ -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,
)
@@ -0,0 +1,3 @@
Test / forkOptions := Def.uncached(
(Test / forkOptions).value.withWorkingDirectory(Some((ThisBuild / baseDirectory).value))
)
@@ -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())
}
}
@@ -0,0 +1,12 @@
# a forked test's working directory remains the project's baseDirectory
> sub/testFull
$ exists sub/cwd-marker
$ absent cwd-marker
$ delete sub/cwd-marker
# Test / forkOptions configures the forked working directory
$ copy-file changes/forkdir.sbt sub/forkdir.sbt
> reload
> sub/testFull
$ exists cwd-marker
$ absent sub/cwd-marker
@@ -9,6 +9,9 @@ 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"),
Compile / run / forkOptions := Def.uncached(
(Compile / run / forkOptions).value.withWorkingDirectory(Some(baseDirectory.value))
),
Test / javaOptions := Vector("Xmx512M"),
Test / envVars := Map("KEY_TEST" -> "VALUE_TEST"),