Repository: scala-steward-org/scala-steward
Pull request #2311 (2021-10-24 21:28:07)

codecov v2
https://github.com/codecov/codecov-action#%EF%B8%8F--deprecration-of-v1


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fd75fcc7..45d24c67 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -56,10 +56,10 @@ jobs:
         run: sbt ++${{ matrix.scala }} validate
 
       - name: Codecov
-        uses: codecov/codecov-action@v1
+        uses: codecov/codecov-action@v2
 
       - name: Compress target directories
-        run: tar cf targets.tar target modules/mill-plugin/.jvm/target modules/sbt-plugin/.jvm/target modules/core/.jvm/target modules/benchmark/.jvm/target project/target
+        run: tar cf targets.tar target modules/docs/.jvm/target modules/mill-plugin/.jvm/target modules/sbt-plugin/.jvm/target modules/core/.jvm/target modules/benchmark/.jvm/target project/target
 
       - name: Upload target directories
         uses: actions/upload-artifact@v2
diff --git a/.scalafmt.conf b/.scalafmt.conf
index 33db3a59..8ba63b77 100644
--- a/.scalafmt.conf
+++ b/.scalafmt.conf
@@ -1,4 +1,4 @@
-version = 3.0.7
+version = 3.0.8
 assumeStandardLibraryStripMargin = true
 align.openParenCallSite = false
 align.openParenDefnSite = false
