Merge pull request #8706 from eed3si9n/bport/restore-cli-ops

[1.x] fix: restore CLI precedence over .sbtopts
This commit is contained in:
eugene yokota 2026-02-07 02:35:31 -05:00 committed by GitHub
commit 8feb6463d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 173 additions and 89 deletions

View File

@ -194,4 +194,59 @@ object RunnerScriptTest extends verify.BasicTestSuite with ShellScriptUtil:
s"Machine config should appear before project config. machineIndex=$machineIndex, projectIndex=$projectIndex"
)
testOutput(
"command line options override project .sbtopts",
sbtOptsFileContents =
"-J-Xmx2g\n-J-XX:ReservedCodeCacheSize=1g\n-J-XX:MaxMetaspaceSize=2g\n-J-Xss512m\n-J-XX:+UseG1GC"
)("-d", "-v", "-mem", "12288"): (out: List[String]) =>
if (isWindows) cancel("Test not supported on windows")
else
val cmdLineStart = out.indexWhere(_.contains("Executing command line"))
assert(cmdLineStart >= 0, "Command line section not found")
val cmdLine = out.drop(cmdLineStart + 1).takeWhile(!_.trim.isEmpty)
val xmxCliIndex = cmdLine.indexWhere(_.contains("-Xmx12288m"))
val xmxSbtoptsIndex = cmdLine.indexWhere(_.contains("-Xmx2g"))
val g1Index = cmdLine.indexWhere(_.contains("-XX:+UseG1GC"))
assert(xmxCliIndex >= 0, "CLI memory setting not found in command line")
assert(xmxSbtoptsIndex < 0, "sbtopts -Xmx2g should be overridden by CLI")
assert(g1Index >= 0, "sbtopts non-memory option not found in command line")
assert(
g1Index < xmxCliIndex,
s"sbtopts options should appear before CLI memory settings. g1Index=$g1Index, xmxCliIndex=$xmxCliIndex"
)
// Test for issue #7289: Special characters in .jvmopts should not cause shell expansion
testOutput(
"sbt with special characters in .jvmopts (pipes, wildcards, ampersands)",
jvmoptsFileContents =
"-Dtest.pipes=host1|host2|host3\n-Dtest.wildcards=path/*/pattern\n-Dtest.ampersand=value&other",
windowsSupport = false,
)("-v"): (out: List[String]) =>
// Verify that properties with special characters are handled correctly
// The pipe characters should be treated literally, not as shell operators
assert(
out.contains[String]("-Dtest.pipes=host1|host2|host3"),
"Property with pipes should be handled correctly"
)
assert(
out.contains[String]("-Dtest.wildcards=path/*/pattern"),
"Property with wildcards should be handled correctly"
)
assert(
out.contains[String]("-Dtest.ampersand=value&other"),
"Property with ampersands should be handled correctly"
)
// Verify no shell errors occurred (no "command not found" messages or "unexpected" errors)
val errorMessages = out.filter(line =>
line.contains("command not found") ||
line.contains("was unexpected at this time") ||
line.contains("syntax error")
)
assert(
errorMessages.isEmpty,
s"Should not have shell expansion errors, but found: ${errorMessages.mkString(", ")}"
)
end RunnerScriptTest

View File

