Repository: scala-steward-org/scala-steward
Pull request #2308 (2021-10-22 06:32:01)

Support Scalafix migrations that target build files
# What has been done in this PR?

This PR extends the Scalafix support to run migrations with the Scalafix CLI in addition to sbt-scalafix. Migrations that are executed with the CLI target `*.sbt` and `project/*.scala` files in the build root directories. So these `build` migrations can change build files while regular `sources` migrations can change source files.

# How?

To distinguish between `sources` and `build` migrations a new optional [`target` field](https://github.com/scala-steward-org/scala-steward/pull/2308/files#diff-347193ff11da2edf2cd0e05d2ea42dffc2434fdf00567e7fc3f0aab5e1f0bc72R35) is added to `ScalafixMigration`. The default value is `sources` if this field is not specified. Scala Steward differentiate these two targets in `SbtAlg` and either calls the proper sbt command or the `scalafix` CLI. A new `ScalafixCli` class is added that takes care of running migrations with the CLI.

# Want to see it in action?

This PR also adds @eed3si9n's `Sbt0_13BuildSyntax` for unified slash syntax to our list of Scalafix migrations:
https://github.com/scala-steward-org/scala-steward/blob/201829072945b13e82d15e490dd912febe68c874/modules/core/src/main/resources/scalafix-migrations.conf#L92-L100
which is used when sbt is bumped over 1.5.0. An example PR where the above migration was used and contains changes to `build.sbt` from Scalafix is:
* https://github.com/scala-steward-org/test-repo-2/pull/55

The above PR also demonstrate another new feature: `Co-authored-by:` attribution of the Scalafix rule authors (#1914). @eed3si9n I added your name to the `authors` field above to test that feature but I'll only keep your name there if you're fine with this attribution in all future PRs where Scala Steward executes the `Sbt0_13BuildSyntax` rule.

# Other notable changes

* The Scalafix CLI has been added to the Docker image.
* Scala Steward checks at start-up if it can run the Scalafix CLI and will print its version.



Patch:
diff --git a/build.sbt b/build.sbt
index ae5d01be..11d1b458 100644
--- a/build.sbt
+++ b/build.sbt
@@ -251,23 +251,22 @@ lazy val dockerSettings = Def.settings(
   dockerBaseImage := Option(System.getenv("DOCKER_BASE_IMAGE"))
     .getOrElse("adoptopenjdk/openjdk11:alpine"),
   dockerCommands ++= {
-    val getSbtVersion = sbtVersion.value
-    val sbtTgz = s"sbt-$getSbtVersion.tgz"
-    val sbtUrl = s"https://github.com/sbt/sbt/releases/download/v$getSbtVersion/$sbtTgz"
-    val millVersion = Dependencies.millVersion.value
-    val binDir = "/usr/local/bin/"
+    val binDir = "/usr/local/bin"
+    val sbtVer = sbtVersion.value
+    val sbtTgz = s"sbt-$sbtVer.tgz"
+    val sbtUrl = s"https://github.com/sbt/sbt/releases/download/v$sbtVer/$sbtTgz"
+    val millBin = s"$binDir/mill"
+    val millVer = Dependencies.millVersion.value
+    val millUrl =
+      s"https://github.com/lihaoyi/mill/releases/download/${millVer.split("-").head}/$millVer"
+    val coursierBin = s"$binDir/coursier"
     Seq(
       Cmd("USER", "root"),
       Cmd("RUN", "apk --no-cache add bash git ca-certificates curl maven openssh"),
       Cmd("RUN", s"wget $sbtUrl && tar -xf $sbtTgz && rm -f $sbtTgz"),
-      Cmd(
-        "RUN",
-        s"curl -L https://github.com/lihaoyi/mill/releases/download/${millVersion.split("-").head}/$millVersion >${binDir}mill && chmod +x ${binDir}mill"
-      ),
-      Cmd(
-        "RUN",
-        s"curl -L https://git.io/coursier-cli > ${binDir}coursier && chmod +x ${binDir}coursier && coursier install scalafmt --install-dir $binDir && rm -rf ${binDir}coursier"
-      )
+      Cmd("RUN", s"curl -L $millUrl > $millBin && chmod +x $millBin"),
+      Cmd("RUN", s"curl -L https://git.io/coursier-cli > $coursierBin && chmod +x $coursierBin"),
+      Cmd("RUN", s"$coursierBin install --install-dir $binDir scalafix scalafmt")
     )
   },
   Docker / packageName := s"fthomas/${name.value}",
diff --git a/docs/help.md b/docs/help.md
index 5b41d9dd..f25339f5 100644
--- a/docs/help.md
+++ b/docs/help.md
@@ -37,5 +37,5 @@ All command line arguments for the `scala-steward` application.
   --gitlab-merge-when-pipeline-succeeds  BOOLEAN     Wether to merge a gitlab merge request when the pipeline succeeds
   --github-app-key-file  FILE                        Github application key file
   --github-app-id  ID                                Github application id
-  --refresh-backoff-period DURATION                  Period of time a failed build won't be triggered again, default: "7 days"
+  --refresh-backoff-period DURATION                  Period of time a failed build won't be triggered again, default: "0 days"
 ```
diff --git a/modules/core/src/main/resources/scalafix-migrations.conf b/modules/core/src/main/resources/scalafix-migrations.conf
index 2bf1108c..772ace6f 100644
--- a/modules/core/src/main/resources/scalafix-migrations.conf
+++ b/modules/core/src/main/resources/scalafix-migrations.conf
@@ -89,6 +89,14 @@ migrations = [
     rewriteRules: ["https://gist.githubusercontent.com/REDNBLACK/9bc56ad71e4b01a63001339fa61b4cfd/raw/5f1cd32713c3235a83fe2d1d182bd71a001ef464/derevo-v0.11.0.scala"],
     doc: "https://github.com/manatki/derevo/blob/0.11.3/README.md#breaking-changes-in-011"
   },
+  {
+    groupId: "org.scala-sbt",
+    artifactIds: ["sbt"],
+    newVersion: "1.5.0",
+    rewriteRules: ["https://gist.githubusercontent.com/eed3si9n/57e83f5330592d968ce49f0d5030d4d5/raw/7f576f16a90e432baa49911c9a66204c354947bb/Sbt0_13BuildSyntax.scala"],
+    doc: "https://eed3si9n.com/syntactic-scalafix-rule-for-unified-slash-syntax",
+    target: "build"
+  },
   {
     groupId: "org.scalacheck",
     artifactIds: ["scalacheck"],
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
index af4970bc..6d139c93 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
@@ -59,7 +59,7 @@ object Cli {
       githubAppId: Option[Long] = None,
       urlCheckerTestUrl: Option[Uri] = None,
       defaultMavenRepo: Option[String] = None,
-      refreshBackoffPeriod: FiniteDuration = 7.days
+      refreshBackoffPeriod: FiniteDuration = 0.days
   )
 
   final case class EnvVar(name: String, value: String)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
index 8caebb80..c2b7bbf8 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
@@ -29,7 +29,7 @@ import org.scalasteward.core.buildtool.sbt.SbtAlg
 import org.scalasteward.core.coursier.{CoursierAlg, VersionsCache}
 import org.scalasteward.core.edit.EditAlg
 import org.scalasteward.core.edit.hooks.HookExecutor
-import org.scalasteward.core.edit.scalafix.{ScalafixMigrationsFinder, ScalafixMigrationsLoader}
+import org.scalasteward.core.edit.scalafix._
 import org.scalasteward.core.git.{GenGitAlg, GitAlg}
 import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.nurture.{NurtureAlg, PullRequestRepository}
@@ -61,6 +61,7 @@ final class Context[F[_]](implicit
     val millAlg: MillAlg[F],
     val pruningAlg: PruningAlg[F],
     val pullRequestRepository: PullRequestRepository[F],
+    val refreshErrorAlg: RefreshErrorAlg[F],
     val repoConfigAlg: RepoConfigAlg[F],
     val sbtAlg: SbtAlg[F],
     val artifactMigrationsLoader: ArtifactMigrationsLoader[F],
@@ -131,6 +132,7 @@ object Context {
       implicit val vcsExtraAlg: VCSExtraAlg[F] = VCSExtraAlg.create[F](config.vcsCfg)
       implicit val pullRequestRepository: PullRequestRepository[F] =
         new PullRequestRepository[F](pullRequestsStore)
+      implicit val scalafixCli: ScalafixCli[F] = new ScalafixCli[F]
       implicit val scalafmtAlg: ScalafmtAlg[F] = new ScalafmtAlg[F](config)
       implicit val selfCheckAlg: SelfCheckAlg[F] = new SelfCheckAlg[F](config)
       implicit val coursierAlg: CoursierAlg[F] = CoursierAlg.create[F]
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/SelfCheckAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/application/SelfCheckAlg.scala
index 601789d8..77e307c0 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/SelfCheckAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/SelfCheckAlg.scala
@@ -18,6 +18,8 @@ package org.scalasteward.core.application
 
 import cats.MonadThrow
 import cats.syntax.all._
+import org.scalasteward.core.edit.scalafix.ScalafixCli
+import org.scalasteward.core.edit.scalafix.ScalafixCli.scalafixBinary
 import org.scalasteward.core.git.GitAlg
 import org.scalasteward.core.scalafmt.{scalafmtBinary, ScalafmtAlg}
 import org.scalasteward.core.util.UrlChecker
@@ -27,6 +29,7 @@ import org.typelevel.log4cats.Logger
 final class SelfCheckAlg[F[_]](config: Config)(implicit
     gitAlg: GitAlg[F],
     logger: Logger[F],
+    scalafixCli: ScalafixCli[F],
     scalafmtAlg: ScalafmtAlg[F],
     urlChecker: UrlChecker[F],
     F: MonadThrow[F]
@@ -35,6 +38,7 @@ final class SelfCheckAlg[F[_]](config: Config)(implicit
     for {
       _ <- logger.info("Run self checks")
       _ <- checkGitBinary
+      _ <- checkScalafixBinary
       _ <- checkScalafmtBinary
       _ <- checkUrlChecker
     } yield ()
@@ -44,6 +48,11 @@ final class SelfCheckAlg[F[_]](config: Config)(implicit
       gitAlg.version.flatMap(output => logger.info(s"Using $output"))
     }
 
+  private def checkScalafixBinary: F[Unit] =
+    logger.attemptWarn.log_(execFailedMessage(scalafixBinary)) {
+      scalafixCli.version.flatMap(output => logger.info(s"Using $scalafixBinary $output"))
+    }
+
   private def checkScalafmtBinary: F[Unit] =
     logger.attemptWarn.log_(execFailedMessage(scalafmtBinary)) {
       scalafmtAlg.version.flatMap(output => logger.info(s"Using $output"))
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 a87287a3..d0c9775b 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
@@ -19,14 +19,14 @@ package org.scalasteward.core.buildtool.sbt
 import better.files.File
 import cats.Functor
 import cats.data.OptionT
-import cats.effect.MonadCancelThrow
+import cats.effect.Concurrent
 import cats.syntax.all._
 import org.scalasteward.core.application.Config
 import org.scalasteward.core.buildtool.BuildToolAlg
 import org.scalasteward.core.buildtool.sbt.command._
 import org.scalasteward.core.buildtool.sbt.data.SbtVersion
 import org.scalasteward.core.data.{Dependency, Scope}
-import org.scalasteward.core.edit.scalafix.ScalafixMigration
+import org.scalasteward.core.edit.scalafix.{ScalafixCli, ScalafixMigration}
 import org.scalasteward.core.io.{FileAlg, FileData, ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.util.Nel
 import org.scalasteward.core.vcs.data.BuildRoot
@@ -48,8 +48,9 @@ object SbtAlg {
       fileAlg: FileAlg[F],
       logger: Logger[F],
       processAlg: ProcessAlg[F],
+      scalafixCli: ScalafixCli[F],
       workspaceAlg: WorkspaceAlg[F],
-      F: MonadCancelThrow[F]
+      F: Concurrent[F]
   ): SbtAlg[F] =
     new SbtAlg[F] {
       override def addGlobalPluginTemporarily[A](plugin: FileData)(fa: F[A]): F[A] =
@@ -74,9 +75,7 @@ object SbtAlg {
       override def getSbtVersion(buildRoot: BuildRoot): F[Option[SbtVersion]] =
         for {
           buildRootDir <- workspaceAlg.buildRootDir(buildRoot)
-          maybeProperties <- fileAlg.readFile(
-            buildRootDir / "project" / "build.properties"
-          )
+          maybeProperties <- fileAlg.readFile(buildRootDir / "project" / "build.properties")
           version = maybeProperties.flatMap(parser.parseBuildProperties)
         } yield version
 
@@ -90,6 +89,12 @@ object SbtAlg {
         } yield additionalDependencies ::: dependencies
 
       override def runMigration(buildRoot: BuildRoot, migration: ScalafixMigration): F[Unit] =
+        migration.targetOrDefault match {
+          case ScalafixMigration.Target.Sources => runSourcesMigration(buildRoot, migration)
+          case ScalafixMigration.Target.Build   => runBuildMigration(buildRoot, migration)
+        }
+
+      private def runSourcesMigration(buildRoot: BuildRoot, migration: ScalafixMigration): F[Unit] =
         addGlobalPluginTemporarily(scalaStewardScalafixSbt) {
           workspaceAlg.buildRootDir(buildRoot).flatMap { buildRootDir =>
             val withScalacOptions =
@@ -102,6 +107,19 @@ object SbtAlg {
           }
         }
 
+      private def runBuildMigration(buildRoot: BuildRoot, migration: ScalafixMigration): F[Unit] =
+        for {
+          buildRootDir <- workspaceAlg.buildRootDir(buildRoot)
+          projectDir = buildRootDir / "project"
+          files0 <- (
+            fileAlg.walk(buildRootDir, 1).filter(_.extension.contains(".sbt")) ++
+              fileAlg.walk(projectDir, 1).filter(_.extension.exists(Set(".sbt", ".scala")))
+          ).compile.toList
+          _ <- Nel.fromList(files0).fold(F.unit) { files1 =>
+            scalafixCli.runMigration(buildRootDir, files1, migration)
+          }
+        } yield ()
+
       val sbtDir: F[File] =
         fileAlg.home.map(_ / ".sbt")
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixCli.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixCli.scala
new file mode 100644
index 00000000..5b52d1a1
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixCli.scala
@@ -0,0 +1,45 @@
+/*
+ * 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.edit.scalafix
+
+import better.files.File
+import cats.Monad
+import cats.syntax.all._
+import org.scalasteward.core.edit.scalafix.ScalafixCli.scalafixBinary
+import org.scalasteward.core.io.{ProcessAlg, WorkspaceAlg}
+import org.scalasteward.core.util.Nel
+
+final class ScalafixCli[F[_]](implicit
+    processAlg: ProcessAlg[F],
+    workspaceAlg: WorkspaceAlg[F],
+    F: Monad[F]
+) {
+  def runMigration(workingDir: File, files: Nel[File], migration: ScalafixMigration): F[Unit] = {
+    val rules = migration.rewriteRules.map("--rules=" + _)
+    val cmd = scalafixBinary :: rules ::: files.map(_.pathAsString)
+    processAlg.exec(cmd, workingDir).void
+  }
+
+  def version: F[String] = {
+    val cmd = Nel.of(scalafixBinary, "--version")
+    workspaceAlg.rootDir.flatMap(processAlg.exec(cmd, _)).map(_.mkString.trim)
+  }
+}
+
+object ScalafixCli {
+  val scalafixBinary = "scalafix"
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigration.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigration.scala
index 3f42daf6..d023bbf7 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigration.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigration.scala
@@ -20,6 +20,7 @@ import cats.syntax.all._
 import io.circe.Decoder
 import io.circe.generic.semiauto._
 import org.scalasteward.core.data.{GroupId, Version}
+import org.scalasteward.core.edit.scalafix.ScalafixMigration.Target
 import org.scalasteward.core.git.{Author, CommitMsg}
 import org.scalasteward.core.util.Nel
 
@@ -30,7 +31,8 @@ final case class ScalafixMigration(
     rewriteRules: Nel[String],
     doc: Option[String] = None,
     scalacOptions: Option[Nel[String]] = None,
-    authors: Option[Nel[Author]] = None
+    authors: Option[Nel[Author]] = None,
+    target: Option[Target] = None
 ) {
   def commitMessage(result: Either[Throwable, Unit]): CommitMsg = {
     val verb = if (result.isRight) "Applied" else "Failed"
@@ -38,9 +40,25 @@ final case class ScalafixMigration(
     val body = doc.map(url => s"See $url for details")
     CommitMsg(title, body.toList, authors.foldMap(_.toList))
   }
+
+  def targetOrDefault: Target =
+    target.getOrElse(Target.Sources)
 }
 
 object ScalafixMigration {
+  sealed trait Target
+  object Target {
+    case object Sources extends Target
+    case object Build extends Target
+
+    implicit val targetDecoder: Decoder[Target] =
+      Decoder[String].emap {
+        case "sources" => Right(Sources)
+        case "build"   => Right(Build)
+        case unknown   => Left(s"Unexpected string '$unknown'. Expected 'sources' or 'build'.")
+      }
+  }
+
   implicit val scalafixMigrationDecoder: Decoder[ScalafixMigration] =
     deriveDecoder
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/io/FileAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/io/FileAlg.scala
index 7d68f606..1929381b 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/io/FileAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/io/FileAlg.scala
@@ -46,7 +46,7 @@ trait FileAlg[F[_]] {
 
   def readUri(uri: Uri): F[String]
 
-  def walk(dir: File): Stream[F, File]
+  def walk(dir: File, maxDepth: Int = Int.MaxValue): Stream[F, File]
 
   def writeFile(file: File, content: String): F[Unit]
 
@@ -137,8 +137,8 @@ object FileAlg {
       private def readSource(source: F[Source]): F[String] =
         Resource.fromAutoCloseable(source).use(src => F.blocking(src.mkString))
 
-      override def walk(dir: File): Stream[F, File] =
-        Stream.eval(F.delay(dir.walk())).flatMap(Stream.fromBlockingIterator(_, 1))
+      override def walk(dir: File, maxDepth: Int): Stream[F, File] =
+        Stream.eval(F.delay(dir.walk(maxDepth))).flatMap(Stream.fromBlockingIterator(_, 1))
 
       override def writeFile(file: File, content: String): F[Unit] =
         logger.debug(s"Write $file") >>
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repocache/RefreshErrorAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/repocache/RefreshErrorAlg.scala
index 8f121812..67ffdf23 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repocache/RefreshErrorAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repocache/RefreshErrorAlg.scala
@@ -46,7 +46,7 @@ final class RefreshErrorAlg[F[_]](
   def persistError[A](repo: Repo)(fa: F[A]): F[A] =
     fa.handleErrorWith { t =>
       dateTimeAlg.currentTimestamp.flatMap { now =>
-        kvStore.put(repo, Entry(now, t.getMessage))
+        kvStore.put(repo, Entry(now, t.toString))
       } >> F.raiseError[A](t)
     }
 
diff --git a/repos.md b/repos.md
index 0af867e5..6df680fd 100644
--- a/repos.md
+++ b/repos.md
@@ -4,4 +4,5 @@ If you want Scala Steward to keep your project up-to-date, please add it there.
 
 - scala-steward-org/scala-steward
 - scala-steward-org/test-repo-2
+- scala-steward-org/test-repo-2:slash-syntax-2
 - scala-steward-org/test-repo-2:test-branch


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/EditAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/EditAlgTest.scala
index 6abb3d42..0e24bcfa 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/EditAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/EditAlgTest.scala
@@ -5,7 +5,9 @@ import cats.effect.unsafe.implicits.global
 import munit.FunSuite
 import org.scalasteward.core.TestInstances.dummyRepoCache
 import org.scalasteward.core.TestSyntax._
+import org.scalasteward.core.buildtool.sbt.{sbtArtifactId, sbtGroupId}
 import org.scalasteward.core.data._
+import org.scalasteward.core.edit.scalafix.ScalafixCli.scalafixBinary
 import org.scalasteward.core.mock.MockConfig.{config, gitCmd}
 import org.scalasteward.core.mock.MockContext.context.editAlg
 import org.scalasteward.core.mock.MockState
@@ -163,6 +165,27 @@ class EditAlgTest extends FunSuite {
     assertEquals(state, expected)
   }
 
+  test("applyUpdate with build Scalafix") {
+    val repo = Repo("edit-alg", "test-3-1")
+    val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
+    val repoDir = config.workspace / repo.toPath
+    val update = Update.Single(sbtGroupId.value % sbtArtifactId.name % "1.4.9", Nel.of("1.5.5"))
+
+    val state = MockState.empty
+      .addFiles(
+        repoDir / "build.sbt" -> "",
+        repoDir / "project" / "build.properties" -> """sbt.version=1.4.9"""
+      )
+      .flatMap(editAlg.applyUpdate(data, update).runS)
+      .unsafeRunSync()
+
+    assert(state.trace.exists {
+      case Cmd(cmd) =>
+        cmd.contains(scalafixBinary) && cmd.exists(_.contains("Sbt0_13BuildSyntax.scala"))
+      case Log(_) => false
+    })
+  }
+
   test("https://github.com/circe/circe-config/pull/40") {
     val update = Update.Single("com.typesafe" % "config" % "1.3.3", Nel.of("1.3.4"))
     val original = Map(
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoaderTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoaderTest.scala
index 64923234..7fca4456 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoaderTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoaderTest.scala
@@ -5,6 +5,7 @@ import munit.FunSuite
 import org.http4s.Uri
 import org.scalasteward.core.application.Config.ScalafixCfg
 import org.scalasteward.core.data.{GroupId, Version}
+import org.scalasteward.core.edit.scalafix.ScalafixMigration.Target.Sources
 import org.scalasteward.core.git.Author
 import org.scalasteward.core.mock.MockConfig.mockRoot
 import org.scalasteward.core.mock.MockContext.context.scalafixMigrationsLoader
@@ -21,7 +22,8 @@ class ScalafixMigrationsLoaderTest extends FunSuite {
        |    newVersion: "1.0.0",
        |    rewriteRules: ["awesome rewrite rule"],
        |    doc: "https://scalacenter.github.io/scalafix/",
-       |    authors: ["Jane Doe <jane@example.com>"]
+       |    authors: ["Jane Doe <jane@example.com>"],
+       |    target: "sources"
        |  }
        |]""".stripMargin
   val migration: ScalafixMigration = ScalafixMigration(
@@ -30,7 +32,8 @@ class ScalafixMigrationsLoaderTest extends FunSuite {
     Version("1.0.0"),
     Nel.of("awesome rewrite rule"),
     Some("https://scalacenter.github.io/scalafix/"),
-    authors = Some(Nel.of(Author("Jane Doe", "jane@example.com")))
+    authors = Some(Nel.of(Author("Jane Doe", "jane@example.com"))),
+    target = Some(Sources)
   )
 
   test("loadAll: without extra file, without defaults") {
diff --git a/modules/core/src/test/scala/org/scalasteward/core/io/MockFileAlg.scala b/modules/core/src/test/scala/org/scalasteward/core/io/MockFileAlg.scala
index c2ecf937..5ee0f3ce 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/io/MockFileAlg.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/io/MockFileAlg.scala
@@ -53,8 +53,10 @@ class MockFileAlg extends FileAlg[MockEff] {
       }
     }
 
-  override def walk(dir: File): Stream[MockEff, File] =
-    Stream.evals(Kleisli.liftF(ioFileAlg.walk(dir).compile.toList.map(_.sortBy(_.pathAsString))))
+  override def walk(dir: File, maxDepth: Int): Stream[MockEff, File] =
+    Stream.evals(
+      Kleisli.liftF(ioFileAlg.walk(dir, maxDepth).compile.toList.map(_.sortBy(_.pathAsString)))
+    )
 
   override def writeFile(file: File, content: String): MockEff[Unit] =
     Kleisli(getFlatMapSet(_.exec(List("write", file.pathAsString)).addFiles(file -> content)))
diff --git a/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala b/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala
index 1d116bfb..ba768f79 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/mock/MockConfig.scala
@@ -22,7 +22,8 @@ object MockConfig {
     gitAskPass = mockRoot / "askpass.sh",
     enableSandbox = Some(true),
     envVar = List(EnvVar("VAR1", "val1"), EnvVar("VAR2", "val2")),
-    cacheTtl = 1.hour
+    cacheTtl = 1.hour,
+    refreshBackoffPeriod = 1.hour
   )
   val config: Config = Config.from(args)
   val envVars = List(s"GIT_ASKPASS=${config.gitCfg.gitAskPass}", "VAR1=val1", "VAR2=val2")
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repocache/RefreshErrorAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repocache/RefreshErrorAlgTest.scala
new file mode 100644
index 00000000..8156e8e7
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/repocache/RefreshErrorAlgTest.scala
@@ -0,0 +1,29 @@
+package org.scalasteward.core.repocache
+
+import cats.syntax.all._
+import munit.CatsEffectSuite
+import org.scalasteward.core.mock.MockContext.context.refreshErrorAlg
+import org.scalasteward.core.mock.{MockEff, MockState}
+import org.scalasteward.core.vcs.data.Repo
+
+class RefreshErrorAlgTest extends CatsEffectSuite {
+  test("skipIfFailedRecently: not failed") {
+    val repo = Repo("refresh-error", "test-1")
+    val expected = 42
+    refreshErrorAlg.skipIfFailedRecently(repo)(MockEff.pure(expected)).runA(MockState.empty).map {
+      obtained => assertEquals(obtained, expected)
+    }
+  }
+
+  test("skipIfFailedRecently: failed") {
+    val repo = Repo("refresh-error", "test-2")
+    val p = refreshErrorAlg.persistError(repo)(MockEff.raiseError(new Throwable())).attempt >>
+      refreshErrorAlg.skipIfFailedRecently(repo)(MockEff.unit)
+
+    val expected = "Skipping due to previous error"
+    p.runA(MockState.empty).attempt.map { obtained =>
+      val message = obtained.fold(_.getMessage, _.toString).take(expected.length)
+      assertEquals(message, expected)
+    }
+  }
+}