Repository: scala-steward-org/scala-steward
Pull request #1875 (2021-01-11 10:02:11)

Feature multi project support
This is a much simpler attemt to get support for multiple projects within a mono repo based on the feedback here:

https://github.com/scala-steward-org/scala-steward/pull/1093#issuecomment-692246532

Currently it's still missing tests since I haven't had a good idea how to do those without unnecessary codebloat.


Patch:
diff --git a/.gitignore b/.gitignore
index f52a4a46..6c454dc1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,10 @@ workspace/
 metals.sbt
 
 .bsp/
+
+
+#Workspace Tooling
+.envrc
+nix
+shell.nix
+
diff --git a/README.md b/README.md
index 34d79931..6cca7208 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,7 @@ Thanks goes to these wonderful people for contributing to Scala Steward:
 * [Daniel Pfeiffer](https://github.com/dpfeiffer)
 * [Daniel Spiewak](https://github.com/djspiewak)
 * [David Francoeur](https://github.com/daddykotex)
+* [Dominic Egger](https://github.com/GrafBlutwurst)
 * [Don Smith III](https://github.com/cactauz)
 * [Doug Roper](https://github.com/htmldoug)
 * [Eldar Yusupov](https://github.com/eyusupov)
@@ -163,6 +164,7 @@ Consider creating PR to add your company to the list and join the community.
 * [PlayQ](https://www.playq.com/)
 * [Precog](https://precog.com/)
 * [Rewards Network](https://www.rewardsnetwork.com/)
+* [Rivero](https://rivero.tech/)
 * [Septeni Original](https://www.septeni-original.co.jp)
 * [Snowplow Analytics](https://snowplowanalytics.com/)
 * [SoftwareMill](https://softwaremill.com)
diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md
index c525b5bb..bb2fe0e5 100644
--- a/docs/repo-specific-configuration.md
+++ b/docs/repo-specific-configuration.md
@@ -86,6 +86,11 @@ commits.message = "Update ${artifactName} from ${currentVersion} to ${nextVersio
 # If false, Scala Steward will not perform scalafmt, so your CI may abort when reformat needed.
 # Default: true
 scalafmt.runAfterUpgrading = false
+
+# It is possible to have multiple scala projects in a single repository. In that case the folders containing the projects (build.sbt folders)
+# are specified using the buildRoots property. Note that the paths used there are relative and if the repo directory itself also contains a build.sbt the dot can be used to specify it.
+# Default: ["."]
+buildRoots = [ ".", "subfolder/projectA" ]
 ```
 
 The version information given in the patterns above can be in two formats:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolAlg.scala
index af0ff3b1..6ff362f1 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolAlg.scala
@@ -19,12 +19,11 @@ package org.scalasteward.core.buildtool
 import org.scalasteward.core.data.Scope
 import org.scalasteward.core.scalafix.Migration
 import org.scalasteward.core.util.Nel
-import org.scalasteward.core.vcs.data.Repo
 
-trait BuildToolAlg[F[_]] {
-  def containsBuild(repo: Repo): F[Boolean]
+trait BuildToolAlg[F[_], R] {
+  def containsBuild(r: R): F[Boolean]
 
-  def getDependencies(repo: Repo): F[List[Scope.Dependencies]]
+  def getDependencies(r: R): F[List[Scope.Dependencies]]
 
-  def runMigrations(repo: Repo, migrations: Nel[Migration]): F[Unit]
+  def runMigrations(r: R, migrations: Nel[Migration]): F[Unit]
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala
index 48fcc9a1..2e4b8433 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/BuildToolDispatcher.scala
@@ -26,8 +26,10 @@ import org.scalasteward.core.scalafix.Migration
 import org.scalasteward.core.scalafmt.ScalafmtAlg
 import org.scalasteward.core.util.Nel
 import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.repoconfig.RepoConfigAlg
+import org.scalasteward.core.vcs.data.BuildRoot
 
-trait BuildToolDispatcher[F[_]] extends BuildToolAlg[F]
+trait BuildToolDispatcher[F[_]] extends BuildToolAlg[F, Repo]
 
 object BuildToolDispatcher {
   def create[F[_]](implicit
@@ -35,33 +37,56 @@ object BuildToolDispatcher {
       millAlg: MillAlg[F],
       sbtAlg: SbtAlg[F],
       scalafmtAlg: ScalafmtAlg[F],
+      repoConfigAlg: RepoConfigAlg[F],
       F: Monad[F]
   ): BuildToolDispatcher[F] = {
     val allBuildTools = List(mavenAlg, millAlg, sbtAlg)
     val fallbackBuildTool = sbtAlg
 
     new BuildToolDispatcher[F] {
+
+      private def buildRootsForRepo(repo: Repo): F[List[BuildRoot]] = for {
+        repoConfigOpt <- repoConfigAlg.readRepoConfig(repo)
+        repoConfig <- repoConfigAlg.mergeWithDefault(repoConfigOpt)
+        buildRoots = repoConfig.buildRoots.map(config =>
+          BuildRoot(repo, config.relativeBuildRootPath)
+        )
+      } yield buildRoots
+
       override def containsBuild(repo: Repo): F[Boolean] =
-        allBuildTools.existsM(_.containsBuild(repo))
+        buildRootsForRepo(repo).flatMap(buildRoots =>
+          buildRoots.existsM(buildRoot => allBuildTools.existsM(_.containsBuild(buildRoot)))
+        )
 
       override def getDependencies(repo: Repo): F[List[Scope.Dependencies]] =
         for {
-          dependencies <- foundBuildTools(repo).flatMap(_.flatTraverse(_.getDependencies(repo)))
-          additionalDependencies <- getAdditionalDependencies(repo)
-        } yield Scope.combineByResolvers(additionalDependencies ::: dependencies)
+          buildRoots <- buildRootsForRepo(repo)
+          result <- buildRoots.flatTraverse(buildRoot =>
+            for {
+              dependencies <- foundBuildTools(buildRoot).flatMap(
+                _.flatTraverse(_.getDependencies(buildRoot))
+              )
+              additionalDependencies <- getAdditionalDependencies(buildRoot)
+            } yield Scope.combineByResolvers(additionalDependencies ::: dependencies)
+          )
+        } yield result
 
       override def runMigrations(repo: Repo, migrations: Nel[Migration]): F[Unit] =
-        foundBuildTools(repo).flatMap(_.traverse_(_.runMigrations(repo, migrations)))
+        buildRootsForRepo(repo).flatMap(buildRoots =>
+          buildRoots.traverse_(buildRoot =>
+            foundBuildTools(buildRoot).flatMap(_.traverse_(_.runMigrations(buildRoot, migrations)))
+          )
+        )
 
-      private def foundBuildTools(repo: Repo): F[List[BuildToolAlg[F]]] =
-        allBuildTools.filterA(_.containsBuild(repo)).map {
+      private def foundBuildTools(buildRoot: BuildRoot): F[List[BuildToolAlg[F, BuildRoot]]] =
+        allBuildTools.filterA(_.containsBuild(buildRoot)).map {
           case Nil  => List(fallbackBuildTool)
           case list => list
         }
 
-      def getAdditionalDependencies(repo: Repo): F[List[Scope.Dependencies]] =
+      def getAdditionalDependencies(buildRoot: BuildRoot): F[List[Scope.Dependencies]] =
         scalafmtAlg
-          .getScalafmtDependency(repo)
+          .getScalafmtDependency(buildRoot)
           .map(_.map(dep => Scope(List(dep), List(Resolver.mavenCentral))).toList)
     }
   }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/maven/MavenAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/maven/MavenAlg.scala
index dca37aa5..caaa5fd8 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/maven/MavenAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/maven/MavenAlg.scala
@@ -25,9 +25,9 @@ import org.scalasteward.core.data._
 import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.scalafix.Migration
 import org.scalasteward.core.util.Nel
-import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.data.BuildRoot
 
-trait MavenAlg[F[_]] extends BuildToolAlg[F]
+trait MavenAlg[F[_]] extends BuildToolAlg[F, BuildRoot]
 
 object MavenAlg {
   def create[F[_]](config: Config)(implicit
@@ -37,19 +37,27 @@ object MavenAlg {
       F: Monad[F]
   ): MavenAlg[F] =
     new MavenAlg[F] {
-      override def containsBuild(repo: Repo): F[Boolean] =
-        workspaceAlg.repoDir(repo).flatMap(repoDir => fileAlg.isRegularFile(repoDir / "pom.xml"))
+      override def containsBuild(buildRoot: BuildRoot): F[Boolean] =
+        workspaceAlg
+          .buildRootDir(buildRoot)
+          .flatMap(buildRootDir => fileAlg.isRegularFile(buildRootDir / "pom.xml"))
 
-      override def getDependencies(repo: Repo): F[List[Scope.Dependencies]] =
+      override def getDependencies(buildRoot: BuildRoot): F[List[Scope.Dependencies]] =
         for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          dependenciesRaw <- exec(mvnCmd(command.listDependencies), repoDir)
-          repositoriesRaw <- exec(mvnCmd(command.listRepositories), repoDir)
+          buildRootDir <- workspaceAlg.buildRootDir(buildRoot)
+          dependenciesRaw <- exec(
+            mvnCmd(command.listDependencies),
+            buildRootDir
+          )
+          repositoriesRaw <- exec(
+            mvnCmd(command.listRepositories),
+            buildRootDir
+          )
           dependencies = parser.parseDependencies(dependenciesRaw).distinct
           resolvers = parser.parseResolvers(repositoriesRaw).distinct
         } yield List(Scope(dependencies, resolvers))
 
-      override def runMigrations(repo: Repo, migrations: Nel[Migration]): F[Unit] =
+      override def runMigrations(buildRoot: BuildRoot, migrations: Nel[Migration]): F[Unit] =
         F.unit
 
       def exec(command: Nel[String], repoDir: File): F[List[String]] =
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala
index bf5d27e2..e4a51c7e 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala
@@ -25,9 +25,9 @@ import org.scalasteward.core.data.Scope.Dependencies
 import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.scalafix.Migration
 import org.scalasteward.core.util.Nel
-import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.data.BuildRoot
 
-trait MillAlg[F[_]] extends BuildToolAlg[F]
+trait MillAlg[F[_]] extends BuildToolAlg[F, BuildRoot]
 
 object MillAlg {
   private val content =
@@ -49,22 +49,24 @@ object MillAlg {
       F: BracketThrow[F]
   ): MillAlg[F] =
     new MillAlg[F] {
-      override def containsBuild(repo: Repo): F[Boolean] =
-        workspaceAlg.repoDir(repo).flatMap(repoDir => fileAlg.isRegularFile(repoDir / "build.sc"))
+      override def containsBuild(buildRoot: BuildRoot): F[Boolean] =
+        workspaceAlg
+          .buildRootDir(buildRoot)
+          .flatMap(buildRootDir => fileAlg.isRegularFile(buildRootDir / "build.sc"))
 
-      override def getDependencies(repo: Repo): F[List[Dependencies]] =
+      override def getDependencies(buildRoot: BuildRoot): F[List[Dependencies]] =
         for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          predef = repoDir / "scala-steward.sc"
+          buildRootDir <- workspaceAlg.buildRootDir(buildRoot)
+          predef = buildRootDir / "scala-steward.sc"
           extracted <- fileAlg.createTemporarily(predef, content) {
             val command = Nel("mill", List("-i", "-p", predef.toString, "show", extractDeps))
-            processAlg.execSandboxed(command, repoDir)
+            processAlg.execSandboxed(command, buildRootDir)
           }
           parsed <- F.fromEither(
             parser.parseModules(extracted.dropWhile(!_.startsWith("{")).mkString("\n"))
           )
         } yield parsed.map(module => Scope(module.dependencies, module.repositories))
 
-      override def runMigrations(repo: Repo, migrations: Nel[Migration]): F[Unit] = F.unit
+      override def runMigrations(buildRoot: BuildRoot, migrations: Nel[Migration]): F[Unit] = F.unit
     }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/sbt/SbtAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/sbt/SbtAlg.scala
index 124bab7d..0d74bd87 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/sbt/SbtAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/sbt/SbtAlg.scala
@@ -30,17 +30,17 @@ import org.scalasteward.core.data.{Dependency, Resolver, Scope}
 import org.scalasteward.core.io.{FileAlg, FileData, ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.scalafix.Migration
 import org.scalasteward.core.util.Nel
-import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.data.BuildRoot
 
-trait SbtAlg[F[_]] extends BuildToolAlg[F] {
+trait SbtAlg[F[_]] extends BuildToolAlg[F, BuildRoot] {
   def addGlobalPluginTemporarily[A](plugin: FileData)(fa: F[A]): F[A]
 
   def addGlobalPlugins[A](fa: F[A]): F[A]
 
-  def getSbtVersion(repo: Repo): F[Option[SbtVersion]]
+  def getSbtVersion(buildRoot: BuildRoot): F[Option[SbtVersion]]
 
-  final def getSbtDependency(repo: Repo)(implicit F: Functor[F]): F[Option[Dependency]] =
-    OptionT(getSbtVersion(repo)).subflatMap(sbtDependency).value
+  final def getSbtDependency(buildRoot: BuildRoot)(implicit F: Functor[F]): F[Option[Dependency]] =
+    OptionT(getSbtVersion(buildRoot)).subflatMap(sbtDependency).value
 }
 
 object SbtAlg {
@@ -66,37 +66,44 @@ object SbtAlg {
         logger.info("Add global sbt plugins") >>
           stewardPlugin.flatMap(addGlobalPluginTemporarily(_)(fa))
 
-      override def containsBuild(repo: Repo): F[Boolean] =
-        workspaceAlg.repoDir(repo).flatMap(repoDir => fileAlg.isRegularFile(repoDir / "build.sbt"))
+      override def containsBuild(buildRoot: BuildRoot): F[Boolean] =
+        workspaceAlg
+          .buildRootDir(buildRoot)
+          .flatMap(buildRootDir => fileAlg.isRegularFile(buildRootDir / "build.sbt"))
 
-      override def getSbtVersion(repo: Repo): F[Option[SbtVersion]] =
+      override def getSbtVersion(buildRoot: BuildRoot): F[Option[SbtVersion]] =
         for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          maybeProperties <- fileAlg.readFile(repoDir / "project" / "build.properties")
+          buildRootDir <- workspaceAlg.buildRootDir(buildRoot)
+          maybeProperties <- fileAlg.readFile(
+            buildRootDir / "project" / "build.properties"
+          )
           version = maybeProperties.flatMap(parser.parseBuildProperties)
         } yield version
 
-      override def getDependencies(repo: Repo): F[List[Scope.Dependencies]] =
+      override def getDependencies(buildRoot: BuildRoot): F[List[Scope.Dependencies]] =
         for {
-          repoDir <- workspaceAlg.repoDir(repo)
+          buildRootDir <- workspaceAlg.buildRootDir(buildRoot)
           commands = Nel.of(crossStewardDependencies, reloadPlugins, stewardDependencies)
-          lines <- sbt(commands, repoDir)
+          lines <- sbt(commands, buildRootDir)
           dependencies = parser.parseDependencies(lines)
-          additionalDependencies <- getAdditionalDependencies(repo)
+          additionalDependencies <- getAdditionalDependencies(buildRoot)
         } yield additionalDependencies ::: dependencies
 
-      override def runMigrations(repo: Repo, migrations: Nel[Migration]): F[Unit] =
+      override def runMigrations(buildRoot: BuildRoot, migrations: Nel[Migration]): F[Unit] =
         addGlobalPluginTemporarily(scalaStewardScalafixSbt) {
-          workspaceAlg.repoDir(repo).flatMap { repoDir =>
+          workspaceAlg.buildRootDir(buildRoot).flatMap { buildRootDir =>
             migrations.traverse_ { migration =>
               val withScalacOptions =
                 migration.scalacOptions.fold[F[Unit] => F[Unit]](identity) { opts =>
                   val file = scalaStewardScalafixOptions(opts.toList)
-                  fileAlg.createTemporarily(repoDir / file.name, file.content)(_)
+                  fileAlg.createTemporarily(
+                    buildRootDir / file.name,
+                    file.content
+                  )(_)
                 }
 
               val scalafixCmds = migration.rewriteRules.map(rule => s"$scalafixAll $rule").toList
-              withScalacOptions(sbt(Nel(scalafixEnable, scalafixCmds), repoDir).void)
+              withScalacOptions(sbt(Nel(scalafixEnable, scalafixCmds), buildRootDir).void)
             }
           }
         }
@@ -127,8 +134,8 @@ object SbtAlg {
           }
         }
 
-      def getAdditionalDependencies(repo: Repo): F[List[Scope.Dependencies]] =
-        getSbtDependency(repo)
+      def getAdditionalDependencies(buildRoot: BuildRoot): F[List[Scope.Dependencies]] =
+        getSbtDependency(buildRoot)
           .map(_.map(dep => Scope(List(dep), List(Resolver.mavenCentral))).toList)
     }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/io/WorkspaceAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/io/WorkspaceAlg.scala
index 7a6970d0..1707a169 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/io/WorkspaceAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/io/WorkspaceAlg.scala
@@ -21,7 +21,7 @@ import cats.FlatMap
 import cats.syntax.all._
 import io.chrisdavenport.log4cats.Logger
 import org.scalasteward.core.application.Config
-import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.data.{BuildRoot, Repo}
 
 trait WorkspaceAlg[F[_]] {
   def cleanWorkspace: F[Unit]
@@ -29,6 +29,8 @@ trait WorkspaceAlg[F[_]] {
   def rootDir: F[File]
 
   def repoDir(repo: Repo): F[File]
+
+  def buildRootDir(buildRoot: BuildRoot): F[File]
 }
 
 object WorkspaceAlg {
@@ -40,6 +42,8 @@ object WorkspaceAlg {
     new WorkspaceAlg[F] {
       private[this] val reposDir = config.workspace / "repos"
 
+      private[this] def repoDirUnsafe(repo: Repo) = reposDir / repo.owner / repo.repo
+
       override def cleanWorkspace: F[Unit] =
         for {
           _ <- logger.info(s"Clean workspace ${config.workspace}")
@@ -51,6 +55,9 @@ object WorkspaceAlg {
         fileAlg.ensureExists(config.workspace)
 
       override def repoDir(repo: Repo): F[File] =
-        fileAlg.ensureExists(reposDir / repo.owner / repo.repo)
+        fileAlg.ensureExists(repoDirUnsafe(repo))
+
+      override def buildRootDir(buildRoot: BuildRoot): F[File] =
+        fileAlg.ensureExists(repoDirUnsafe(buildRoot.repo) / buildRoot.relativePath)
     }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/BuildRootConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/BuildRootConfig.scala
new file mode 100644
index 00000000..ad599214
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/BuildRootConfig.scala
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2018-2021 Scala Steward contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.scalasteward.core.repoconfig
+
+import cats.Eq
+import io.circe.{Decoder, Encoder}
+
+final case class BuildRootConfig(relativeBuildRootPath: String)
+
+object BuildRootConfig {
+  val current = BuildRootConfig(".")
+
+  implicit val buildRootConfigDecoder: Decoder[BuildRootConfig] =
+    Decoder[String].map(BuildRootConfig.apply)
+
+  implicit val buildRootConfigStrategyEncoder: Encoder[BuildRootConfig] =
+    Encoder[String].contramap(_.relativeBuildRootPath)
+
+  implicit val buildRootConfigStrategyEq: Eq[BuildRootConfig] =
+    Eq.fromUniversalEquals
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
index de3c0c93..2399a1ac 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
@@ -27,7 +27,8 @@ final case class RepoConfig(
     pullRequests: PullRequestsConfig = PullRequestsConfig(),
     scalafmt: ScalafmtConfig = ScalafmtConfig(),
     updates: UpdatesConfig = UpdatesConfig(),
-    updatePullRequests: Option[PullRequestUpdateStrategy] = None
+    updatePullRequests: Option[PullRequestUpdateStrategy] = None,
+    buildRoots: List[BuildRootConfig] = List(BuildRootConfig.current)
 ) {
   def updatePullRequestsOrDefault: PullRequestUpdateStrategy =
     updatePullRequests.getOrElse(PullRequestUpdateStrategy.default)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/scalafmt/ScalafmtAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/scalafmt/ScalafmtAlg.scala
index 114bd87c..8c79a468 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/scalafmt/ScalafmtAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/scalafmt/ScalafmtAlg.scala
@@ -22,15 +22,17 @@ import cats.{Functor, Monad}
 import org.scalasteward.core.data.{Dependency, Version}
 import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.util.Nel
-import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.data.BuildRoot
 
 trait ScalafmtAlg[F[_]] {
-  def getScalafmtVersion(repo: Repo): F[Option[Version]]
+  def getScalafmtVersion(buildRoot: BuildRoot): F[Option[Version]]
 
   def version: F[String]
 
-  final def getScalafmtDependency(repo: Repo)(implicit F: Functor[F]): F[Option[Dependency]] =
-    Nested(getScalafmtVersion(repo)).map(scalafmtDependency).value
+  final def getScalafmtDependency(buildRoot: BuildRoot)(implicit
+      F: Functor[F]
+  ): F[Option[Dependency]] =
+    Nested(getScalafmtVersion(buildRoot)).map(scalafmtDependency).value
 }
 
 object ScalafmtAlg {
@@ -41,10 +43,10 @@ object ScalafmtAlg {
       F: Monad[F]
   ): ScalafmtAlg[F] =
     new ScalafmtAlg[F] {
-      override def getScalafmtVersion(repo: Repo): F[Option[Version]] =
+      override def getScalafmtVersion(buildRoot: BuildRoot): F[Option[Version]] =
         for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          scalafmtConfFile = repoDir / ".scalafmt.conf"
+          buildRootDir <- workspaceAlg.buildRootDir(buildRoot)
+          scalafmtConfFile = buildRootDir / ".scalafmt.conf"
           fileContent <- fileAlg.readFile(scalafmtConfFile)
         } yield fileContent.flatMap(parseScalafmtConf)
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/data/BuildRoot.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/BuildRoot.scala
new file mode 100644
index 00000000..d54f949c
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/BuildRoot.scala
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2018-2021 Scala Steward contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.scalasteward.core.vcs.data
+
+final case class BuildRoot(repo: Repo, relativePath: String)


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala
index a803f815..8a068e24 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/BuildToolDispatcherTest.scala
@@ -18,10 +18,13 @@ class BuildToolDispatcherTest extends FunSuite {
       repoDir / ".scalafmt.conf" -> "version=2.0.0"
     )
     val initial = MockState.empty.copy(files = files)
-    val (state, deps) = buildToolDispatcher.getDependencies(repo).run(initial).unsafeRunSync()
+    val (state, deps) =
+      buildToolDispatcher.getDependencies(repo).run(initial).unsafeRunSync()
 
     val expectedState = initial.copy(commands =
       Vector(
+        List("read", s"$repoDir/.scala-steward.conf"),
+        List("read", s"/tmp/default.scala-steward.conf"),
         List("test", "-f", s"$repoDir/pom.xml"),
         List("test", "-f", s"$repoDir/build.sc"),
         List("test", "-f", s"$repoDir/build.sbt"),
@@ -42,6 +45,7 @@ class BuildToolDispatcherTest extends FunSuite {
         List("read", s"$repoDir/.scalafmt.conf")
       )
     )
+
     assertEquals(state, expectedState)
 
     val expectedDeps = List(
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/maven/MavenAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/maven/MavenAlgTest.scala
index 1beb25ae..9176b628 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/buildtool/maven/MavenAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/maven/MavenAlgTest.scala
@@ -4,16 +4,17 @@ import better.files.File
 import munit.FunSuite
 import org.scalasteward.core.mock.MockContext._
 import org.scalasteward.core.mock.MockState
-import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.data.{BuildRoot, Repo}
 
 class MavenAlgTest extends FunSuite {
   test("getDependencies") {
     val repo = Repo("namespace", "repo-name")
+    val buildRoot = BuildRoot(repo, ".")
     val repoDir = config.workspace / repo.show
     val files: Map[File, String] = Map.empty
 
     val state =
-      mavenAlg.getDependencies(repo).runS(MockState.empty.copy(files = files)).unsafeRunSync()
+      mavenAlg.getDependencies(buildRoot).runS(MockState.empty.copy(files = files)).unsafeRunSync()
 
     val expected = MockState.empty.copy(
       files = files,
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillAlgTest.scala
index 2d7c865b..be906f03 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillAlgTest.scala
@@ -4,11 +4,12 @@ import munit.FunSuite
 import org.scalasteward.core.buildtool.mill.MillAlg.extractDeps
 import org.scalasteward.core.mock.MockContext.{config, millAlg}
 import org.scalasteward.core.mock.MockState
-import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.data.{BuildRoot, Repo}
 
 class MillAlgTest extends FunSuite {
   test("getDependencies") {
     val repo = Repo("lihaoyi", "fastparse")
+    val buildRoot = BuildRoot(repo, ".")
     val repoDir = config.workspace / repo.show
     val predef = s"$repoDir/scala-steward.sc"
     val millCmd = List(
@@ -25,7 +26,7 @@ class MillAlgTest extends FunSuite {
       extractDeps
     )
     val initial = MockState.empty.copy(commandOutputs = Map(millCmd -> List("""{"modules":[]}""")))
-    val state = millAlg.getDependencies(repo).runS(initial).unsafeRunSync()
+    val state = millAlg.getDependencies(buildRoot).runS(initial).unsafeRunSync()
     val expected = initial.copy(
       commands = Vector(
         List("write", predef),
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/sbt/SbtAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/sbt/SbtAlgTest.scala
index 1d762fd1..a254836f 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/buildtool/sbt/SbtAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/sbt/SbtAlgTest.scala
@@ -8,7 +8,7 @@ import org.scalasteward.core.mock.MockContext._
 import org.scalasteward.core.mock.MockState
 import org.scalasteward.core.scalafix.Migration
 import org.scalasteward.core.util.Nel
-import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.data.{BuildRoot, Repo}
 
 class SbtAlgTest extends FunSuite {
   test("addGlobalPlugins") {
@@ -33,10 +33,11 @@ class SbtAlgTest extends FunSuite {
 
   test("getDependencies") {
     val repo = Repo("typelevel", "cats")
+    val buildRoot = BuildRoot(repo, ".")
     val repoDir = config.workspace / repo.show
     val files = Map(repoDir / "project" / "build.properties" -> "sbt.version=1.2.6")
     val initial = MockState.empty.copy(files = files)
-    val state = sbtAlg.getDependencies(repo).runS(initial).unsafeRunSync()
+    val state = sbtAlg.getDependencies(buildRoot).runS(initial).unsafeRunSync()
     val expected = initial.copy(
       commands = Vector(
         List(
@@ -60,6 +61,7 @@ class SbtAlgTest extends FunSuite {
 
   test("runMigrations") {
     val repo = Repo("fthomas", "scala-steward")
+    val buildRoot = BuildRoot(repo, ".")
     val repoDir = config.workspace / repo.show
     val migrations = Nel.of(
       Migration(
@@ -71,7 +73,7 @@ class SbtAlgTest extends FunSuite {
         None
       )
     )
-    val state = sbtAlg.runMigrations(repo, migrations).runS(MockState.empty).unsafeRunSync()
+    val state = sbtAlg.runMigrations(buildRoot, migrations).runS(MockState.empty).unsafeRunSync()
     val expected = MockState.empty.copy(
       commands = Vector(
         List("write", "/tmp/steward/.sbt/0.13/plugins/scala-steward-scalafix.sbt"),
@@ -98,6 +100,7 @@ class SbtAlgTest extends FunSuite {
 
   test("runMigrations: migration with scalacOptions") {
     val repo = Repo("fthomas", "scala-steward")
+    val buildRoot = BuildRoot(repo, ".")
     val repoDir = config.workspace / repo.show
     val migrations = Nel.of(
       Migration(
@@ -109,7 +112,7 @@ class SbtAlgTest extends FunSuite {
         Some(Nel.of("-P:semanticdb:synthetics:on"))
       )
     )
-    val state = sbtAlg.runMigrations(repo, migrations).runS(MockState.empty).unsafeRunSync()
+    val state = sbtAlg.runMigrations(buildRoot, migrations).runS(MockState.empty).unsafeRunSync()
     val expected = MockState.empty.copy(
       commands = Vector(
         List("write", "/tmp/steward/.sbt/0.13/plugins/scala-steward-scalafix.sbt"),
diff --git a/modules/core/src/test/scala/org/scalasteward/core/io/MockWorkspaceAlg.scala b/modules/core/src/test/scala/org/scalasteward/core/io/MockWorkspaceAlg.scala
index 4fdcdd73..3bc619a0 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/io/MockWorkspaceAlg.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/io/MockWorkspaceAlg.scala
@@ -4,6 +4,7 @@ import better.files.File
 import cats.data.StateT
 import org.scalasteward.core.mock.{MockContext, MockEff}
 import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.data.BuildRoot
 
 class MockWorkspaceAlg extends WorkspaceAlg[MockEff] {
   override def cleanWorkspace: MockEff[Unit] =
@@ -14,4 +15,7 @@ class MockWorkspaceAlg extends WorkspaceAlg[MockEff] {
 
   override def repoDir(repo: Repo): MockEff[File] =
     rootDir.map(_ / repo.owner / repo.repo)
+
+  override def buildRootDir(buildRoot: BuildRoot): MockEff[File] =
+    repoDir(buildRoot.repo)
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
index edb5aeef..3061cdad 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
@@ -29,6 +29,7 @@ class RepoConfigAlgTest extends FunSuite {
          |updates.fileExtensions = [ ".txt" ]
          |pullRequests.frequency = "@weekly"
          |commits.message = "Update ${artifactName} from ${currentVersion} to ${nextVersion}"
+         |buildRoots = [ ".", "subfolder/subfolder" ]
          |""".stripMargin
     val initialState = MockState.empty.add(configFile, content)
     val config = repoConfigAlg
@@ -72,7 +73,8 @@ class RepoConfigAlgTest extends FunSuite {
       ),
       commits = CommitsConfig(
         message = Some("Update ${artifactName} from ${currentVersion} to ${nextVersion}")
-      )
+      ),
+      buildRoots = List(BuildRootConfig("."), BuildRootConfig("subfolder/subfolder"))
     )
     assertEquals(config, expected)
   }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/scalafmt/ScalafmtAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/scalafmt/ScalafmtAlgTest.scala
index 660f3f7d..1a5f50bf 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/scalafmt/ScalafmtAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/scalafmt/ScalafmtAlgTest.scala
@@ -4,11 +4,12 @@ import munit.FunSuite
 import org.scalasteward.core.data.Version
 import org.scalasteward.core.mock.MockContext._
 import org.scalasteward.core.mock.MockState
-import org.scalasteward.core.vcs.data.Repo
+import org.scalasteward.core.vcs.data.{BuildRoot, Repo}
 
 class ScalafmtAlgTest extends FunSuite {
   test("getScalafmtVersion on unquoted version") {
     val repo = Repo("fthomas", "scala-steward")
+    val buildRoot = BuildRoot(repo, ".")
     val repoDir = config.workspace / repo.owner / repo.repo
     val scalafmtConf = repoDir / ".scalafmt.conf"
     val initialState = MockState.empty.add(
@@ -19,7 +20,7 @@ class ScalafmtAlgTest extends FunSuite {
         |""".stripMargin
     )
     val (state, maybeVersion) =
-      scalafmtAlg.getScalafmtVersion(repo).run(initialState).unsafeRunSync()
+      scalafmtAlg.getScalafmtVersion(buildRoot).run(initialState).unsafeRunSync()
     val expectedState = MockState.empty.copy(
       commands = Vector(List("read", s"$repoDir/.scalafmt.conf")),
       files = Map(
@@ -37,6 +38,7 @@ class ScalafmtAlgTest extends FunSuite {
 
   test("getScalafmtVersion on quoted version") {
     val repo = Repo("fthomas", "scala-steward")
+    val buildRoot = BuildRoot(repo, ".")
     val repoDir = config.workspace / repo.owner / repo.repo
     val scalafmtConf = repoDir / ".scalafmt.conf"
     val initialState = MockState.empty.add(
@@ -46,7 +48,8 @@ class ScalafmtAlgTest extends FunSuite {
         |align.openParenCallSite = false
         |""".stripMargin
     )
-    val (_, maybeVersion) = scalafmtAlg.getScalafmtVersion(repo).run(initialState).unsafeRunSync()
+    val (_, maybeVersion) =
+      scalafmtAlg.getScalafmtVersion(buildRoot).run(initialState).unsafeRunSync()
     assertEquals(maybeVersion, Some(Version("2.0.0-RC8")))
   }
 }