@ -35,96 +35,116 @@ trait ShellScriptUtil extends BasicTestSuite {
sbtOptsFileContents: String = "",
javaToolOptions: String = "",
distSbtoptsContents: String = "",
machineSbtoptsContents: String = ""
machineSbtoptsContents: String = "",
jvmoptsFileContents: String = "",
windowsSupport: Boolean = true,
)(args: String*)(f: List[String] => Any) =
test(name) {
val workingDirectory = Files.createTempDirectory("sbt-launcher-package-test").toFile
retry(() => IO.copyDirectory(new File("launcher-package/citest"), workingDirectory))
if !windowsSupport && isWindows then
test(name):
cancel("test not supported on Windows")
else
test(name) {
val workingDirectory = Files.createTempDirectory("sbt-launcher-package-test").toFile
retry(() => IO.copyDirectory(new File("launcher-package/citest"), workingDirectory))
var sbtHome: Option[File] = None
var configHome: Option[File] = None
var tempSbtHome: Option[File] = None
var testSbtScript: File = sbtScript
try
val sbtOptsFile = new File(workingDirectory, ".sbtopts")
sbtOptsFile.createNewFile()
val writer = new PrintWriter(sbtOptsFile)
try {
writer.write(sbtOptsFileContents)
} finally {
writer.close()
}
var sbtHome: Option[File] = None
var configHome: Option[File] = None
var tempSbtHome: Option[File] = None
var testSbtScript: File = sbtScript
try
val sbtOptsFile = new File(workingDirectory, ".sbtopts")
sbtOptsFile.createNewFile()
val writer = new PrintWriter(sbtOptsFile)
try {
writer.write(sbtOptsFileContents)
} finally {
writer.close()
}
val envVars = scala.collection.mutable.Map[String, String]()
// Create .jvmopts file if contents provided
if (jvmoptsFileContents.nonEmpty) {
val jvmoptsFile = new File(workingDirectory, ".jvmopts")
jvmoptsFile.createNewFile()
val jvmoptsWriter = new PrintWriter(jvmoptsFile)
try {
jvmoptsWriter.write(jvmoptsFileContents)
} finally {
jvmoptsWriter.close()
}
}
// Set up dist sbtopts if provided
// Note: sbt script derives sbt_home from script location, not SBT_HOME env var
// Copy the sbt staging directory to a temp location to avoid modifying the staging directory
if (distSbtoptsContents.nonEmpty) {
val originalSbtHome = sbtScript.getParentFile.getParentFile
val tempSbtHomeDir = Files.createTempDirectory("sbt-home-test").toFile
tempSbtHome = Some(tempSbtHomeDir)
// Copy the entire sbt home directory structure
retry(() => IO.copyDirectory(originalSbtHome, tempSbtHomeDir))
// Get the script from the copied directory
val binDir = new File(tempSbtHomeDir, "bin")
testSbtScript = new File(binDir, sbtScript.getName)
// Create dist sbtopts in the copied directory
val distSbtoptsDir = new File(tempSbtHomeDir, "conf")
distSbtoptsDir.mkdirs()
val distSbtoptsFile = new File(distSbtoptsDir, "sbtopts")
IO.write(distSbtoptsFile, distSbtoptsContents)
// Store reference for cleanup
sbtHome = Some(tempSbtHomeDir)
}
val envVars = scala.collection.mutable.Map[String, String]()
// Ensure no machine sbtopts exists when testing dist-only (unless explicitly provided)
// The script only loads dist if machine doesn't exist
if (distSbtoptsContents.nonEmpty && machineSbtoptsContents.isEmpty && configHome.isEmpty) {
// Set XDG_CONFIG_HOME to a temp directory without sbtopts to prevent default machine sbtopts from being found
val emptyConfigHome = Files.createTempDirectory("empty-config-home").toFile
envVars("XDG_CONFIG_HOME") = emptyConfigHome.getAbsolutePath
// Also unset SBT_ETC_FILE if it exists
sys.env.get("SBT_ETC_FILE").foreach(_ => envVars("SBT_ETC_FILE") = "")
// Store for cleanup
configHome = Some(emptyConfigHome)
}
// Set up dist sbtopts if provided
// Note: sbt script derives sbt_home from script location, not SBT_HOME env var
// Copy the sbt staging directory to a temp location to avoid modifying the staging directory
if (distSbtoptsContents.nonEmpty) {
val originalSbtHome = sbtScript.getParentFile.getParentFile
val tempSbtHomeDir = Files.createTempDirectory("sbt-home-test").toFile
tempSbtHome = Some(tempSbtHomeDir)
// Copy the entire sbt home directory structure
retry(() => IO.copyDirectory(originalSbtHome, tempSbtHomeDir))
// Get the script from the copied directory
val binDir = new File(tempSbtHomeDir, "bin")
testSbtScript = new File(binDir, sbtScript.getName)
// Create dist sbtopts in the copied directory
val distSbtoptsDir = new File(tempSbtHomeDir, "conf")
distSbtoptsDir.mkdirs()
val distSbtoptsFile = new File(distSbtoptsDir, "sbtopts")
IO.write(distSbtoptsFile, distSbtoptsContents)
// Store reference for cleanup
sbtHome = Some(tempSbtHomeDir)
}
// Set up machine sbtopts if provided
if (machineSbtoptsContents.nonEmpty) {
val configHomeDir = Files.createTempDirectory("config-home").toFile
configHome = Some(configHomeDir)
val machineSbtoptsDir = new File(configHomeDir, "sbt")
machineSbtoptsDir.mkdirs()
val machineSbtoptsFile = new File(machineSbtoptsDir, "sbtopts")
IO.write(machineSbtoptsFile, machineSbtoptsContents)
envVars("XDG_CONFIG_HOME") = configHomeDir.getAbsolutePath
}
// Ensure no machine sbtopts exists when testing dist-only (unless explicitly provided)
// The script only loads dist if machine doesn't exist
if (
distSbtoptsContents.nonEmpty && machineSbtoptsContents.isEmpty && configHome.isEmpty
) {
// Set XDG_CONFIG_HOME to a temp directory without sbtopts to prevent default machine sbtopts from being found
val emptyConfigHome = Files.createTempDirectory("empty-config-home").toFile
envVars("XDG_CONFIG_HOME") = emptyConfigHome.getAbsolutePath
// Also unset SBT_ETC_FILE if it exists
sys.env.get("SBT_ETC_FILE").foreach(_ => envVars("SBT_ETC_FILE") = "")
// Store for cleanup
configHome = Some(emptyConfigHome)
}
val path = sys.env.getOrElse("PATH", sys.env("Path"))
envVars("JAVA_OPTS") = javaOpts
envVars("SBT_OPTS") = sbtOpts
envVars("JAVA_TOOL_OPTIONS") = javaToolOptions
if (isWindows)
envVars("JAVACMD") = new File(javaBinDir, "java").getAbsolutePath()
else
envVars("PATH") = javaBinDir + File.pathSeparator + path
// Set up machine sbtopts if provided
if (machineSbtoptsContents.nonEmpty) {
val configHomeDir = Files.createTempDirectory("config-home").toFile
configHome = Some(configHomeDir)
val machineSbtoptsDir = new File(configHomeDir, "sbt")
machineSbtoptsDir.mkdirs()
val machineSbtoptsFile = new File(machineSbtoptsDir, "sbtopts")
IO.write(machineSbtoptsFile, machineSbtoptsContents)
envVars("XDG_CONFIG_HOME") = configHomeDir.getAbsolutePath
}
val out = scala.sys.process
.Process(
Seq(testSbtScript.getAbsolutePath) ++ args,
workingDirectory,
envVars.toSeq*
)
.!!
.linesIterator
.toList
f(out)
()
finally
IO.delete(workingDirectory)
// Clean up temporary sbt home directory if we created one
tempSbtHome.foreach(IO.delete)
configHome.foreach(IO.delete)
}
val path = sys.env.getOrElse("PATH", sys.env("Path"))
envVars("JAVA_OPTS") = javaOpts
envVars("SBT_OPTS") = sbtOpts
envVars("JAVA_TOOL_OPTIONS") = javaToolOptions
if (isWindows)
envVars("JAVACMD") = new File(javaBinDir, "java").getAbsolutePath()
else
envVars("PATH") = javaBinDir + File.pathSeparator + path
val out = scala.sys.process
.Process(
Seq(testSbtScript.getAbsolutePath) ++ args,
workingDirectory,
envVars.toSeq*
)
.!!
.linesIterator
.toList
f(out)
()
finally
IO.delete(workingDirectory)
// Clean up temporary sbt home directory if we created one
tempSbtHome.foreach(IO.delete)
configHome.foreach(IO.delete)
}
}