diff --git a/README.md b/README.md
index ea415c9b..7ba1c92c 100644
--- a/README.md
+++ b/README.md
@@ -121,6 +121,7 @@ Thanks goes to these wonderful people for contributing to Scala Steward:
 * [Michel Daviot](https://github.com/tyrcho)
 * [miguelpuyol](https://github.com/miguelpuyol)
 * [nafg](https://github.com/nafg)
+* [Ondra Pelech](https://github.com/sideeffffect)
 * [Pavel Shapkin](https://github.com/psttf)
 * [Philippus Baalman](https://github.com/Philippus)
 * [Piotr Gabara](https://github.com/pgabara)
diff --git a/build.sbt b/build.sbt
index ae5d01be..46ccd46c 100644
--- a/build.sbt
+++ b/build.sbt
@@ -7,10 +7,13 @@ val groupId = "org.scala-steward"
 val projectName = "scala-steward"
 val rootPkg = groupId.replace("-", "")
 val gitHubOwner = "scala-steward-org"
+val gitHubUrl = s"https://github.com/$gitHubOwner/$projectName"
+val mainBranch = "master"
 
 val moduleCrossPlatformMatrix: Map[String, List[Platform]] = Map(
   "benchmark" -> List(JVMPlatform),
   "core" -> List(JVMPlatform),
+  "docs" -> List(JVMPlatform),
   "sbt-plugin" -> List(JVMPlatform),
   "mill-plugin" -> List(JVMPlatform)
 )
@@ -23,7 +26,7 @@ val Scala213 = "2.13.5"
 ThisBuild / crossScalaVersions := Seq(Scala212, Scala213)
 ThisBuild / githubWorkflowTargetTags ++= Seq("v*")
 ThisBuild / githubWorkflowPublishTargetBranches := Seq(
-  RefPredicate.Equals(Ref.Branch("master")),
+  RefPredicate.Equals(Ref.Branch(mainBranch)),
   RefPredicate.StartsWith(Ref.Tag("v"))
 )
 ThisBuild / githubWorkflowPublish := Seq(
@@ -49,14 +52,17 @@ ThisBuild / githubWorkflowJavaVersions := Seq("adopt@1.8", "adopt@1.11")
 ThisBuild / githubWorkflowBuild :=
   Seq(
     WorkflowStep.Sbt(List("validate"), name = Some("Build project")),
-    WorkflowStep.Use(UseRef.Public("codecov", "codecov-action", "v1"), name = Some("Codecov"))
+    WorkflowStep.Use(
+      UseRef.Public("codecov", "codecov-action", "v2"),
+      name = Some("Codecov")
+    )
   )
 
 /// projects
 
 lazy val root = project
   .in(file("."))
-  .aggregate(benchmark.jvm, core.jvm, `sbt-plugin`.jvm, `mill-plugin`.jvm)
+  .aggregate(benchmark.jvm, core.jvm, docs.jvm, `sbt-plugin`.jvm, `mill-plugin`.jvm)
   .settings(commonSettings)
   .settings(noPublishSettings)
 
@@ -135,7 +141,9 @@ lazy val core = myCrossProject("core")
       scalaVersion,
       scalaBinaryVersion,
       sbtVersion,
-      BuildInfoKey.map(git.gitHeadCommit) { case (k, v) => k -> v.getOrElse("master") },
+      BuildInfoKey("gitHubUrl" -> gitHubUrl),
+      BuildInfoKey("mainBranch" -> mainBranch),
+      BuildInfoKey.map(git.gitHeadCommit) { case (k, v) => k -> v.getOrElse(mainBranch) },
       BuildInfoKey.map(`sbt-plugin`.jvm / moduleRootPkg) { case (_, v) =>
         "sbtPluginModuleRootPkg" -> v
       },
@@ -178,6 +186,34 @@ lazy val core = myCrossProject("core")
     Compile / unmanagedResourceDirectories ++= (`sbt-plugin`.jvm / Compile / unmanagedSourceDirectories).value
   )
 
+lazy val docs = myCrossProject("docs")
+  .dependsOn(core)
+  .enablePlugins(MdocPlugin)
+  .settings(noPublishSettings)
+  .settings(
+    mdocIn := baseDirectory.value / ".." / "mdoc",
+    mdocOut := (LocalRootProject / baseDirectory).value / "docs",
+    mdocVariables := Map(
+      "GITHUB_URL" -> gitHubUrl,
+      "MAIN_BRANCH" -> mainBranch
+    ),
+    checkDocs := {
+      val inDir = mdocIn.value.getCanonicalPath
+      val outDir = mdocOut.value.getCanonicalPath
+      val rootDir = (LocalRootProject / baseDirectory).value
+      try git.runner.value.apply("diff", "--quiet", outDir)(rootDir, streams.value.log)
+      catch {
+        case t: Throwable =>
+          val msg = s"Docs in $inDir and $outDir are out of sync." +
+            " Run 'sbt docs/mdoc' and commit the changes to fix this."
+          throw new Throwable(msg, t)
+      }
+      ()
+    },
+    coverageEnabled := false,
+    unusedCompileDependencies := Set.empty
+  )
+
 lazy val `sbt-plugin` = myCrossProject("sbt-plugin")
   .settings(noPublishSettings)
   .settings(
@@ -230,12 +266,10 @@ lazy val compileSettings = Def.settings(
 lazy val metadataSettings = Def.settings(
   name := projectName,
   organization := groupId,
-  homepage := Some(url(s"https://github.com/$gitHubOwner/$projectName")),
+  homepage := Some(url(gitHubUrl)),
   startYear := Some(2018),
   licenses := List("Apache-2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")),
-  scmInfo := Some(
-    ScmInfo(homepage.value.get, s"scm:git:https://github.com/$gitHubOwner/$projectName.git")
-  ),
+  scmInfo := Some(ScmInfo(homepage.value.get, s"scm:git:$gitHubUrl.git")),
   headerLicense := Some(HeaderLicense.ALv2("2018-2021", "Scala Steward contributors")),
   developers := List(
     Developer(
@@ -251,23 +285,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}",
@@ -282,7 +315,7 @@ lazy val noPublishSettings = Def.settings(
 lazy val scaladocSettings = Def.settings(
   Compile / doc / scalacOptions ++= {
     val tag = s"v${version.value}"
-    val tree = if (isSnapshot.value) git.gitHeadCommit.value.getOrElse("master") else tag
+    val tree = if (isSnapshot.value) git.gitHeadCommit.value.getOrElse(mainBranch) else tag
     Seq(
       "-doc-source-url",
       s"${scmInfo.value.get.browseUrl}/blob/$tree€{FILE_PATH}.scala",
@@ -294,6 +327,8 @@ lazy val scaladocSettings = Def.settings(
 
 /// setting keys
 
+lazy val checkDocs = taskKey[Unit]("")
+
 lazy val installPlugin = taskKey[Unit]("Copies StewardPlugin.scala into global plugins directory.")
 installPlugin := {
   val name = "StewardPlugin.scala"
@@ -346,6 +381,8 @@ addCommandsAlias(
     "test",
     "coverageReport",
     "doc",
+    "docs/mdoc",
+    "docs/checkDocs",
     "package",
     "packageSrc",
     "core/assembly",
diff --git a/docs/faq.md b/docs/faq.md
index 00b191cc..e7bb041d 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -46,7 +46,7 @@ Updates for `sbt` and `scalafmt` can be controlled by using the following `group
 ## Can Scala Steward update multiple branches in a repository?
 
 Yes! You can update multiple branches of a repository by adding it several times to the "repos.md" file
-and a suffix with a different branch on each occurence (`owner/repo:branch`). For example:
+and a suffix with a different branch on each occurrence (`owner/repo:branch`). For example:
 
 ```md
 // repos.md
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/docs/running.md b/docs/running.md
index 733d2120..5519b270 100644
--- a/docs/running.md
+++ b/docs/running.md
@@ -41,7 +41,7 @@ The [`git-ask-pass` option](https://git-scm.com/docs/gitcredentials) must specif
 - either the plain text password corresponding to the configured `${LOGIN}`,
 - or (recommended) an authentication token corresponding to `${LOGIN}` (with appropriate permissions to watch the repositories; e.g. [Create a personal access token](https://help.github.com/en/articles/creating-a-personal-access-token-for-the-command-line) for GitHub).
 
-**Note about git-ask-pass option**: The provided script must start with a valid shebang like `#!/bin/sh`, see issue [#1374](/../../issues/1374)
+**Note about git-ask-pass option**: The provided script must start with a valid shebang like `#!/bin/sh`, see issue [#1374](https://github.com/scala-steward-org/scala-steward/issues/1374)
 
 More information about using the `--scalafix-migrations` and `--artifact-migrations` options can be found 
 [here][scalafixmigrations] and [here][artifactmigrations].
diff --git a/modules/core/src/main/resources/artifact-migrations.conf b/modules/core/src/main/resources/artifact-migrations.conf
index 86cc6411..ca04eaf4 100644
--- a/modules/core/src/main/resources/artifact-migrations.conf
+++ b/modules/core/src/main/resources/artifact-migrations.conf
@@ -250,5 +250,11 @@ changes = [
     groupIdAfter = org.polyvariant
     artifactIdAfter = better-tostring
     initialVersion = 0.3.8
+  },
+  {
+    groupIdBefore = com.novocode
+    groupIdAfter = com.github.sbt
+    artifactIdAfter = junit-interface
+    initialVersion = 0.13.2
   }
 ]
diff --git a/modules/core/src/main/resources/scalafix-migrations.conf b/modules/core/src/main/resources/scalafix-migrations.conf
index 2bf1108c..067fd34d 100644
--- a/modules/core/src/main/resources/scalafix-migrations.conf
+++ b/modules/core/src/main/resources/scalafix-migrations.conf
@@ -5,6 +5,20 @@ migrations = [
     newVersion: "1.0.0",
     rewriteRules: ["github:functional-streams-for-scala/fs2/v1?sha=v1.0.5"]
   },
+  {
+    groupId: "co.fs2",
+    artifactIds: ["fs2-.*"],
+    newVersion: "3.0.7",
+    // Note that the replace rule can generate broken code in some cases.
+    // See https://github.com/scalacenter/scalafix/issues/1168 for details.
+    rewriteRules: [
+      "replace:fs2.text.utf8Decode/fs2.text.utf8.decode",
+      "replace:fs2.text.utf8DecodeC/fs2.text.utf8.decodeC",
+      "replace:fs2.text.utf8Encode/fs2.text.utf8.encode",
+      "replace:fs2.text.utf8EncodeC/fs2.text.utf8.encodeC"
+    ],
+    executionOrder: "post-update"
+  },
   {
     groupId: "com.github.fd4s",
     artifactIds: ["fs2-kafka"],
@@ -89,6 +103,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..67218137 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
@@ -16,11 +16,13 @@
 
 package org.scalasteward.core.application
 
+import cats.ApplicativeThrow
 import cats.effect._
 import cats.effect.implicits._
 import cats.syntax.all._
 import org.http4s.Uri
 import org.http4s.client.Client
+import org.http4s.headers.`User-Agent`
 import org.http4s.okhttp.client.OkHttpBuilder
 import org.scalasteward.core.buildtool.BuildToolDispatcher
 import org.scalasteward.core.buildtool.maven.MavenAlg
@@ -29,7 +31,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 +63,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],
@@ -79,7 +82,11 @@ object Context {
       logger <- Resource.eval(Slf4jLogger.fromName[F]("org.scalasteward.core"))
       _ <- Resource.eval(printBanner(logger))
       config = Config.from(args)
-      client <- OkHttpBuilder.withDefaultClient[F].flatMap(_.resource)
+      userAgent <- Resource.eval(mkUserAgent[F](config.vcsCfg.login))
+      client <- OkHttpBuilder
+        .withDefaultClient[F]
+        .flatMap(_.resource)
+        .map(c => Client[F](req => c.run(req.putHeaders(userAgent))))
       fileAlg = FileAlg.create(logger, F)
       processAlg = ProcessAlg.create(config.processCfg)(logger, F)
       workspaceAlg = WorkspaceAlg.create(config)(fileAlg, logger, F)
@@ -131,6 +138,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]
@@ -164,4 +172,10 @@ object Context {
       .mkString(System.lineSeparator())
     logger.info(msg)
   }
+
+  private def mkUserAgent[F[_]](login: String)(implicit F: ApplicativeThrow[F]): F[`User-Agent`] = {
+    val s = s"Scala-Steward/${org.scalasteward.core.BuildInfo.version}" +
+      s" (operated by $login; ${org.scalasteward.core.BuildInfo.gitHubUrl})"
+    F.fromEither(`User-Agent`.parse(s))
+  }
 }
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/coursier/CoursierAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala
index 2fede563..2053bac3 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/coursier/CoursierAlg.scala
@@ -140,5 +140,5 @@ object CoursierAlg {
     }
 
   private def getScmUrlOrHomePage(info: Info): Option[Uri] =
-    (info.scm.flatMap(_.url).toList :+ info.homePage).flatMap(uri.browsableUriFromString).headOption
+    uri.findBrowsableUrl(info.scm.flatMap(_.url).toList :+ info.homePage)
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/SemVer.scala b/modules/core/src/main/scala/org/scalasteward/core/data/SemVer.scala
index 6bcddc99..c65a4437 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/SemVer.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/SemVer.scala
@@ -19,6 +19,8 @@ package org.scalasteward.core.data
 import cats.syntax.all._
 import org.scalasteward.core.data.SemVer.Change._
 
+import scala.annotation.tailrec
+
 final case class SemVer(
     major: String,
     minor: String,
@@ -42,11 +44,30 @@ object SemVer {
     case object BuildMetadata extends Change("build-metadata")
   }
 
-  def getChange(from: SemVer, to: SemVer): Option[Change] =
+  def getChangeSpec(from: SemVer, to: SemVer): Option[Change] =
     if (from.major =!= to.major) Some(Major)
     else if (from.minor =!= to.minor) Some(Minor)
     else if (from.preRelease =!= to.preRelease) Some(PreRelease)
     else if (from.patch =!= to.patch) Some(Patch)
     else if (from.buildMetadata =!= to.buildMetadata) Some(BuildMetadata)
     else None
+
+  @tailrec
+  def getChangeEarly(from: SemVer, to: SemVer): Option[Change] = {
+    val zero = "0"
+    // Codacy doesn't allow using `if`s, so using `match` instead
+    (from.major === zero, to.major === zero) match { // work around Codacy's "Consider using case matching instead of else if blocks"
+      case (true, true)
+          if from.minor =!= zero ||
+            to.minor =!= zero ||
+            from.patch =!= zero ||
+            to.patch =!= zero =>
+        getChangeEarly(
+          from.copy(major = from.minor, minor = from.patch, patch = zero),
+          to.copy(major = to.minor, minor = to.patch, patch = zero)
+        )
+      case _ =>
+        getChangeSpec(from, to)
+    }
+  }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/EditAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/EditAlg.scala
index 005283eb..11b355b2 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/EditAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/EditAlg.scala
@@ -27,7 +27,9 @@ import org.scalasteward.core.edit.scalafix.{ScalafixMigration, ScalafixMigration
 import org.scalasteward.core.git
 import org.scalasteward.core.git.GitAlg
 import org.scalasteward.core.io.{isSourceFile, FileAlg, WorkspaceAlg}
+import org.scalasteward.core.repocache.RepoCache
 import org.scalasteward.core.repoconfig.RepoConfig
+import org.scalasteward.core.scalafmt.{scalafmtModule, ScalafmtAlg}
 import org.scalasteward.core.util._
 import org.scalasteward.core.util.logger._
 import org.scalasteward.core.vcs.data.Repo
@@ -40,6 +42,7 @@ final class EditAlg[F[_]](implicit
     hookExecutor: HookExecutor[F],
     logger: Logger[F],
     scalafixMigrationsFinder: ScalafixMigrationsFinder,
+    scalafmtAlg: ScalafmtAlg[F],
     workspaceAlg: WorkspaceAlg[F],
     F: Concurrent[F]
 ) {
@@ -58,19 +61,21 @@ final class EditAlg[F[_]](implicit
             for {
               _ <- preCommit
               repo = data.repo
-              migrations = scalafixMigrationsFinder.findMigrations(update)
-              scalafixEdits <-
-                if (migrations.isEmpty) F.pure(Nil)
+              (preMigrations, postMigrations) = scalafixMigrationsFinder.findMigrations(update)
+              preScalafixEdits <-
+                if (preMigrations.isEmpty) F.pure(Nil)
                 else
                   gitAlg.discardChanges(repo) *>
-                    runScalafixMigrations(repo, data.config, migrations) <*
+                    runScalafixMigrations(repo, data.config, preMigrations) <*
                     bumpVersion(update, files)
+              _ <- reformatChangedFiles(repo, data.cache)
               updateCommitMsg = git.commitMsgFor(update, data.config.commits, data.repo.branch)
               updateEdit <- gitAlg
                 .commitAllIfDirty(repo, updateCommitMsg)
                 .map(_.map(commit => UpdateEdit(update, commit)))
+              postScalafixEdits <- runScalafixMigrations(repo, data.config, postMigrations)
               hooksEdits <- hookExecutor.execPostUpdateHooks(data, update)
-            } yield scalafixEdits ++ updateEdit ++ hooksEdits
+            } yield preScalafixEdits ++ updateEdit ++ postScalafixEdits ++ hooksEdits
         }
     }
 
@@ -111,4 +116,11 @@ final class EditAlg[F[_]](implicit
     }
     bindUntilTrue[Nel, F](actions)
   }
+
+  private def reformatChangedFiles(repo: Repo, cache: RepoCache): F[Unit] =
+    if (cache.dependsOn(List(scalafmtModule)))
+      logger.attemptWarn.log_("Reformatting changed files failed") {
+        scalafmtAlg.reformatChanged(repo)
+      }
+    else F.unit
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/HookExecutor.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/HookExecutor.scala
index 7d00b48d..ed3bc310 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/HookExecutor.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/HookExecutor.scala
@@ -25,7 +25,7 @@ import org.scalasteward.core.edit.EditAttempt.HookEdit
 import org.scalasteward.core.git.{CommitMsg, GitAlg}
 import org.scalasteward.core.io.{ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.repocache.RepoCache
-import org.scalasteward.core.scalafmt.{scalafmtArtifactId, scalafmtBinary, scalafmtGroupId}
+import org.scalasteward.core.scalafmt.{scalafmtArtifactId, scalafmtGroupId, ScalafmtAlg}
 import org.scalasteward.core.util.Nel
 import org.scalasteward.core.util.logger._
 import org.scalasteward.core.vcs.data.Repo
@@ -75,12 +75,6 @@ object HookExecutor {
   private val conditionalSbtGitHubActionsModules =
     (sbtGroupId, sbtArtifactId) :: scalaLangModules
 
-  private def dependsOnSbtGithubActions(cache: RepoCache): Boolean =
-    cache.dependencyInfos.exists(_.value.exists { info =>
-      val module = (info.dependency.groupId, info.dependency.artifactId)
-      sbtGitHubActionsModules.contains(module)
-    })
-
   private def sbtGithubActionsHook(
       groupId: GroupId,
       artifactId: ArtifactId,
@@ -100,7 +94,7 @@ object HookExecutor {
     PostUpdateHook(
       groupId = scalafmtGroupId,
       artifactId = scalafmtArtifactId,
-      command = Nel.of(scalafmtBinary, "--non-interactive"),
+      command = ScalafmtAlg.postUpdateHookCommand,
       useSandbox = false,
       commitMessage = update => CommitMsg(s"Reformat with scalafmt ${update.nextVersion}"),
       enabledByCache = _ => true,
@@ -110,6 +104,6 @@ object HookExecutor {
         sbtGithubActionsHook(gid, aid, _ => true)
       } ++
       conditionalSbtGitHubActionsModules.map { case (gid, aid) =>
-        sbtGithubActionsHook(gid, aid, dependsOnSbtGithubActions)
+        sbtGithubActionsHook(gid, aid, _.dependsOn(sbtGitHubActionsModules))
       }
 }
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..108dafc6 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
@@ -16,12 +16,14 @@
 
 package org.scalasteward.core.edit.scalafix
 
+import cats.Eq
 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.{ExecutionOrder, Target}
 import org.scalasteward.core.git.{Author, CommitMsg}
-import org.scalasteward.core.util.Nel
+import org.scalasteward.core.util.{unexpectedString, Nel}
 
 final case class ScalafixMigration(
     groupId: GroupId,
@@ -30,7 +32,9 @@ 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,
+    executionOrder: Option[ExecutionOrder] = None
 ) {
   def commitMessage(result: Either[Throwable, Unit]): CommitMsg = {
     val verb = if (result.isRight) "Applied" else "Failed"
@@ -38,9 +42,44 @@ final case class ScalafixMigration(
     val body = doc.map(url => s"See $url for details")
     CommitMsg(title, body.toList, authors.foldMap(_.toList))
   }
+
+  def executionOrderOrDefault: ExecutionOrder =
+    executionOrder.getOrElse(ExecutionOrder.PreUpdate)
+
+  def targetOrDefault: Target =
+    target.getOrElse(Target.Sources)
 }
 
 object ScalafixMigration {
+  sealed trait ExecutionOrder extends Product with Serializable
+  object ExecutionOrder {
+    case object PreUpdate extends ExecutionOrder
+    case object PostUpdate extends ExecutionOrder
+
+    implicit val executionOrderEq: Eq[ExecutionOrder] =
+      Eq.fromUniversalEquals
+
+    implicit val executionOrderDecoder: Decoder[ExecutionOrder] =
+      Decoder[String].emap {
+        case "pre-update"  => Right(PreUpdate)
+        case "post-update" => Right(PostUpdate)
+        case s             => unexpectedString(s, List("pre-update", "post-update"))
+      }
+  }
+
+  sealed trait Target extends Product with Serializable
+  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 s         => unexpectedString(s, List("sources", "build"))
+      }
+  }
+
   implicit val scalafixMigrationDecoder: Decoder[ScalafixMigration] =
     deriveDecoder
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsFinder.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsFinder.scala
index 5eef38b4..bb59bc05 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsFinder.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsFinder.scala
@@ -18,15 +18,18 @@ package org.scalasteward.core.edit.scalafix
 
 import cats.syntax.all._
 import org.scalasteward.core.data.{Update, Version}
+import org.scalasteward.core.edit.scalafix.ScalafixMigration.ExecutionOrder
 
 final class ScalafixMigrationsFinder(migrations: List[ScalafixMigration]) {
-  def findMigrations(update: Update): List[ScalafixMigration] =
-    migrations.filter { migration =>
-      update.groupId === migration.groupId &&
-      migration.artifactIds.exists { re =>
-        update.artifactIds.exists(artifactId => re.r.findFirstIn(artifactId.name).isDefined)
-      } &&
-      Version(update.currentVersion) < migration.newVersion &&
-      Version(update.nextVersion) >= migration.newVersion
-    }
+  def findMigrations(update: Update): (List[ScalafixMigration], List[ScalafixMigration]) =
+    migrations
+      .filter { migration =>
+        update.groupId === migration.groupId &&
+        migration.artifactIds.exists { re =>
+          update.artifactIds.exists(artifactId => re.r.findFirstIn(artifactId.name).isDefined)
+        } &&
+        Version(update.currentVersion) < migration.newVersion &&
+        Version(update.nextVersion) >= migration.newVersion
+      }
+      .partition(_.executionOrderOrDefault === ExecutionOrder.PreUpdate)
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoader.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoader.scala
index a4d981ed..41064c9d 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoader.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsLoader.scala
@@ -20,7 +20,6 @@ import cats.MonadThrow
 import cats.syntax.all._
 import io.circe.config.parser.decode
 import org.http4s.Uri
-import org.http4s.implicits.http4sLiteralsSyntax
 import org.scalasteward.core.application.Config.ScalafixCfg
 import org.scalasteward.core.edit.scalafix.ScalafixMigrationsLoader._
 import org.scalasteward.core.io.FileAlg
@@ -52,6 +51,7 @@ final class ScalafixMigrationsLoader[F[_]](implicit
 }
 
 object ScalafixMigrationsLoader {
-  val defaultScalafixMigrationsUrl: Uri =
-    uri"https://raw.githubusercontent.com/scala-steward-org/scala-steward/master/modules/core/src/main/resources/scalafix-migrations.conf"
+  val defaultScalafixMigrationsUrl: Uri = Uri.unsafeFromString(
+    s"https://raw.githubusercontent.com/scala-steward-org/scala-steward/${org.scalasteward.core.BuildInfo.mainBranch}/modules/core/src/main/resources/scalafix-migrations.conf"
+  )
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala
index 32fc59dd..98e57cb5 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala
@@ -37,6 +37,9 @@ final class FileGitAlg[F[_]](config: GitCfg)(implicit
   override def branchExists(repo: File, branch: Branch): F[Boolean] =
     git("branch", "--list", "--no-color", "--all", branch.name)(repo).map(_.mkString.trim.nonEmpty)
 
+  override def branchesDiffer(repo: File, b1: Branch, b2: Branch): F[Boolean] =
+    git("diff", "--name-only", b1.name, b2.name)(repo).map(_.nonEmpty)
+
   override def checkoutBranch(repo: File, branch: Branch): F[Unit] =
     git("checkout", branch.name)(repo).void
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala
index 9aa0744f..3a61c1e6 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala
@@ -28,6 +28,8 @@ trait GenGitAlg[F[_], Repo] {
 
   def branchExists(repo: Repo, branch: Branch): F[Boolean]
 
+  def branchesDiffer(repo: Repo, b1: Branch, b2: Branch): F[Boolean]
+
   def checkoutBranch(repo: Repo, branch: Branch): F[Unit]
 
   def clone(repo: Repo, url: Uri): F[Unit]
@@ -88,6 +90,9 @@ trait GenGitAlg[F[_], Repo] {
       override def branchExists(repo: A, branch: Branch): F[Boolean] =
         f(repo).flatMap(self.branchExists(_, branch))
 
+      override def branchesDiffer(repo: A, b1: Branch, b2: Branch): F[Boolean] =
+        f(repo).flatMap(self.branchesDiffer(_, b1, b2))
+
       override def checkoutBranch(repo: A, branch: Branch): F[Unit] =
         f(repo).flatMap(self.checkoutBranch(_, branch))
 
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/nurture/NurtureAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala
index 9974843f..ed9733dd 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala
@@ -156,7 +156,11 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
       editAlg.applyUpdate(data.repoData, data.update, createBranch).flatMap { edits =>
         val editCommits = edits.flatMap(_.maybeCommit)
         if (editCommits.isEmpty) logger.warn("No commits created").as(Ignored)
-        else pushCommits(data, editCommits) >> createPullRequest(data, edits)
+        else
+          gitAlg.branchesDiffer(data.repo, data.baseBranch, data.updateBranch).flatMap {
+            case true  => pushCommits(data, editCommits) >> createPullRequest(data, edits)
+            case false => logger.warn("No diff between base and update branch").as(Ignored)
+          }
       }
     }
 
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/modules/core/src/main/scala/org/scalasteward/core/repocache/RepoCache.scala b/modules/core/src/main/scala/org/scalasteward/core/repocache/RepoCache.scala
index 74df4a23..2556d82e 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repocache/RepoCache.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repocache/RepoCache.scala
@@ -16,9 +16,10 @@
 
 package org.scalasteward.core.repocache
 
+import cats.syntax.all._
 import io.circe.Codec
 import io.circe.generic.semiauto._
-import org.scalasteward.core.data.{DependencyInfo, Scope}
+import org.scalasteward.core.data.{ArtifactId, DependencyInfo, GroupId, Scope}
 import org.scalasteward.core.git.Sha1
 import org.scalasteward.core.repoconfig.RepoConfig
 
@@ -26,7 +27,15 @@ final case class RepoCache(
     sha1: Sha1,
     dependencyInfos: List[Scope[List[DependencyInfo]]],
     maybeRepoConfig: Option[RepoConfig]
-)
+) {
+  def dependsOn(modules: List[(GroupId, ArtifactId)]): Boolean =
+    dependencyInfos.exists(_.value.exists { info =>
+      modules.exists { case (groupId, artifactId) =>
+        info.dependency.groupId === groupId &&
+          info.dependency.artifactId.name === artifactId.name
+      }
+    })
+}
 
 object RepoCache {
   implicit val repoCacheCodec: Codec[RepoCache] =
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 66fdf3c6..c509e3af 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
@@ -23,9 +23,9 @@ import io.circe.ParsingFailure
 import org.scalasteward.core.application.Config
 import org.scalasteward.core.data.{Scope, Version}
 import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
-import org.scalasteward.core.scalafmt.ScalafmtAlg.parseScalafmtConf
+import org.scalasteward.core.scalafmt.ScalafmtAlg.{opts, parseScalafmtConf}
 import org.scalasteward.core.util.Nel
-import org.scalasteward.core.vcs.data.BuildRoot
+import org.scalasteward.core.vcs.data.{BuildRoot, Repo}
 import org.typelevel.log4cats.Logger
 
 final class ScalafmtAlg[F[_]](config: Config)(implicit
@@ -51,13 +51,28 @@ final class ScalafmtAlg[F[_]](config: Config)(implicit
       .map(version => Scope(List(scalafmtDependency(version)), List(config.defaultResolver)))
       .value
 
-  def version: F[String] =
-    workspaceAlg.rootDir
-      .flatMap(processAlg.exec(Nel.of(scalafmtBinary, "--version"), _))
-      .map(_.mkString.trim)
+  def reformatChanged(repo: Repo): F[Unit] = {
+    val cmd = Nel.of(scalafmtBinary, opts.nonInteractive, opts.quiet) ++ opts.modeChanged
+    workspaceAlg.repoDir(repo).flatMap(processAlg.exec(cmd, _)).void
+  }
+
+  def version: F[String] = {
+    val cmd = Nel.of(scalafmtBinary, opts.version)
+    workspaceAlg.rootDir.flatMap(processAlg.exec(cmd, _)).map(_.mkString.trim)
+  }
 }
 
 object ScalafmtAlg {
+  object opts {
+    val modeChanged = List("--mode", "changed")
+    val nonInteractive = "--non-interactive"
+    val quiet = "--quiet"
+    val version = "--version"
+  }
+
+  val postUpdateHookCommand: Nel[String] =
+    Nel.of(scalafmtBinary, opts.nonInteractive, opts.quiet)
+
   private[scalafmt] def parseScalafmtConf(s: String): Either[ParsingFailure, Option[Version]] =
     io.circe.config.parser.parse(s).map {
       _.asObject.flatMap(_.apply("version")).flatMap(_.asString).map(Version.apply)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/scalafmt/package.scala b/modules/core/src/main/scala/org/scalasteward/core/scalafmt/package.scala
index 5079f7c0..ec38a72e 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/scalafmt/package.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/scalafmt/package.scala
@@ -29,6 +29,9 @@ package object scalafmt {
     ArtifactId(core, s"${core}_$defaultScalaBinaryVersion")
   }
 
+  val scalafmtModule: (GroupId, ArtifactId) =
+    (scalafmtGroupId, scalafmtArtifactId)
+
   def isScalafmtUpdate(update: Update.Single): Boolean =
     update.groupId === scalafmtGroupId && update.artifactId.name === scalafmtArtifactId.name
 
@@ -40,5 +43,5 @@ package object scalafmt {
 
   val scalafmtBinary: String = "scalafmt"
 
-  val scalafmtConfName = ".scalafmt.conf"
+  val scalafmtConfName: String = ".scalafmt.conf"
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoader.scala b/modules/core/src/main/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoader.scala
index 057ff472..5a2a59a9 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoader.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/artifact/ArtifactMigrationsLoader.scala
@@ -20,7 +20,6 @@ import cats.MonadThrow
 import cats.syntax.all._
 import io.circe.config.parser.decode
 import org.http4s.Uri
-import org.http4s.implicits.http4sLiteralsSyntax
 import org.scalasteward.core.application.Config.ArtifactCfg
 import org.scalasteward.core.io.FileAlg
 import org.scalasteward.core.update.artifact.ArtifactMigrationsLoader.defaultArtifactMigrationsUrl
@@ -52,6 +51,7 @@ final class ArtifactMigrationsLoader[F[_]](implicit
 }
 
 object ArtifactMigrationsLoader {
-  val defaultArtifactMigrationsUrl: Uri =
-    uri"https://raw.githubusercontent.com/scala-steward-org/scala-steward/master/modules/core/src/main/resources/artifact-migrations.conf"
+  val defaultArtifactMigrationsUrl: Uri = Uri.unsafeFromString(
+    s"https://raw.githubusercontent.com/scala-steward-org/scala-steward/${org.scalasteward.core.BuildInfo.mainBranch}/modules/core/src/main/resources/artifact-migrations.conf"
+  )
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/util/package.scala b/modules/core/src/main/scala/org/scalasteward/core/util/package.scala
index b4d796d7..f2410264 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/util/package.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/util/package.scala
@@ -87,4 +87,7 @@ package object util {
       _.mapAccumulate(init) { case (i, a) => (N.plus(i, weight(a)), a) }
         .takeThrough { case (total, _) => N.lt(total, limit) }
         .map { case (_, a) => a }
+
+  def unexpectedString(s: String, expected: List[String]): Left[String, Nothing] =
+    Left(s"Unexpected string '$s'. Expected one of: ${expected.mkString(", ")}.")
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/util/uri.scala b/modules/core/src/main/scala/org/scalasteward/core/util/uri.scala
index 8cb6b47c..4889d70c 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/util/uri.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/util/uri.scala
@@ -20,7 +20,7 @@ import cats.syntax.all._
 import io.circe.{Decoder, KeyDecoder, KeyEncoder}
 import monocle.Optional
 import org.http4s.Uri
-import org.http4s.Uri.{Authority, UserInfo}
+import org.http4s.Uri.{Authority, Scheme, UserInfo}
 
 object uri {
   implicit val uriDecoder: Decoder[Uri] =
@@ -41,7 +41,11 @@ object uri {
   val withUserInfo: Optional[Uri, UserInfo] =
     authorityWithUserInfo.compose(withAuthority)
 
-  def browsableUriFromString(s: String): Option[Uri] =
-    if (s.isEmpty || s.startsWith("git@") || s.startsWith("git:")) None
-    else Uri.fromString(s).toOption.filter(_.scheme.isDefined)
+  private val httpSchemes: Set[Scheme] =
+    Set(Scheme.https, Scheme.http)
+
+  def findBrowsableUrl(xs: List[String]): Option[Uri] = {
+    val urls = xs.flatMap(Uri.fromString(_).toList).filter(_.scheme.isDefined)
+    urls.find(_.scheme.exists(httpSchemes)).orElse(urls.headOption)
+  }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala
index 65b153e1..e92c3f9a 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSType.scala
@@ -19,6 +19,7 @@ package org.scalasteward.core.vcs
 import cats.Eq
 import cats.syntax.all._
 import org.http4s.syntax.literals._
+import org.scalasteward.core.util.unexpectedString
 import org.scalasteward.core.vcs.VCSType._
 
 sealed trait VCSType {
@@ -57,8 +58,7 @@ object VCSType {
   def parse(s: String): Either[String, VCSType] =
     all.find(_.asString === s) match {
       case Some(value) => Right(value)
-      case None =>
-        Left(s"Unexpected string '$s'. Expected one of: ${all.map(_.asString).mkString(", ")}.")
+      case None        => unexpectedString(s, all.map(_.asString))
     }
 
   def fromPublicWebHost(host: String): Option[VCSType] =
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/json.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/json.scala
index 77166f56..0b6eb011 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/json.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/json.scala
@@ -19,6 +19,7 @@ package org.scalasteward.core.vcs.bitbucket
 import io.circe.Decoder
 import org.http4s.Uri
 import org.scalasteward.core.git.{Branch, Sha1}
+import org.scalasteward.core.util.unexpectedString
 import org.scalasteward.core.util.uri.uriDecoder
 import org.scalasteward.core.vcs.data._
 
@@ -34,7 +35,7 @@ private[bitbucket] object json {
     Decoder[String].emap {
       case "OPEN"                               => Right(PullRequestState.Open)
       case "MERGED" | "SUPERSEDED" | "DECLINED" => Right(PullRequestState.Closed)
-      case unknown                              => Left(s"Unexpected string '$unknown'")
+      case s => unexpectedString(s, List("OPEN", "MERGED", "SUPERSEDED", "DECLINED"))
     }
 
   implicit val pullRequestOutDecoder: Decoder[PullRequestOut] = Decoder.instance { c =>
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala
index 8a0cd345..e5252958 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala
@@ -63,7 +63,8 @@ object NewPullRequestData {
       oldVersionDetails.toList ++
       List(ignoreFutureUpdates(update))
     val labels = List(updateType(update)) ++
-      semVerLabel(update).toList ++
+      earlySemVerLabel(update).toList ++
+      semVerSpecLabel(update).toList ++
       migrationLabel.toList ++
       oldVersionLabel.toList
 
@@ -188,12 +189,19 @@ object NewPullRequestData {
       )
     }
 
-  def semVerLabel(update: Update): Option[String] =
+  def earlySemVerLabel(update: Update): Option[String] =
     for {
       curr <- SemVer.parse(update.currentVersion)
       next <- SemVer.parse(update.nextVersion)
-      change <- SemVer.getChange(curr, next)
-    } yield s"semver-${change.render}"
+      change <- SemVer.getChangeEarly(curr, next)
+    } yield s"early-semver-${change.render}"
+
+  def semVerSpecLabel(update: Update): Option[String] =
+    for {
+      curr <- SemVer.parse(update.currentVersion)
+      next <- SemVer.parse(update.nextVersion)
+      change <- SemVer.getChangeSpec(curr, next)
+    } yield s"semver-spec-${change.render}"
 
   def from(
       data: UpdateData,
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/data/PullRequestState.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/PullRequestState.scala
index 9a184028..439749c1 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/data/PullRequestState.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/PullRequestState.scala
@@ -18,6 +18,7 @@ package org.scalasteward.core.vcs.data
 
 import cats.Eq
 import io.circe.{Decoder, Encoder}
+import org.scalasteward.core.util.unexpectedString
 import org.scalasteward.core.vcs.data.PullRequestState.{Closed, Open}
 
 sealed trait PullRequestState {
@@ -40,7 +41,7 @@ object PullRequestState {
       _.toLowerCase match {
         case "open" | "opened"                => Right(Open)
         case "closed" | "merged" | "declined" => Right(Closed)
-        case unknown                          => Left(s"Unexpected string '$unknown'")
+        case s => unexpectedString(s, List("open", "opened", "closed", "merged", "declined"))
       }
     }
 
diff --git a/modules/docs/mdoc/artifact-migrations.md b/modules/docs/mdoc/artifact-migrations.md
new file mode 100644
index 00000000..6a2c37a5
--- /dev/null
+++ b/modules/docs/mdoc/artifact-migrations.md
@@ -0,0 +1,35 @@
+# Artifact Migrations
+
+Scala Steward can look for newer versions of artifacts with different group Ids, artifact ids, or both different.
+
+## Adding artifact migrations to Scala Steward
+
+By default, Scala Steward applies the artifact migrations defined in the
+[default list][migrations]. When running Scala Steward you can also specify
+files or URLs (via the `--artifact-migrations` command-line option) that
+contain additional migrations. These files are in [HOCON][HOCON] format and
+should look like this:
+```hocon
+changes = [
+  {
+    groupIdBefore = com.geirsson
+    groupIdAfter = org.scalameta
+    artifactIdAfter = sbt-scalafmt
+    initialVersion = 2.0.0
+  }
+]
+```
+In this example, scala-steward will look in the project for dependencies with the before group id, "com.geirsson", and 
+artifact id "sbt-scalafmt". If found, scala-steward will search for updates using the after group id, "org.scalameta", 
+artifact id "sbt-scalafmt", and version greater than the initial version, 2.0.0. If found, scala-steward will update
+to the after group id and latest version.
+
+The fields `groupIdBefore` and `artifactIdBefore` are optional. If just `groupIdBefore` is specified, as in the previous
+example, then only the group id will get renamed. If just `artifactIdBefore` is specified, then only the artifact id
+will get renamed. Specifying both `groupIdBefore` and `artifactIdBefore` will rename both.
+
+Pull requests that added artifact migrations can be found [here][migration-prs].
+
+[migrations]: @GITHUB_URL@/blob/@MAIN_BRANCH@/modules/core/src/main/resources/artifact-migrations.conf
+[migration-prs]: @GITHUB_URL@/pulls?q=label%3Aartifact-migration
+[HOCON]: https://github.com/lightbend/config/blob/master/HOCON.md
diff --git a/modules/docs/mdoc/faq.md b/modules/docs/mdoc/faq.md
new file mode 100644
index 00000000..3147b53d
--- /dev/null
+++ b/modules/docs/mdoc/faq.md
@@ -0,0 +1,96 @@
+# Frequently Asked Questions
+
+## How does Scala Steward decide what version it is updating to?
+
+Scala Steward first proposes an update to the latest patch version at the
+same minor and major version. If the dependency is already on the latest patch
+version, it proposes an update to the latest minor version at the same major
+version. And if the dependency is already on the latest minor version, it
+proposes an update to the latest major version.
+
+Here is an example. Suppose you are depending on a library foo, which has been
+published for the following versions: 1.0.0, 1.0.1, 1.0.2, 1.1.0, 1.2.0, 2.0.0,
+and 3.0.0.
+
+* If your version is 1.0.0 or 1.0.1, Scala Steward would create a PR updating it to 1.0.2
+* If your version is 1.0.2 or 1.1.0, Scala Steward would create a PR updating it to 1.2.0
+* If your version is 1.2.0 or 2.0.0, Scala Steward would create a PR updating it to 3.0.0
+
+Of course, once you merge a Scala Steward PR, you've updated your version,
+which can result in Scala Steward sending another PR making the next update.
+
+## How can version updates be controlled
+
+You can control the update of individual dependencies or of all dependencies
+belonging to a group by specifying version prefixes for them:
+
+```properties
+updates.pin = [
+  # Allow x.y to x.(y+1), but not to (x+1).y
+  { groupId = "a.group.id", artifactId="a.name", version="x." },
+  # Allow x.y.z to x.y.(z+1), but not to x.(y+1)
+  { groupId = "a.group.id", artifactId="another.name", version="x.y." }
+
+
+  # Fix the major version for all dependencies with this groupId
+  { groupId = "a.group.id", version="x." },
+]
+```
+
+Updates for `sbt` and `scalafmt` can be controlled by using the following `groupId` and `artifactId`:
+```properties
+{ groupId = "org.scala-sbt", artifactId = "sbt" }
+{ groupId = "org.scalameta", artifactId = "scalafmt-core" }
+```
+
+## Can Scala Steward update multiple branches in a repository?
+
+Yes! You can update multiple branches of a repository by adding it several times to the "repos.md" file
+and a suffix with a different branch on each occurrence (`owner/repo:branch`). For example:
+
+```md
+// repos.md
+- owner/repo
+- owner/repo:0.1.x
+- owner/repo:0.2.x
+```
+
+This configuration will update the default branch, as well as the branches `0.1.x` and `0.2.x` in the repo `owner/repo`.
+
+## Can Scala Steward update dependencies in giter8 templates ?
+
+Scala Steward can update versions in giter8 templates if the dependencies of the template 
+are also added as dependencies of the template build.
+An example is [library.g8](https://github.com/ChristopherDavenport/library.g8) ([example PR](https://github.com/ChristopherDavenport/library.g8/pull/100/files))
+
+## Why do Scala Steward updates provide no URLs in PRs?
+
+Scala Steward updates can only provide links to release notes, diffs and changelogs if
+the `ivy.xml` file contain the `homepage` attribute or the `pom.xml` contains either
+a  `scm.url` or an `url` attribute.
+
+## How can Scala Steward's PRs be merged automatically?
+
+You can use [Mergify](https://mergify.io) to automatically merge Scala Steward's
+pull requests. Mergify rules that does this can be found in Scala Steward's own
+repository [here](@GITHUB_URL@/blob/@MAIN_BRANCH@/.mergify.yml).
+Mergify can also be configured to only merge patch updates (in case the version
+number adheres to [Semantic Versioning](https://semver.org/)) as demonstrated
+[here](https://github.com/fthomas/refined/blob/master/.mergify.yml).
+
+Other examples of Mergify rules for Scala Steward can be found via
+[GitHub's code search](https://github.com/search?p=6&q=%22author%3Dscala-steward%22+filename%3A.mergify.yml&type=Code).
+
+An alternative is the [merge PR github action](https://github.com/marketplace/actions/merge-dependency-update-prs)
+which you can include in a GitHub workflow to automatically merge Scala Steward's 
+pull requests.
+
+## Where can Scala Steward's PGP key be found?
+
+Scala Steward signs commits with a PGP key. The fingerprint of that key is
+774C3674392662AE645C9B8396BDF10FFAB8B6A6 and it can be found at
+https://keys.openpgp.org/search?q=me@scala-steward.org.
+
+## How can I change log levels?
+
+If you want to switch from the standard INFO log level you can call scala steward with `-DLOG_LEVEL=DEBUG` for example: `scala-steward -DLOG_LEVEL=DEBUG  --workspace  "$STEWARD_DIR/workspace" ...` 
diff --git a/modules/docs/mdoc/running.md b/modules/docs/mdoc/running.md
new file mode 100644
index 00000000..f03a1034
--- /dev/null
+++ b/modules/docs/mdoc/running.md
@@ -0,0 +1,265 @@
+## Running Scala Steward
+
+A complete list of all command line arguments can be found [here](help.md).
+
+```bash
+sbt stage
+
+./modules/core/.jvm/target/universal/stage/bin/scala-steward \
+  --workspace  "$STEWARD_DIR/workspace" \
+  --repos-file "$STEWARD_DIR/repos.md" \
+  --default-repo-conf "$STEWARD_DIR/default.scala-steward.conf" \
+  --git-author-email ${EMAIL} \
+  --vcs-api-host "https://api.github.com" \
+  --vcs-login ${LOGIN} \
+  --git-ask-pass "$STEWARD_DIR/.github/askpass/$LOGIN.sh" \
+  --sign-commits \
+  --env-var FOO=BAR
+```
+
+Or as a [Docker](https://www.docker.com/) container:
+
+```bash
+sbt docker:publishLocal
+
+docker run -v $STEWARD_DIR:/opt/scala-steward -it fthomas/scala-steward:latest \
+  --workspace  "/opt/scala-steward/workspace" \
+  --repos-file "/opt/scala-steward/repos.md" \
+  --default-repo-conf "/opt/scala-steward/default.scala-steward.conf" \
+  --git-author-email ${EMAIL} \
+  --vcs-api-host "https://api.github.com" \
+  --vcs-login ${LOGIN} \
+  --git-ask-pass "/opt/scala-steward/.github/askpass/$LOGIN.sh" \
+  --sign-commits \
+  --env-var FOO=BAR \ 
+  --scalafix-migrations "/opt/scala-steward/extra-scalafix-migrations.conf" \
+  --artifact-migrations "/opt/scala-steward/extra-artifact-migrations.conf" 
+```
+
+The [`git-ask-pass` option](https://git-scm.com/docs/gitcredentials) must specify an executable file (script) that returns (on the stdout),
+
+- either the plain text password corresponding to the configured `${LOGIN}`,
+- or (recommended) an authentication token corresponding to `${LOGIN}` (with appropriate permissions to watch the repositories; e.g. [Create a personal access token](https://help.github.com/en/articles/creating-a-personal-access-token-for-the-command-line) for GitHub).
+
+**Note about git-ask-pass option**: The provided script must start with a valid shebang like `#!/bin/sh`, see issue [#1374](@GITHUB_URL@/issues/1374)
+
+More information about using the `--scalafix-migrations` and `--artifact-migrations` options can be found 
+[here][scalafixmigrations] and [here][artifactmigrations].
+
+### Workspace
+
+The workspace directory (specified with `--workspace`) provides a location for cache and temporary files.  
+
+It is important to persist this workspace between runs.  Without this, Scala Steward will be unable to observe 
+repo-specific preferences (such as [pullRequests.frequency](repo-specific-configuration.md)) correctly.   
+
+### Private repositories
+
+If you run Scala Steward for your own private projects, the option `--do-not-fork` can be required, not to fork.
+Instead it will create pull requests directly on the private repository (as soon as the `${LOGIN}` can).
+
+#### Credentials using environment variables
+
+It can also be useful to pass additional environment variables from the command line using the `--env-var` flag as shown in the examples above. You can use this to pass any credentials required by your projects to resolve any private dependencies, e.g.:
+
+```bash
+--env-var BINTRAY_USER=username \
+--env-var BINTRAY_PASS=password
+```
+
+These variables will be accessible (in sbt) to all of the projects that Scala Steward checks dependencies for.
+
+#### Credentials using a credentials.sbt file
+
+If your projects require credentials, you can also provide global credentials in the `$HOME/.sbt/1.0/credentials.sbt` file. 
+The file should contain a single line: `credentials += Credentials("Some Nexus Repository Manager", "my.artifact.repo.net", "admin", "admin123")`.
+
+#### sbt 0.13 workaround
+For sbt 0.13 builds, scala-steward [may be unable](https://gitter.im/fthomas/scala-steward?at=5f0573dac7d15f7d0f7b15ac) to extract credentials for private resolvers. Instead, you can [configure coursier directly](https://get-coursier.io/docs/other-credentials) by adding `~/.config/coursier/credentials.properties`:
+```scala
+example1.username=username
+example1.password=password
+example1.host=artifacts.example.com
+example1.realm=Example Realm
+```
+
+### Running locally from sbt
+
+#### Sample run for GitLab
+
+```
+sbt
+project core
+run --do-not-fork --workspace "/path/workspace" --repos-file "/path/repos.md" --default-repo-conf "/path/default.scala-steward.conf" --git-ask-pass "/path/pass.sh" --git-author-email "email@example.org" --vcs-type "gitlab" --vcs-api-host "https://gitlab.com/api/v4/" --vcs-login "gitlab.steward"
+```
+
+
+### Running on Docker for Bitbucket
+
+* Create a file `repos.md` that will be injected into the container as as volume.
+* Create a file `run.sh` with this content:
+
+```
+echo "#!/bin/sh"                  >> pass.sh
+echo "echo '$BITBUCKET_PASSWORD'" >> pass.sh
+
+chmod +x pass.sh
+
+docker run -v $PWD:/opt/scala-steward \
+    -v ~/.sbt/:/root/.sbt \
+    -it fthomas/scala-steward:latest \
+    -DLOG_LEVEL=TRACE \
+    --do-not-fork \
+    --workspace "/opt/scala-steward/workspace" \
+    --repos-file "/opt/scala-steward/repos.md" \
+    --default-repo-conf "/opt/scala-steward/default.scala-steward.conf" \
+    --git-ask-pass "/opt/scala-steward/pass.sh" \
+    --git-author-email "myemail@company.xyz" \
+    --vcs-type "bitbucket" \
+    --vcs-api-host "https://api.bitbucket.org/2.0" \
+    --vcs-login "$BITBUCKET_USERNAME"
+```
+
+* Run it from a CI tool or manually using with this command:
+
+`BITBUCKET_USERNAME=<myuser> BITBUCKET_PASSWORD=<mypass> ./run.sh`
+
+### Running in a Bitbucket pipeline to update Bitbucket repos
+
+* Create a file `repos.md` that will be injected into the container as a volume.
+* Create a file `run.sh` with this content:
+
+```
+echo "#!/bin/sh"                  >> pass.sh
+echo "echo '$BITBUCKET_PASSWORD'" >> pass.sh
+
+chmod +x pass.sh
+
+docker run -v $PWD:/opt/scala-steward \
+    -i fthomas/scala-steward:latest \
+    -DLOG_LEVEL=TRACE \
+    --do-not-fork \
+    --workspace "/opt/scala-steward/workspace" \
+    --repos-file "/opt/scala-steward/repos.md" \
+    --default-repo-conf "/opt/scala-steward/default.scala-steward.conf" \
+    --git-ask-pass "/opt/scala-steward/pass.sh" \
+    --git-author-email "myemail@company.xyz" \
+    --vcs-type "bitbucket" \
+    --vcs-api-host "https://api.bitbucket.org/2.0" \
+    --vcs-login "$BITBUCKET_USERNAME"
+```
+
+NOTE: This script is slightly different to the one in the previous Bitbucket
+example, because it needs to run in a Bitbucket Pipeline. The `-t` flag has been
+removed, and we do mount `~/.sbt` as a volume.
+
+* Prepare an S3 bucket (or similar storage) to persist the Scala Steward
+  workspace between runs
+* Set some repository variables: AWS credentials, plus the S3 bucket name
+* Create a pipeline to run Scala Steward and sync the workspace to S3:
+
+```
+image:
+  name: <any Linux image with AWS CLI installed>
+
+options:
+  docker: true
+
+definitions:
+  services:
+    docker:
+      memory: 4096
+
+pipelines:
+  custom:
+    run-scala-steward:
+      - step:
+          name: Run Scala Steward
+          size: 2x
+          script:
+            - aws s3 sync s3://${WORKSPACE_BUCKET}/workspace ./workspace
+            - ./run.sh
+            - aws s3 sync ./workspace s3://${WORKSPACE_BUCKET}/workspace
+```
+
+* In the Pipelines UI, configure the pipeline to run on a schedule (e.g. daily)
+
+### Running On-premise
+
+#### GitHub Enterprise
+
+There is multiple articles on how to run Scala Steward on-premise:
+
+* [Running Scala Steward On-premise](https://engineering.avast.io/running-scala-steward-on-premise)
+* [Running scala-steward periodically on AWS Fargate](https://medium.com/@tanishiking/running-scala-steward-periodically-on-aws-fargate-3d3d202f0f7)
+* [Scala StewardとGitHub Actionsで依存ライブラリの更新を自動化する](https://scalapedia.com/articles/145/Scala+Steward%E3%81%A8GitHub+Actions%E3%81%A7%E4%BE%9D%E5%AD%98%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA%E3%81%AE%E6%9B%B4%E6%96%B0%E3%82%92%E8%87%AA%E5%8B%95%E5%8C%96%E3%81%99%E3%82%8B)
+* [Centralized Scala Steward with GitHub Actions](https://hector.dev/2020/11/18/centralized-scala-steward-with-github-actions)
+* [Big Timesavers for Busy Scala Developers](https://speakerdeck.com/exoego/big-timesavers-for-busy-scala-developers)
+* [Running scala steward on private repos](http://www.roundcrisis.com/2020/08/15/Scala-Steward-locally/)
+  
+#### GitLab
+
+The following describes a setup using GitLab Docker runner, which you have to setup seperately.
+
+1. create a "scalasteward" user in GitLab
+2. assign that user "Developer" permissions in every project that should be managed by Scala Steward
+3. login as that user and create a Personal Access Token with `api`, `read_repository` and `write_repository` scopes
+4. create a project and add the following GitLab CI config
+
+```yaml
+check:
+  rules:
+    # only run when scheduled, or when pushing a commit to the default
+    # branch which changed the repos.md file
+    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
+      when: never
+    - if: '$CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH'
+      changes:
+        - repos.md
+      when: on_success
+    - if: '$CI_PIPELINE_SOURCE == "schedule"'
+      when: on_success
+  image:
+    name: fthomas/scala-steward:latest
+    entrypoint: [""]
+  variables:
+    # change values here, if needed;
+    EMAIL: 'me@scala-steward.org'
+    LOGIN: 'scalasteward'
+  script:
+    - mkdir --parents "$CI_PROJECT_DIR/.sbt" "$CI_PROJECT_DIR/.ivy2"
+    - ln -sfT "$CI_PROJECT_DIR/.sbt"  "$HOME/.sbt"
+    - ln -sfT "$CI_PROJECT_DIR/.ivy2" "$HOME/.ivy2"
+    - >-
+      /opt/docker/bin/scala-steward
+        --workspace  "$CI_PROJECT_DIR/workspace"
+        --process-timeout 30min
+        --do-not-fork
+        --repos-file "$CI_PROJECT_DIR/repos.md"
+        --default-repo-conf "$CI_PROJECT_DIR/default.scala-steward.conf"
+        --git-author-email "${EMAIL}"
+        --vcs-type "gitlab"
+        --vcs-api-host "${CI_API_V4_URL}"
+        --vcs-login "${LOGIN}"
+        --git-ask-pass "$CI_PROJECT_DIR/askpass.sh"
+  cache:
+    key: scala-steward
+    paths:
+      - .ivy2/cache
+      - .sbt/boot/scala*
+      - workspace/store
+```
+5. add a masked CI variable `SCALA_STEWARD_TOKEN` in "Settings > CI / CD : Variables" for the access token
+6. add the `askpass.sh` script to the repository:
+
+```bash
+#!/usr/bin/env bash
+
+echo "${SCALA_STEWARD_TOKEN}"
+```
+7. add the `repos.md` file 
+8. (*optional*) create a new schedule to trigger the pipeline on a daily/weekly basis
+
+
+[scalafixmigrations]: @GITHUB_URL@/blob/@MAIN_BRANCH@/docs/scalafix-migrations.md
+[artifactmigrations]: @GITHUB_URL@/blob/@MAIN_BRANCH@/docs/artifact-migrations.md
diff --git a/modules/docs/mdoc/scalafix-migrations.md b/modules/docs/mdoc/scalafix-migrations.md
new file mode 100644
index 00000000..1f6b018a
--- /dev/null
+++ b/modules/docs/mdoc/scalafix-migrations.md
@@ -0,0 +1,69 @@
+# Scalafix Migrations
+
+Scala Steward can run [Scalafix][Scalafix] rewrite rules for version updates
+to not only bump version numbers but to also adapt the code for the new
+version. This allows to automatically rewrite deprecated code or code that
+would not compile with the new version of the dependency.
+
+Here are two pull requests that demonstrate this feature:
+
+* https://github.com/barambani/http4s-extend/pull/67/files
+* https://github.com/fthomas/scalafix-test/pull/6/files
+
+And here are all pull requests where Scala Steward applied Scalafix
+migrations:
+
+* [Created pull requests](https://github.com/search?q=author%3Ascala-steward+is%3Apr+scalafix-migrations)
+  ([compact](             https://github.com/pulls?q=author%3Ascala-steward+is%3Apr+scalafix-migrations))
+* [Merged pull requests]( https://github.com/search?q=author%3Ascala-steward+is%3Amerged+sort%3Aupdated-desc+scalafix-migrations)
+  ([compact](             https://github.com/pulls?q=author%3Ascala-steward+is%3Amerged+sort%3Aupdated-desc+scalafix-migrations))
+
+## How does this work?
+
+Scala Steward contains a list of [migration rules][migrations] combined with
+metadata that describes to which updates they are applicable. When it prepares
+a pull request and finds that one or more rules match the current update,
+Scala Steward applies these migration rules via the sbt-scalafix plugin
+which resolves those rules and edits the source code accordingly.
+
+## Writing migration rules for your project
+
+See the [Scalafix Developer Guide][scalafix-dev-guide] for more information
+about writing rewrite rules or have a look at the existing
+[migration rules][migrations] for inspiration. Rules in Scala Steward must be
+accessible via the [github:][using-github], the [http:][using-http] or the
+[dependency:][using-dependency] schemes.
+
+## Adding migration rules to Scala Steward
+
+After you have written a new migration rule for a new version of your project,
+Scala Steward needs to be made aware of it. Creating a pull request that adds
+the new rule to the list of [migrations][migrations] is enough for that. Once
+that pull request is merged, Scala Steward will start using this migration.
+
+When running Scala Steward you can also specify files or URLs (via the
+`--scalafix-migrations` command-line option) that contain additional
+migrations which are not present in the [default list][migrations].
+These files are in [HOCON][HOCON] format and should look like this:
+```hocon
+migrations = [
+  {
+    groupId: "org.http4s",
+    artifactIds: ["http4s-.*"],
+    newVersion: "0.20.0",
+    rewriteRules: ["github:http4s/http4s/v0_20?sha=v0.20.11"],
+    doc: "https://github.com/http4s/http4s/blob/v0.20.0/docs/src/main/tut/upgrading.md"
+  }
+]
+```
+
+Pull requests that added migration rules can be found [here][scalafix-prs].
+
+[Scalafix]: https://scalacenter.github.io/scalafix/
+[migrations]: @GITHUB_URL@/blob/@MAIN_BRANCH@/modules/core/src/main/resources/scalafix-migrations.conf
+[scalafix-dev-guide]: https://scalacenter.github.io/scalafix/docs/developers/setup.html
+[using-dependency]: https://scalacenter.github.io/scalafix/docs/rules/external-rules.html
+[using-github]: https://scalacenter.github.io/scalafix/docs/developers/sharing-rules.html#using-github
+[using-http]: https://scalacenter.github.io/scalafix/docs/developers/sharing-rules.html#using-http
+[scalafix-prs]: @GITHUB_URL@/pulls?q=label%3Ascalafix-migration
+[HOCON]: https://github.com/lightbend/config/blob/master/HOCON.md
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 1fb47e39..9db14054 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -20,7 +20,7 @@ object Dependencies {
   val coursierCore = "io.get-coursier" %% "coursier" % "2.0.16"
   val cron4sCore = "com.github.alonsodomin.cron4s" %% "cron4s-core" % "0.6.1"
   val disciplineMunit = "org.typelevel" %% "discipline-munit" % "1.0.9"
-  val fs2Core = "co.fs2" %% "fs2-core" % "3.1.6"
+  val fs2Core = "co.fs2" %% "fs2-core" % "3.2.2"
   val fs2Io = "co.fs2" %% "fs2-io" % fs2Core.revision
   val http4sCore = "org.http4s" %% "http4s-core" % "1.0.0-M29"
   val http4sCirce = "org.http4s" %% "http4s-circe" % http4sCore.revision
diff --git a/project/plugins.sbt b/project/plugins.sbt
index 3fcc9741..006bb592 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -10,7 +10,8 @@ addSbtPlugin("de.heikoseeberger" % "sbt-header" % "5.6.0")
 addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.20")
 addSbtPlugin("pl.project13.scala" % "sbt-jmh" % "0.4.3")
 addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.1.0")
-addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.9.1")
+addSbtPlugin("org.scalameta" % "sbt-mdoc" % "2.2.24")
+addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.9.2")
 
 // This is only here so that Scala Steward updates the version in sbt/package.scala too.
 addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.31")
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/coursier/CoursierAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/coursier/CoursierAlgTest.scala
index d33e79c3..877ea648 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/coursier/CoursierAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/coursier/CoursierAlgTest.scala
@@ -1,7 +1,6 @@
 package org.scalasteward.core.coursier
 
-import cats.effect.unsafe.implicits.global
-import munit.FunSuite
+import munit.CatsEffectSuite
 import org.http4s.syntax.literals._
 import org.scalasteward.core.TestSyntax._
 import org.scalasteward.core.buildtool.sbt.data.{SbtVersion, ScalaVersion}
@@ -9,86 +8,49 @@ import org.scalasteward.core.data.{ArtifactId, Dependency, GroupId}
 import org.scalasteward.core.mock.MockContext.context.coursierAlg
 import org.scalasteward.core.mock.MockState
 
-class CoursierAlgTest extends FunSuite {
+class CoursierAlgTest extends CatsEffectSuite {
   test("getArtifactUrl: library") {
-    val dep =
-      Dependency(GroupId("org.typelevel"), ArtifactId("cats-effect", "cats-effect_2.12"), "1.0.0")
-    val (state, result) = coursierAlg
-      .getArtifactUrl(dep.withMavenCentral)
-      .runSA(MockState.empty)
-      .unsafeRunSync()
-    assertEquals(state, MockState.empty)
-    assertEquals(result, Some(uri"https://github.com/typelevel/cats-effect"))
+    val artifactId = ArtifactId("cats-effect", "cats-effect_2.12")
+    val dep = Dependency(GroupId("org.typelevel"), artifactId, "1.0.0")
+    coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained =>
+      assertEquals(obtained, Some(uri"https://github.com/typelevel/cats-effect"))
+    }
   }
 
   test("getArtifactUrl: defaults to homepage") {
-    val dep = Dependency(
-      GroupId("com.typesafe.play"),
-      ArtifactId("play-ws-standalone-json", "play-ws-standalone-json_2.12"),
-      "2.1.0-M7"
-    )
-    val (state, result) = coursierAlg
-      .getArtifactUrl(dep.withMavenCentral)
-      .runSA(MockState.empty)
-      .unsafeRunSync()
-    assertEquals(state, MockState.empty)
-    assertEquals(result, Some(uri"https://github.com/playframework/play-ws"))
+    val artifactId = ArtifactId("play-ws-standalone-json", "play-ws-standalone-json_2.12")
+    val dep = Dependency(GroupId("com.typesafe.play"), artifactId, "2.1.0-M7")
+    coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained =>
+      assertEquals(obtained, Some(uri"https://github.com/playframework/play-ws"))
+    }
   }
 
   test("getArtifactUrl: URL with no or invalid scheme 1") {
-    val dep = Dependency(
-      GroupId("org.msgpack"),
-      ArtifactId("msgpack-core"),
-      "0.8.20"
-    )
-    val (state, result) = coursierAlg
-      .getArtifactUrl(dep.withMavenCentral)
-      .runSA(MockState.empty)
-      .unsafeRunSync()
-    assertEquals(state, MockState.empty)
-    assertEquals(result, Some(uri"http://msgpack.org/"))
+    val dep = Dependency(GroupId("org.msgpack"), ArtifactId("msgpack-core"), "0.8.20")
+    coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained =>
+      assertEquals(obtained, Some(uri"http://msgpack.org/"))
+    }
   }
 
   test("getArtifactUrl: URL with no or invalid scheme 2") {
-    val dep = Dependency(
-      GroupId("org.xhtmlrenderer"),
-      ArtifactId("flying-saucer-parent"),
-      "9.0.1"
-    )
-    val (state, result) = coursierAlg
-      .getArtifactUrl(dep.withMavenCentral)
-      .runSA(MockState.empty)
-      .unsafeRunSync()
-    assertEquals(state, MockState.empty)
-    assertEquals(result, Some(uri"http://code.google.com/p/flying-saucer/"))
+    val dep = Dependency(GroupId("org.xhtmlrenderer"), ArtifactId("flying-saucer-parent"), "9.0.1")
+    coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained =>
+      assertEquals(obtained, Some(uri"http://code.google.com/p/flying-saucer/"))
+    }
   }
 
   test("getArtifactUrl: from parent") {
-    val dep = Dependency(
-      GroupId("net.bytebuddy"),
-      ArtifactId("byte-buddy"),
-      "1.10.5"
-    )
-    val (state, result) = coursierAlg
-      .getArtifactUrl(dep.withMavenCentral)
-      .runSA(MockState.empty)
-      .unsafeRunSync()
-    assertEquals(state, MockState.empty)
-    assertEquals(result, Some(uri"https://bytebuddy.net"))
+    val dep = Dependency(GroupId("net.bytebuddy"), ArtifactId("byte-buddy"), "1.10.5")
+    coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained =>
+      assertEquals(obtained, Some(uri"https://bytebuddy.net"))
+    }
   }
 
   test("getArtifactUrl: minimal pom") {
-    val dep = Dependency(
-      GroupId("altrmi"),
-      ArtifactId("altrmi-common"),
-      "0.9.6"
-    )
-    val (state, result) = coursierAlg
-      .getArtifactUrl(dep.withMavenCentral)
-      .runSA(MockState.empty)
-      .unsafeRunSync()
-    assertEquals(state, MockState.empty)
-    assertEquals(result, None)
+    val dep = Dependency(GroupId("altrmi"), ArtifactId("altrmi-common"), "0.9.6")
+    coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained =>
+      assertEquals(obtained, None)
+    }
   }
 
   test("getArtifactUrl: sbt plugin on Maven Central") {
@@ -99,12 +61,9 @@ class CoursierAlgTest extends FunSuite {
       Some(SbtVersion("1.0")),
       Some(ScalaVersion("2.12"))
     )
-    val (state, result) = coursierAlg
-      .getArtifactUrl(dep.withMavenCentral)
-      .runSA(MockState.empty)
-      .unsafeRunSync()
-    assertEquals(state, MockState.empty)
-    assertEquals(result, Some(uri"https://github.com/xerial/sbt-sonatype"))
+    coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained =>
+      assertEquals(obtained, Some(uri"https://github.com/xerial/sbt-sonatype"))
+    }
   }
 
   test("getArtifactUrl: sbt plugin on sbt-plugin-releases") {
@@ -115,10 +74,18 @@ class CoursierAlgTest extends FunSuite {
       Some(SbtVersion("1.0")),
       Some(ScalaVersion("2.12"))
     )
-    val (state, result) =
-      coursierAlg.getArtifactUrl(dep.withSbtPluginReleases).runSA(MockState.empty).unsafeRunSync()
-    assertEquals(state, MockState.empty)
-    assertEquals(result, Some(uri"https://github.com/sbt/sbt-release"))
+    coursierAlg.getArtifactUrl(dep.withSbtPluginReleases).runA(MockState.empty).map { obtained =>
+      assertEquals(obtained, Some(uri"https://github.com/sbt/sbt-release"))
+    }
+  }
+
+  test("getArtifactUrl: invalid scm URL but valid homepage") {
+    val groupId = GroupId("com.github.japgolly.scalajs-react")
+    val dep = Dependency(groupId, ArtifactId("core", "core_sjs1_2.13"), "2.0.0-RC5")
+
+    coursierAlg.getArtifactUrl(dep.withMavenCentral).runA(MockState.empty).map { obtained =>
+      assertEquals(obtained, Some(uri"https://github.com/japgolly/scalajs-react"))
+    }
   }
 
   test("getArtifactIdUrlMapping") {
@@ -126,17 +93,13 @@ class CoursierAlgTest extends FunSuite {
       Dependency(GroupId("org.typelevel"), ArtifactId("cats-core", "cats-core_2.12"), "1.6.0"),
       Dependency(GroupId("org.typelevel"), ArtifactId("cats-effect", "cats-effect_2.12"), "1.0.0")
     )
-    val (state, result) = coursierAlg
-      .getArtifactIdUrlMapping(dependencies.withMavenCentral)
-      .runSA(MockState.empty)
-      .unsafeRunSync()
-    assertEquals(state, MockState.empty)
-    assertEquals(
-      result,
-      Map(
-        "cats-core" -> uri"https://github.com/typelevel/cats",
-        "cats-effect" -> uri"https://github.com/typelevel/cats-effect"
-      )
-    )
+    coursierAlg.getArtifactIdUrlMapping(dependencies.withMavenCentral).runA(MockState.empty).map {
+      obtained =>
+        val expected = Map(
+          "cats-core" -> uri"https://github.com/typelevel/cats",
+          "cats-effect" -> uri"https://github.com/typelevel/cats-effect"
+        )
+        assertEquals(obtained, expected)
+    }
   }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/data/SemVerTest.scala b/modules/core/src/test/scala/org/scalasteward/core/data/SemVerTest.scala
index 0dfc7a0e..32df5671 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/data/SemVerTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/data/SemVerTest.scala
@@ -47,7 +47,7 @@ class SemVerTest extends FunSuite {
     assertEquals(SemVer.parse("0.0.01"), None)
   }
 
-  test("getChange") {
+  test("getChangeSpec") {
     List(
       (SemVer("1", "3", "4"), SemVer("2", "1", "2"), Some(Change.Major)),
       (SemVer("2", "3", "4"), SemVer("2", "5", "2"), Some(Change.Minor)),
@@ -64,7 +64,32 @@ class SemVerTest extends FunSuite {
       (SemVer("2", "3", "4", Some("M1")), SemVer("2", "3", "4", Some("M1")), None),
       (SemVer("0", "20", "0", Some("M4")), SemVer("0", "20", "3"), Some(Change.PreRelease))
     ).foreach { case (from, to, result) =>
-      assertEquals(SemVer.getChange(from, to), result)
+      assertEquals(SemVer.getChangeSpec(from, to), result)
+    }
+  }
+
+  test("getChangeEarly") {
+    List(
+      (SemVer("1", "3", "4"), SemVer("2", "1", "2"), Some(Change.Major)),
+      (SemVer("2", "3", "4"), SemVer("2", "5", "2"), Some(Change.Minor)),
+      (
+        SemVer("2", "3", "4", Some("SNAP1")),
+        SemVer("2", "3", "4", Some("SNAP2")),
+        Some(Change.PreRelease)
+      ),
+      (
+        SemVer("2", "3", "4", Some("M1"), Some("1")),
+        SemVer("2", "3", "4", Some("M1"), Some("2")),
+        Some(Change.BuildMetadata)
+      ),
+      (SemVer("2", "3", "4", Some("M1")), SemVer("2", "3", "4", Some("M1")), None),
+      (SemVer("0", "20", "0", Some("M4")), SemVer("0", "20", "3"), Some(Change.Minor)),
+      (SemVer("0", "1", "0", None, None), SemVer("0", "2", "0", None, None), Some(Change.Major)),
+      (SemVer("0", "0", "1", None, None), SemVer("0", "0", "2", None, None), Some(Change.Major)),
+      (SemVer("0", "0", "0", None, None), SemVer("0", "0", "1", None, None), Some(Change.Major)),
+      (SemVer("0", "0", "0", None, None), SemVer("0", "0", "0", None, None), None)
+    ).foreach { case (from, to, result) =>
+      assertEquals(SemVer.getChangeEarly(from, to), result)
     }
   }
 }
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 e6eefb4a..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,13 +5,16 @@ import cats.effect.unsafe.implicits.global
 import munit.FunSuite
 import org.scalasteward.core.TestInstances.dummyRepoCache
 import org.scalasteward.core.TestSyntax._
-import org.scalasteward.core.data.{GroupId, RepoData, Update}
+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
 import org.scalasteward.core.mock.MockState.TraceEntry.{Cmd, Log}
 import org.scalasteward.core.repoconfig.RepoConfig
-import org.scalasteward.core.scalafmt.{scalafmtBinary, scalafmtConfName}
+import org.scalasteward.core.scalafmt.ScalafmtAlg.opts
+import org.scalasteward.core.scalafmt.{scalafmtBinary, scalafmtConfName, scalafmtDependency}
 import org.scalasteward.core.util.Nel
 import org.scalasteward.core.vcs.data.Repo
 
@@ -57,7 +60,10 @@ class EditAlgTest extends FunSuite {
 
   test("applyUpdate with scalafmt update") {
     val repo = Repo("edit-alg", "test-2")
-    val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
+    val cache = dummyRepoCache.copy(dependencyInfos =
+      List(List(DependencyInfo(scalafmtDependency(Version("2.0.0")), Nil)).withMavenCentral)
+    )
+    val data = RepoData(repo, cache, RepoConfig.empty)
     val repoDir = config.workspace / repo.toPath
     val update = Update.Single("org.scalameta" % "scalafmt-core" % "2.0.0", Nel.of("2.1.0"))
     val scalafmtConf = repoDir / scalafmtConfName
@@ -95,9 +101,16 @@ class EditAlgTest extends FunSuite {
         Log("Trying heuristic 'specific'"),
         Cmd("read", scalafmtConf.pathAsString),
         Cmd("write", scalafmtConf.pathAsString),
+        Cmd(
+          "VAR1=val1" :: "VAR2=val2" :: repoDir.toString ::
+            scalafmtBinary :: opts.nonInteractive :: opts.quiet :: opts.modeChanged
+        ),
         Cmd(gitStatus(repoDir)),
         Log("Executing post-update hook for org.scalameta:scalafmt-core"),
-        Cmd("VAR1=val1", "VAR2=val2", repoDir.toString, scalafmtBinary, "--non-interactive"),
+        Cmd(
+          "VAR1=val1" :: "VAR2=val2" :: repoDir.toString ::
+            scalafmtBinary :: opts.nonInteractive :: opts.quiet :: Nil
+        ),
         Cmd(gitStatus(repoDir))
       ),
       files = Map(
@@ -152,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/hooks/HookExecutorTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala
index 37f20417..1410a08c 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala
@@ -10,6 +10,7 @@ import org.scalasteward.core.mock.MockContext.context.{hookExecutor, workspaceAl
 import org.scalasteward.core.mock.MockState
 import org.scalasteward.core.mock.MockState.TraceEntry.{Cmd, Log}
 import org.scalasteward.core.repoconfig.{RepoConfig, ScalafmtConfig}
+import org.scalasteward.core.scalafmt.ScalafmtAlg.opts
 import org.scalasteward.core.scalafmt.{scalafmtArtifactId, scalafmtBinary, scalafmtGroupId}
 import org.scalasteward.core.util.Nel
 import org.scalasteward.core.vcs.data.Repo
@@ -40,7 +41,10 @@ class HookExecutorTest extends CatsEffectSuite {
     val expected = initial.copy(
       trace = Vector(
         Log("Executing post-update hook for org.scalameta:scalafmt-core"),
-        Cmd("VAR1=val1", "VAR2=val2", repoDir.toString, scalafmtBinary, "--non-interactive"),
+        Cmd(
+          "VAR1=val1" :: "VAR2=val2" :: repoDir.toString ::
+            scalafmtBinary :: opts.nonInteractive :: opts.quiet :: Nil
+        ),
         Cmd(
           gitCmd(repoDir),
           "status",
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationTest.scala
index 25867cf7..48845ee9 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationTest.scala
@@ -2,6 +2,7 @@ package org.scalasteward.core.edit.scalafix
 
 import io.circe.config.parser
 import munit.FunSuite
+import org.scalasteward.core.edit.scalafix.ScalafixMigration.{ExecutionOrder, Target}
 import org.scalasteward.core.util.Nel
 
 class ScalafixMigrationTest extends FunSuite {
@@ -25,4 +26,12 @@ class ScalafixMigrationTest extends FunSuite {
     )
     assertEquals(obtained, Right(expected))
   }
+
+  test("decode unknown executionOrder") {
+    assert(io.circe.parser.decode[ExecutionOrder](""""foo"""").isLeft)
+  }
+
+  test("decode unknown target") {
+    assert(io.circe.parser.decode[Target](""""foo"""").isLeft)
+  }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsFinderTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsFinderTest.scala
index 2b22f7d8..fe5dc9af 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsFinderTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/scalafix/ScalafixMigrationsFinderTest.scala
@@ -10,17 +10,20 @@ class ScalafixMigrationsFinderTest extends FunSuite {
   test("findMigrations") {
     val update = Update.Single("org.typelevel" % "cats-core" % "2.1.0", Nel.of("2.2.0"))
     val migrations = scalafixMigrationsFinder.findMigrations(update)
-    val expected = List(
-      ScalafixMigration(
-        GroupId("org.typelevel"),
-        Nel.of("cats-core"),
-        Version("2.2.0"),
-        Nel.of("github:typelevel/cats/Cats_v2_2_0?sha=v2.2.0"),
-        Some(
-          "https://github.com/typelevel/cats/blob/v2.2.0/scalafix/README.md#migration-to-cats-v220"
-        ),
-        Some(Nel.of("-P:semanticdb:synthetics:on"))
-      )
+    val expected = (
+      List(
+        ScalafixMigration(
+          GroupId("org.typelevel"),
+          Nel.of("cats-core"),
+          Version("2.2.0"),
+          Nel.of("github:typelevel/cats/Cats_v2_2_0?sha=v2.2.0"),
+          Some(
+            "https://github.com/typelevel/cats/blob/v2.2.0/scalafix/README.md#migration-to-cats-v220"
+          ),
+          Some(Nel.of("-P:semanticdb:synthetics:on"))
+        )
+      ),
+      List()
     )
     assertEquals(migrations, expected)
   }
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..733dc72b 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,8 @@ 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.ExecutionOrder.PreUpdate
+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 +23,9 @@ 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",
+       |    executionOrder: "pre-update"
        |  }
        |]""".stripMargin
   val migration: ScalafixMigration = ScalafixMigration(
@@ -30,7 +34,9 @@ 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),
+    executionOrder = Some(PreUpdate)
   )
 
   test("loadAll: without extra file, without defaults") {
diff --git a/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala
index c054670b..a23d1623 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala
@@ -38,6 +38,25 @@ class FileGitAlgTest extends CatsEffectSuite {
     } yield ()
   }
 
+  test("branchesDiffer") {
+    val repo = rootDir / "branchesDiffer"
+    val (foo, bar) = (Branch("foo"), Branch("bar"))
+    for {
+      _ <- ioAuxGitAlg.createRepo(repo)
+      _ <- ioGitAlg.createBranch(repo, foo)
+      _ <- ioGitAlg.createBranch(repo, bar)
+      b1 <- ioGitAlg.branchesDiffer(repo, bar, foo)
+      _ <- ioFileAlg.writeFile(repo / "test.txt", "hello")
+      _ <- ioAuxGitAlg.git("add", "test.txt")(repo)
+      _ <- ioGitAlg.commitAll(repo, CommitMsg("Add test.txt"))
+      b2 <- ioGitAlg.branchesDiffer(repo, foo, bar)
+      _ <- ioAuxGitAlg.git("rm", "test.txt")(repo)
+      _ <- ioGitAlg.commitAll(repo, CommitMsg("Remove test.txt"))
+      b3 <- ioGitAlg.branchesDiffer(repo, foo, bar)
+      _ = assertEquals((b1, b2, b3), (false, true, false))
+    } yield ()
+  }
+
   test("cloneExists") {
     val repo = rootDir / "cloneExists"
     for {
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)
+    }
+  }
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
index 6dda95ab..f0a9dc29 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
@@ -29,7 +29,7 @@ class NewPullRequestDataTest extends FunSuite {
     val expected =
       raw"""|{
             |  "title" : "Update logback-classic to 1.2.3",
-            |  "body" : "Updates ch.qos.logback:logback-classic from 1.2.0 to 1.2.3.\n\n\nI'll automatically update this PR to resolve conflicts as long as you don't change it yourself.\n\nIf you'd like to skip this version, you can just close this PR. If you have any feedback, just mention me in the comments below.\n\nConfigure Scala Steward for your repository with a [`.scala-steward.conf`](https://github.com/scala-steward-org/scala-steward/blob/${org.scalasteward.core.BuildInfo.gitHeadCommit}/docs/repo-specific-configuration.md) file.\n\nHave a fantastic day writing Scala!\n\n<details>\n<summary>Ignore future updates</summary>\n\nAdd this to your `.scala-steward.conf` file to ignore future updates of this dependency:\n```\nupdates.ignore = [ { groupId = \"ch.qos.logback\", artifactId = \"logback-classic\" } ]\n```\n</details>\n\nlabels: library-update, semver-patch",
+            |  "body" : "Updates ch.qos.logback:logback-classic from 1.2.0 to 1.2.3.\n\n\nI'll automatically update this PR to resolve conflicts as long as you don't change it yourself.\n\nIf you'd like to skip this version, you can just close this PR. If you have any feedback, just mention me in the comments below.\n\nConfigure Scala Steward for your repository with a [`.scala-steward.conf`](https://github.com/scala-steward-org/scala-steward/blob/${org.scalasteward.core.BuildInfo.gitHeadCommit}/docs/repo-specific-configuration.md) file.\n\nHave a fantastic day writing Scala!\n\n<details>\n<summary>Ignore future updates</summary>\n\nAdd this to your `.scala-steward.conf` file to ignore future updates of this dependency:\n```\nupdates.ignore = [ { groupId = \"ch.qos.logback\", artifactId = \"logback-classic\" } ]\n```\n</details>\n\nlabels: library-update, early-semver-patch, semver-spec-patch",
             |  "head" : "scala-steward:update/logback-classic-1.2.3",
             |  "base" : "master",
             |  "draft" : false