Track generated .class files and clean leftovers after project load.

Fixes #1524

* Track generated .class files from Eval
* While loading, join all classfiles throughout Load, lots of bookkeeping.
* When a given build URI is done loading, we can look at its
  project/target/config-classes directory and clean out any extra items
  that are lingering from previous build definitions.
* Add TODOs to handle the same thing in global directories.  Right now,
  given the shared nature of these projects, it's a bit too dangerous to
  do so.
This commit is contained in:
Josh Suereth 2014-08-12 12:35:37 -04:00
parent 0bfb5a4118
commit a1b3117a42
9 changed files with 181 additions and 53 deletions

View File

@ -153,8 +153,8 @@ final class Eval(optionsNoncp: Seq[String], classpath: Seq[File], mkReporter: Se
finally { unlinkAll() }
}
val classFiles = getClassFiles(backing, moduleName)
new EvalIntermediate(extra, loader, classFiles, moduleName)
val generatedFiles = getGeneratedFiles(backing, moduleName)
new EvalIntermediate(extra, loader, generatedFiles, moduleName)
}
// location of the cached type or definition information
private[this] def cacheFile(base: File, moduleName: String): File = new File(base, moduleName + ".cache")
@ -254,11 +254,20 @@ final class Eval(optionsNoncp: Seq[String], classpath: Seq[File], mkReporter: Se
private[this] def classExists(dir: File, name: String) = (new File(dir, name + ".class")).exists
// TODO: use the code from Analyzer
private[this] def getGeneratedFiles(backing: Option[File], moduleName: String): Seq[File] =
backing match {
case None => Nil
case Some(dir) => dir listFiles moduleFileFilter(moduleName)
}
private[this] def getClassFiles(backing: Option[File], moduleName: String): Seq[File] =
backing match {
case None => Nil
case Some(dir) => dir listFiles moduleClassFilter(moduleName)
}
private[this] def moduleFileFilter(moduleName: String) = new java.io.FilenameFilter {
def accept(dir: File, s: String) =
(s contains moduleName)
}
private[this] def moduleClassFilter(moduleName: String) = new java.io.FilenameFilter {
def accept(dir: File, s: String) =
(s contains moduleName) && (s endsWith ".class")

View File

@ -60,7 +60,7 @@ val p = {
property("val test") = secure {
val defs = (ValTestContent, 1 to 7) :: Nil
val res = eval.evalDefinitions(defs, new EvalImports(Nil, ""), "<defs>", "scala.Int" :: Nil)
val res = eval.evalDefinitions(defs, new EvalImports(Nil, ""), "<defs>", None, "scala.Int" :: Nil)
label("Val names", res.valNames) |: (res.valNames.toSet == ValTestNames)
}

View File

@ -23,6 +23,11 @@ import scala.annotation.tailrec
*
*/
object EvaluateConfigurations {
type LazyClassLoaded[T] = ClassLoader => T
private[sbt] case class TrackedEvalResult[T](generated: Seq[File], result: LazyClassLoaded[T])
/**
* This represents the parsed expressions in a build sbt, as well as where they were defined.
*/
@ -38,7 +43,7 @@ object EvaluateConfigurations {
* raw sbt-types that can be accessed and used.
*/
@deprecated("We no longer merge build.sbt files together unless they are in the same directory.", "0.13.6")
def apply(eval: Eval, srcs: Seq[File], imports: Seq[String]): ClassLoader => LoadedSbtFile =
def apply(eval: Eval, srcs: Seq[File], imports: Seq[String]): LazyClassLoaded[LoadedSbtFile] =
{
val loadFiles = srcs.sortBy(_.getName) map { src => evaluateSbtFile(eval, src, IO.readLines(src), imports, 0) }
loader => (LoadedSbtFile.empty /: loadFiles) { (loaded, load) => loaded merge load(loader) }
@ -49,7 +54,7 @@ object EvaluateConfigurations {
*
* Note: This ignores any non-Setting[_] values in the file.
*/
def evaluateConfiguration(eval: Eval, src: File, imports: Seq[String]): ClassLoader => Seq[Setting[_]] =
def evaluateConfiguration(eval: Eval, src: File, imports: Seq[String]): LazyClassLoaded[Seq[Setting[_]]] =
evaluateConfiguration(eval, src, IO.readLines(src), imports, 0)
/**
@ -76,7 +81,7 @@ object EvaluateConfigurations {
*
* @return Just the Setting[_] instances defined in the .sbt file.
*/
def evaluateConfiguration(eval: Eval, file: File, lines: Seq[String], imports: Seq[String], offset: Int): ClassLoader => Seq[Setting[_]] =
def evaluateConfiguration(eval: Eval, file: File, lines: Seq[String], imports: Seq[String], offset: Int): LazyClassLoaded[Seq[Setting[_]]] =
{
val l = evaluateSbtFile(eval, file, lines, imports, offset)
loader => l(loader).settings
@ -93,7 +98,7 @@ object EvaluateConfigurations {
* @return A function which can take an sbt classloader and return the raw types/configuratoin
* which was compiled/parsed for the given file.
*/
private[sbt] def evaluateSbtFile(eval: Eval, file: File, lines: Seq[String], imports: Seq[String], offset: Int): ClassLoader => LoadedSbtFile =
private[sbt] def evaluateSbtFile(eval: Eval, file: File, lines: Seq[String], imports: Seq[String], offset: Int): LazyClassLoaded[LoadedSbtFile] =
{
// TODO - Store the file on the LoadedSbtFile (or the parent dir) so we can accurately do
// detection for which project project manipulations should be applied.
@ -112,13 +117,15 @@ object EvaluateConfigurations {
evaluateDslEntry(eval, name, allImports, dslExpression, range)
}
eval.unlinkDeferred()
// Tracks all the files we generated from evaluating the sbt file.
val allGeneratedFiles = (definitions.generated ++ dslEntries.flatMap(_.generated))
loader => {
val projects =
definitions.values(loader).collect {
case p: Project => resolveBase(file.getParentFile, p)
}
val (settingsRaw, manipulationsRaw) =
dslEntries map (_ apply loader) partition {
dslEntries map (_.result apply loader) partition {
case internals.ProjectSettings(_) => true
case _ => false
}
@ -130,7 +137,7 @@ object EvaluateConfigurations {
case internals.ProjectManipulation(f) => f
}
// TODO -get project manipulations.
new LoadedSbtFile(settings, projects, importDefs, manipulations, definitions)
new LoadedSbtFile(settings, projects, importDefs, manipulations, definitions, allGeneratedFiles)
}
}
/** move a project to be relative to this file after we've evaluated it. */
@ -161,18 +168,22 @@ object EvaluateConfigurations {
* @param range The original position in source of the expression, for error messages.
*
* @return A method that given an sbt classloader, can return the actual [[DslEntry]] defined by
* the expression.
* the expression, and the sequence of .class files generated.
*/
private[sbt] def evaluateDslEntry(eval: Eval, name: String, imports: Seq[(String, Int)], expression: String, range: LineRange): ClassLoader => internals.DslEntry = {
private[sbt] def evaluateDslEntry(eval: Eval, name: String, imports: Seq[(String, Int)], expression: String, range: LineRange): TrackedEvalResult[internals.DslEntry] = {
// TODO - Should we try to namespace these between.sbt files? IF they hash to the same value, they may actually be
// exactly the same setting, so perhaps we don't care?
val result = try {
eval.eval(expression, imports = new EvalImports(imports, name), srcName = name, tpeName = Some(SettingsDefinitionName), line = range.start)
} catch {
case e: sbt.compiler.EvalException => throw new MessageOnlyException(e.getMessage)
}
loader => {
val pos = RangePosition(name, range shift 1)
result.getValue(loader).asInstanceOf[internals.DslEntry].withPos(pos)
}
// TODO - keep track of configuration classes defined.
TrackedEvalResult(result.generated,
loader => {
val pos = RangePosition(name, range shift 1)
result.getValue(loader).asInstanceOf[internals.DslEntry].withPos(pos)
})
}
/**
@ -189,9 +200,9 @@ object EvaluateConfigurations {
* the expression.
*/
@deprecated("Build DSL now includes non-Setting[_] type settings.", "0.13.6") // Note: This method is used by the SET command, so we may want to evaluate that sucker a bit.
def evaluateSetting(eval: Eval, name: String, imports: Seq[(String, Int)], expression: String, range: LineRange): ClassLoader => Seq[Setting[_]] =
def evaluateSetting(eval: Eval, name: String, imports: Seq[(String, Int)], expression: String, range: LineRange): LazyClassLoaded[Seq[Setting[_]]] =
{
evaluateDslEntry(eval, name, imports, expression, range) andThen {
evaluateDslEntry(eval, name, imports, expression, range).result andThen {
case internals.ProjectSettings(values) => values
case _ => Nil
}

View File

@ -75,7 +75,13 @@ object Load {
{
val eval = mkEval(data(config.globalPluginClasspath), base, defaultEvalOptions)
val imports = BuildUtil.baseImports ++ BuildUtil.importAllRoot(config.globalPluginNames)
loader => EvaluateConfigurations(eval, files, imports)(loader).settings
loader => {
val loaded = EvaluateConfigurations(eval, files, imports)(loader)
// TODO - We have a potential leak of config-classes in the global directory right now.
// We need to find a way to clean these safely, or at least warn users about
// unused class files that could be cleaned when multiple sbt instances are not running.
loaded.settings
}
}
def loadGlobal(state: State, base: File, global: File, config: sbt.LoadBuildConfiguration): sbt.LoadBuildConfiguration =
if (base != global && global.exists) {
@ -238,8 +244,34 @@ object Load {
def mkEval(classpath: Seq[File], base: File, options: Seq[String]): Eval =
new Eval(options, classpath, s => new ConsoleReporter(s), Some(evalOutputDirectory(base)))
/**
* This will clean up left-over files in the config-classes directory if they are no longer used.
*
* @param base The base directory for the build, should match the one passed into `mkEval` method.
*/
def cleanEvalClasses(base: File, keep: Seq[File]): Unit = {
val baseTarget = evalOutputDirectory(base)
val keepSet = keep.map(_.getCanonicalPath).toSet
// If there are no keeper files, this may be because cache was up-to-date and
// the files aren't properly returned, even though they should be.
// TODO - figure out where the caching of whether or not to generate classfiles occurs, and
// put cleanups there, perhaps.
if (!keepSet.isEmpty) {
def keepFile(f: File) = keepSet(f.getCanonicalPath)
import Path._
val existing = (baseTarget.***.get).filterNot(_.isDirectory)
val toDelete = existing.filterNot(keepFile)
if (!toDelete.isEmpty) {
System.err.println(s"Discovered unused files: ${toDelete.mkString("\n * ", "\n * ", "\n")}. Note: Please reload all other running sbt instances in $base")
IO.delete(toDelete)
}
}
}
@deprecated("This method is no longer used", "0.13.6")
def configurations(srcs: Seq[File], eval: () => Eval, imports: Seq[String]): ClassLoader => LoadedSbtFile =
if (srcs.isEmpty) const(LoadedSbtFile.empty) else EvaluateConfigurations(eval(), srcs, imports)
if (srcs.isEmpty) const(LoadedSbtFile.empty)
else EvaluateConfigurations(eval(), srcs, imports)
def load(file: File, s: State, config: sbt.LoadBuildConfiguration): sbt.PartBuild =
load(file, builtinLoader(s, config.copy(pluginManagement = config.pluginManagement.shift, extraBuilds = Nil)), config.extraBuilds.toList)
@ -400,6 +432,7 @@ object Load {
val plugs = plugins(defDir, s, config.copy(pluginManagement = config.pluginManagement.forPlugin))
val defsScala = plugs.detected.builds.values
// NOTE - because we create an eval here, we need a clean-eval later for this URI.
lazy val eval = mkEval(plugs.classpath, defDir, plugs.pluginData.scalacOptions)
val initialProjects = defsScala.flatMap(b => projectsFromBuild(b, normBase))
@ -407,25 +440,29 @@ object Load {
val memoSettings = new mutable.HashMap[File, LoadedSbtFile]
def loadProjects(ps: Seq[Project], createRoot: Boolean) = {
loadTransitive(ps, normBase, plugs, () => eval, config.injectSettings, Nil, memoSettings, config.log, createRoot, uri, config.pluginManagement.context)
loadTransitive(ps, normBase, plugs, () => eval, config.injectSettings, Nil, memoSettings, config.log, createRoot, uri, config.pluginManagement.context, Nil)
}
val loadedProjectsRaw = loadProjects(initialProjects, !hasRootAlreadyDefined)
// TODO - As of sbt 0.13.6 we should always have a default root project from
// here on, so the autogenerated build aggregated can be removed from this code. ( I think)
// We may actually want to move it back here and have different flags in loadTransitive...
val hasRoot = loadedProjectsRaw.exists(_.base == normBase) || defsScala.exists(_.rootProject.isDefined)
val (loadedProjects, defaultBuildIfNone) =
val hasRoot = loadedProjectsRaw.projects.exists(_.base == normBase) || defsScala.exists(_.rootProject.isDefined)
val (loadedProjects, defaultBuildIfNone, keepClassFiles) =
if (hasRoot)
(loadedProjectsRaw, Build.defaultEmpty)
(loadedProjectsRaw.projects, Build.defaultEmpty, loadedProjectsRaw.generatedConfigClassFiles)
else {
val existingIDs = loadedProjectsRaw.map(_.id)
val existingIDs = loadedProjectsRaw.projects.map(_.id)
val refs = existingIDs.map(id => ProjectRef(uri, id))
val defaultID = autoID(normBase, config.pluginManagement.context, existingIDs)
val b = Build.defaultAggregated(defaultID, refs)
val defaultProjects = loadProjects(projectsFromBuild(b, normBase), false)
(defaultProjects ++ loadedProjectsRaw, b)
(defaultProjects.projects ++ loadedProjectsRaw.projects, b, defaultProjects.generatedConfigClassFiles ++ loadedProjectsRaw.generatedConfigClassFiles)
}
// Now we clean stale class files.
// TODO - this may cause issues with multiple sbt clients, but that should be deprecated pending sbt-server anyway
cleanEvalClasses(defDir, keepClassFiles)
val defs = if (defsScala.isEmpty) defaultBuildIfNone :: Nil else defsScala
// HERE we pull out the defined vals from memoSettings and unify them all so
// we can use them later.
@ -458,6 +495,8 @@ object Load {
private[this] def projectsFromBuild(b: Build, base: File): Seq[Project] =
b.projectDefinitions(base).map(resolveBase(base))
// Lame hackery to keep track of our state.
private[this] case class LoadedProjects(projects: Seq[Project], generatedConfigClassFiles: Seq[File])
/**
* Loads a new set of projects, including any transitively defined projects underneath this one.
*
@ -498,10 +537,12 @@ object Load {
log: Logger,
makeOrDiscoverRoot: Boolean,
buildUri: URI,
context: PluginManagement.Context): Seq[Project] =
context: PluginManagement.Context,
generatedConfigClassFiles: Seq[File]): LoadedProjects =
{
// load all relevant configuration files (.sbt, as .scala already exists at this point)
def discover(auto: AddSettings, base: File): DiscoveredProjects = discoverProjects(auto, base, plugins, eval, memoSettings)
def discover(auto: AddSettings, base: File): DiscoveredProjects =
discoverProjects(auto, base, plugins, eval, memoSettings)
// Step two, Finalize a project with all its settings/configuration.
def finalizeProject(p: Project, configFiles: Seq[File]): Project = {
val loadedFiles = configFiles flatMap { f => memoSettings.get(f) }
@ -509,48 +550,49 @@ object Load {
}
// Discover any new project definition for the base directory of this project, and load all settings.
// Also return any newly discovered project instances.
def discoverAndLoad(p: Project): (Project, Seq[Project]) = {
val (root, discovered, files) = discover(p.auto, p.base) match {
case DiscoveredProjects(Some(root), rest, files) =>
def discoverAndLoad(p: Project): (Project, Seq[Project], Seq[File]) = {
val (root, discovered, files, generated) = discover(p.auto, p.base) match {
case DiscoveredProjects(Some(root), rest, files, generated) =>
// TODO - We assume here the project defined in a build.sbt WINS because the original was
// a phony. However, we may want to 'merge' the two, or only do this if the original was a default
// generated project.
(root, rest, files)
case DiscoveredProjects(None, rest, files) => (p, rest, files)
(root, rest, files, generated)
case DiscoveredProjects(None, rest, files, generated) => (p, rest, files, generated)
}
val finalRoot = finalizeProject(root, files)
finalRoot -> discovered
(finalRoot, discovered, generated)
}
// Load all config files AND finalize the project at the root directory, if it exists.
// Continue loading if we find any more.
newProjects match {
case Seq(next, rest @ _*) =>
log.debug(s"[Loading] Loading project ${next.id} @ ${next.base}")
val (finished, discovered) = discoverAndLoad(next)
loadTransitive(rest ++ discovered, buildBase, plugins, eval, injectSettings, acc :+ finished, memoSettings, log, false, buildUri, context)
val (finished, discovered, generated) = discoverAndLoad(next)
loadTransitive(rest ++ discovered, buildBase, plugins, eval, injectSettings, acc :+ finished, memoSettings, log, false, buildUri, context, generated)
case Nil if makeOrDiscoverRoot =>
log.debug(s"[Loading] Scanning directory ${buildBase}")
discover(AddSettings.defaultSbtFiles, buildBase) match {
case DiscoveredProjects(Some(root), discovered, files) =>
case DiscoveredProjects(Some(root), discovered, files, generated) =>
log.debug(s"[Loading] Found root project ${root.id} w/ remaining ${discovered.map(_.id).mkString(",")}")
val finalRoot = finalizeProject(root, files)
loadTransitive(discovered, buildBase, plugins, eval, injectSettings, finalRoot +: acc, memoSettings, log, false, buildUri, context)
loadTransitive(discovered, buildBase, plugins, eval, injectSettings, finalRoot +: acc, memoSettings, log, false, buildUri, context, generated)
// Here we need to create a root project...
case DiscoveredProjects(None, discovered, files) =>
case DiscoveredProjects(None, discovered, files, generated) =>
log.debug(s"[Loading] Found non-root projects ${discovered.map(_.id).mkString(",")}")
// Here we do something interesting... We need to create an aggregate root project
val otherProjects = loadTransitive(discovered, buildBase, plugins, eval, injectSettings, acc, memoSettings, log, false, buildUri, context)
val existingIds = otherProjects map (_.id)
val otherProjects = loadTransitive(discovered, buildBase, plugins, eval, injectSettings, acc, memoSettings, log, false, buildUri, context, Nil)
val otherGenerated = otherProjects.generatedConfigClassFiles
val existingIds = otherProjects.projects map (_.id)
val refs = existingIds map (id => ProjectRef(buildUri, id))
val defaultID = autoID(buildBase, context, existingIds)
val root = finalizeProject(Build.defaultAggregatedProject(defaultID, buildBase, refs), files)
val result = root +: (acc ++ otherProjects)
val result = root +: (acc ++ otherProjects.projects)
log.debug(s"[Loading] Done in ${buildBase}, returning: ${result.map(_.id).mkString("(", ", ", ")")}")
result
LoadedProjects(result, generated ++ otherGenerated)
}
case Nil =>
log.debug(s"[Loading] Done in ${buildBase}, returning: ${acc.map(_.id).mkString("(", ", ", ")")}")
acc
LoadedProjects(acc, generatedConfigClassFiles)
}
}
@ -564,8 +606,13 @@ object Load {
* @param root The project at "root" directory we were looking, or non if non was defined.
* @param nonRoot Any sub-projects discovered from this directory
* @param sbtFiles Any sbt file loaded during this discovery (used later to complete the project).
* @param generatedFile Any .class file that was generated when compiling/discovering these projects.
*/
private[this] case class DiscoveredProjects(root: Option[Project], nonRoot: Seq[Project], sbtFiles: Seq[File])
private[this] case class DiscoveredProjects(
root: Option[Project],
nonRoot: Seq[Project],
sbtFiles: Seq[File],
generatedFiles: Seq[File])
/**
* This method attempts to resolve/apply all configuration loaded for a project. It is responsible for the following:
@ -682,10 +729,11 @@ object Load {
case _ => Seq.empty
}
val rawFiles = associatedFiles(auto)
val rawProjects = loadFiles(rawFiles).projects
val loadedFiles = loadFiles(rawFiles)
val rawProjects = loadedFiles.projects
val (root, nonRoot) = rawProjects.partition(_.base == projectBase)
// TODO - good error message if more than one root project
DiscoveredProjects(root.headOption, nonRoot, rawFiles)
DiscoveredProjects(root.headOption, nonRoot, rawFiles, loadedFiles.generatedFiles)
}
def globalPluginClasspath(globalPlugin: Option[GlobalPlugin]): Seq[Attributed[File]] =

View File

@ -14,17 +14,19 @@ private[sbt] final class LoadedSbtFile(
val manipulations: Seq[Project => Project],
// TODO - we may want to expose a simpler interface on top of here for the set command,
// rather than what we have now...
val definitions: DefinedSbtValues) {
@deprecated("LoadedSbtFiles are no longer directly merged.", "0.13.6")
val definitions: DefinedSbtValues,
val generatedFiles: Seq[File]) {
// We still use merge for now. We track originating sbt file in an alternative manner.
def merge(o: LoadedSbtFile): LoadedSbtFile =
new LoadedSbtFile(
settings ++ o.settings,
projects ++ o.projects,
importedDefs ++ o.importedDefs,
manipulations,
definitions zip o.definitions)
definitions zip o.definitions,
generatedFiles ++ o.generatedFiles)
def clearProjects = new LoadedSbtFile(settings, Nil, importedDefs, manipulations, definitions)
def clearProjects = new LoadedSbtFile(settings, Nil, importedDefs, manipulations, definitions, generatedFiles)
}
/**
@ -74,6 +76,6 @@ private[sbt] object DefinedSbtValues {
private[sbt] object LoadedSbtFile {
/** Represents an empty .sbt file: no Projects, imports, or settings.*/
def empty = new LoadedSbtFile(Nil, Nil, Nil, Nil, DefinedSbtValues.empty)
def empty = new LoadedSbtFile(Nil, Nil, Nil, Nil, DefinedSbtValues.empty, Nil)
}

View File

@ -45,6 +45,8 @@
[1489]: https://github.com/sbt/sbt/pull/1489
[1494]: https://github.com/sbt/sbt/pull/1494
[1516]: https://github.com/sbt/sbt/pull/1516
[1465]: https://github.com/sbt/sbt/issues/1465
[1524]: https://github.com/sbt/sbt/issues/1524
[@dansanduleac]: https://github.com/dansanduleac
[@2m]: https://github.com/2m
@ -104,6 +106,8 @@
- set no longer removes any `++` scala version setting. [#856][856]/[#1489][1489] by [@jsuereth][@jsuereth]
- Fixes `Scope.parseScopedKey`. [#1384][1384] by [@eed3si9n][@eed3si9n]
- Fixes `build.sbt` errors causing `ArrayIndexOutOfBoundsException` due to invalid source in position. [#1181][1181] by [@eed3si9n][@eed3si9n]
- Fixes config-classes leak in loading build files. [#1524][1524] by [@jsuereth][@jsuereth]
- Fixes name-conflicts in hashed settings class files. [#1465][1465] by [@jsuereth][@jsuereth]
### Maven Central Repository defaults to HTTPS

View File

@ -2,4 +2,48 @@ lazy val common = project
lazy val boink = project
lazy val woof = project
lazy val woof = project
lazy val numConfigClasses = taskKey[Int]("counts number of config classes")
lazy val configClassCountFile = settingKey[File]("File where we write the # of config classes")
lazy val saveNumConfigClasses = taskKey[Unit]("Saves the number of config clases")
lazy val checkNumConfigClasses = taskKey[Unit]("Checks the number of config clases")
lazy val checkDifferentConfigClasses = taskKey[Unit]("Checks that the number of config classes are different.")
configClassCountFile := (target.value / "config-count")
numConfigClasses := {
val cdir = (baseDirectory in ThisBuild).value / "project/target/config-classes"
(cdir.*** --- cdir).get.length
}
saveNumConfigClasses := {
IO.write(configClassCountFile.value, numConfigClasses.value.toString)
}
def previousConfigCount = Def.task {
val previousString = IO.read(configClassCountFile.value)
try Integer.parseInt(previousString)
catch {
case t: Throwable => throw new RuntimeException(s"Failed to parse previous config file value: $previousString", t)
}
}
checkDifferentConfigClasses := {
val previousString = IO.read(configClassCountFile.value)
val previous = previousConfigCount.value
val current = numConfigClasses.value
assert(previous != current, s"Failed to create new configuration classes. Expected: $previous, Found: $current")
}
checkNumConfigClasses := {
val previousString = IO.read(configClassCountFile.value)
val previous = previousConfigCount.value
val current = numConfigClasses.value
assert(previous == current, s"Failed to delete extra configuration classes. Expected: $previous, Found: $current")
}

View File

@ -0,0 +1,3 @@
organization := "more"
version := "settings"

View File

@ -1 +1,8 @@
> compile
> compile
> saveNumConfigClasses
$ copy-file changes/extras.sbt extras.sbt
> reload
> checkDifferentConfigClasses
$ delete extras.sbt
> reload
> checkNumConfigClasses