19
sbt
View File

@ -767,8 +767,11 @@ process_args () {
loadConfigFile() {
# Make sure the last line is read even if it doesn't have a terminating \n
# Output lines literally without shell expansion to handle special characters safely
cat "$1" | sed $'/^\#/d;s/\r$//' | while read -r line || [[ -n "$line" ]]; do
eval echo $line
# Use printf with properly quoted variable to prevent shell expansion
# This safely handles special characters like |, *, &, etc.
printf '%s\n' "$line"
done
}
@ -853,17 +856,23 @@ runNativeClient() {
original_args=("$@")
sbt_file_opts=()
# Pull in the machine-wide settings configuration.
if [[ -f "$machine_sbt_opts_file" ]]; then
set -- "$@" $(loadConfigFile "$machine_sbt_opts_file")
sbt_file_opts+=($(loadConfigFile "$machine_sbt_opts_file"))
else
# Otherwise pull in the default settings configuration.
[[ -f "$dist_sbt_opts_file" ]] && set -- "$@" $(loadConfigFile "$dist_sbt_opts_file")
[[ -f "$dist_sbt_opts_file" ]] && sbt_file_opts+=($(loadConfigFile "$dist_sbt_opts_file"))
fi
# Pull in the project-level config file, if it exists (highest priority, overrides machine/dist).
# Append so it appears last in command line and wins for duplicate properties.
[[ -f "$sbt_opts_file" ]] && set -- "$@" $(loadConfigFile "$sbt_opts_file")
[[ -f "$sbt_opts_file" ]] && sbt_file_opts+=($(loadConfigFile "$sbt_opts_file"))
# Prepend sbtopts so command line args appear last and win for duplicate properties.
if (( ${#sbt_file_opts[@]} > 0 )); then
set -- "${sbt_file_opts[@]}" "$@"
fi
# Pull in the project-level java config, if it exists.
[[ -f ".jvmopts" ]] && export JAVA_OPTS="$JAVA_OPTS $(loadConfigFile .jvmopts)"