Repository: scala-steward-org/scala-steward
Pull request #1488 (2020-06-17 07:57:34)

Mergeable repo configurations
Continuation of #1483, allows configuration(s) to be merged together: 

Target Repo (`.scala-steward.conf`) => Default Scala Steward (`default.scala-steward.conf`) => Empty
by types: `Option[RepoConfig]` |+| `Option[RepoConfig]` |+| `RepoConfig.default`.

Current use-case - when running `scala-steward` on-prem with default global configuration, some repos want to override some settings (say, ignore few dependencies) by keeping global one as the base / fallback. Current behavior will pick the project's one and completely ignore default, global conf.

This PR allows you to have them both by merging conf together


Patch:
diff --git a/docs/running.md b/docs/running.md
index 08d457b8..a622c5da 100644
--- a/docs/running.md
+++ b/docs/running.md
@@ -129,4 +129,4 @@ docker run -v $PWD:/opt/scala-steward \
 ### Running On-premise (GitHub Enterprise)
 
 There is an article on how they run Scala Steward on-premise at Avast:
-* [Running Scala Steward On-premise](https://engineering.avast.io/running-scala-steward-on-premise)
+* [Running Scala Steward On-premise](https://engineering.avast.io/running-scala-steward-on-premise)
\ No newline at end of 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 7bf76610..3e3fabd2 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
@@ -81,7 +81,7 @@ final class NurtureAlg[F[_]](implicit
       updates: List[Update.Single]
   ): F[Unit] =
     for {
-      repoConfig <- repoConfigAlg.readRepoConfigOrDefault(repo)
+      repoConfig <- repoConfigAlg.readRepoConfigWithDefault(repo)
       grouped = Update.groupByGroupId(updates)
       sorted = grouped.sortBy(migrationAlg.findMigrations(_).size)
       _ <- logger.info(util.logger.showUpdates(sorted))
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/CommitsConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/CommitsConfig.scala
index efbcdae9..bfd050e1 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/CommitsConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/CommitsConfig.scala
@@ -16,6 +16,7 @@
 
 package org.scalasteward.core.repoconfig
 
+import cats.kernel.{Eq, Semigroup}
 import io.circe.Codec
 import io.circe.generic.extras.Configuration
 import io.circe.generic.extras.semiauto._
@@ -33,6 +34,13 @@ object CommitsConfig {
   implicit val customConfig: Configuration =
     Configuration.default.withDefaults
 
+  implicit val eqPullRequestsConfig: Eq[CommitsConfig] = Eq.fromUniversalEquals
+
   implicit val commitsConfigCodec: Codec[CommitsConfig] =
     deriveConfiguredCodec
+
+  implicit val semigroup: Semigroup[CommitsConfig] = new Semigroup[CommitsConfig] {
+    override def combine(x: CommitsConfig, y: CommitsConfig): CommitsConfig =
+      CommitsConfig(x.message.orElse(y.message))
+  }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestsConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestsConfig.scala
index 0d972836..d47b0dc5 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestsConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestsConfig.scala
@@ -16,6 +16,7 @@
 
 package org.scalasteward.core.repoconfig
 
+import cats.kernel.{Eq, Semigroup}
 import io.circe.Codec
 import io.circe.generic.extras.Configuration
 import io.circe.generic.extras.semiauto.deriveConfiguredCodec
@@ -31,6 +32,13 @@ object PullRequestsConfig {
   implicit val customConfig: Configuration =
     Configuration.default.withDefaults
 
+  implicit val eqPullRequestsConfig: Eq[PullRequestsConfig] = Eq.fromUniversalEquals
+
   implicit val pullRequestsConfigCodec: Codec[PullRequestsConfig] =
     deriveConfiguredCodec
+
+  implicit val semigroup: Semigroup[PullRequestsConfig] = new Semigroup[PullRequestsConfig] {
+    override def combine(x: PullRequestsConfig, y: PullRequestsConfig): PullRequestsConfig =
+      PullRequestsConfig(x.frequency.orElse(y.frequency))
+  }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
index eef63321..54accb52 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
@@ -16,6 +16,8 @@
 
 package org.scalasteward.core.repoconfig
 
+import cats.kernel.Semigroup
+import cats.syntax.semigroup._
 import io.circe.Codec
 import io.circe.generic.extras.Configuration
 import io.circe.generic.extras.semiauto._
@@ -38,4 +40,19 @@ object RepoConfig {
 
   implicit val repoConfigCodec: Codec[RepoConfig] =
     deriveConfiguredCodec
+
+  implicit val semigroup: Semigroup[RepoConfig] = new Semigroup[RepoConfig] {
+    override def combine(x: RepoConfig, y: RepoConfig): RepoConfig =
+      (x, y) match {
+        case (`empty`, _) => y
+        case (_, `empty`) => x
+        case _ =>
+          RepoConfig(
+            commits = x.commits |+| y.commits,
+            pullRequests = x.pullRequests |+| y.pullRequests,
+            updates = x.updates |+| y.updates,
+            updatePullRequests = x.updatePullRequests.orElse(y.updatePullRequests)
+          )
+      }
+  }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigAlg.scala
index 69475d92..1d917eba 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfigAlg.scala
@@ -35,10 +35,14 @@ final class RepoConfigAlg[F[_]](implicit
     workspaceAlg: WorkspaceAlg[F],
     F: MonadThrowable[F]
 ) {
-  def readRepoConfigOrDefault(repo: Repo): F[RepoConfig] =
-    readRepoConfig(repo).flatMap { config =>
-      config.map(F.pure).getOrElse(defaultRepoConfig)
-    }
+
+  def readRepoConfigWithDefault(repo: Repo): F[RepoConfig] =
+    for {
+      config <- readRepoConfig(repo)
+      defaultCfg <- defaultRepoConfig
+    } yield config
+      .map(_ |+| defaultCfg)
+      .getOrElse(defaultCfg)
 
   /**
     * Default configuration will try to read file specified in config.defaultRepoConfigFile first;
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala
index 07f731e1..e348ded3 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala
@@ -16,7 +16,8 @@
 
 package org.scalasteward.core.repoconfig
 
-import cats.syntax.all._
+import cats.implicits._
+import cats.kernel.Eq
 import io.circe.generic.semiauto._
 import io.circe.{Decoder, Encoder, HCursor}
 import org.scalasteward.core.data.{GroupId, Update}
@@ -25,7 +26,9 @@ final case class UpdatePattern(
     groupId: GroupId,
     artifactId: Option[String],
     version: Option[UpdatePattern.Version]
-)
+) {
+  def isWholeGroupIdAllowed: Boolean = artifactId.isEmpty && version.isEmpty
+}
 
 object UpdatePattern {
   final case class MatchResult(
@@ -67,6 +70,8 @@ object UpdatePattern {
         } yield Version(prefix, suffix)
       )
 
+  implicit val eqVersion: Eq[Version] = Eq.fromUniversalEquals
+
   implicit val updatePatternVersionEncoder: Encoder[Version] =
     deriveEncoder
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala
index 72c9b776..0365c15a 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala
@@ -16,12 +16,14 @@
 
 package org.scalasteward.core.repoconfig
 
+import cats.implicits._
+import cats.kernel.Semigroup
 import eu.timepit.refined.types.numeric.PosInt
 import io.circe.generic.extras.Configuration
 import io.circe.generic.extras.semiauto._
 import io.circe.refined._
 import io.circe.{Decoder, Encoder}
-import org.scalasteward.core.data.Update
+import org.scalasteward.core.data.{GroupId, Update}
 import org.scalasteward.core.update.FilterAlg.{
   FilterResult,
   IgnoredByConfig,
@@ -89,6 +91,109 @@ object UpdatesConfig {
   val defaultFileExtensions: Set[String] =
     Set(".scala", ".sbt", ".sbt.shared", ".sc", ".yml", "pom.xml")
 
+  private[repoconfig] val nonExistingFileExtension: List[String] = List(".non-exist")
+  private[repoconfig] val nonExistingUpdatePattern: List[UpdatePattern] = List(
+    UpdatePattern(GroupId("non-exist"), None, None)
+  )
+
   // prevent IntelliJ from removing the import of io.circe.refined._
   locally(refinedDecoder: Decoder[PosInt])
+
+  implicit val semigroup: Semigroup[UpdatesConfig] = new Semigroup[UpdatesConfig] {
+    override def combine(x: UpdatesConfig, y: UpdatesConfig): UpdatesConfig =
+      UpdatesConfig(
+        pin = mergePin(x.pin, y.pin),
+        allow = mergeAllow(x.allow, y.allow),
+        ignore = mergeIgnore(x.ignore, y.ignore),
+        limit = x.limit.orElse(y.limit),
+        includeScala = x.includeScala.orElse(y.includeScala),
+        fileExtensions = mergeFileExtensions(x.fileExtensions, y.fileExtensions)
+      )
+  }
+
+  //  Strategy: union with repo preference in terms of revision
+  private[repoconfig] def mergePin(
+      x: List[UpdatePattern],
+      y: List[UpdatePattern]
+  ): List[UpdatePattern] =
+    (x ::: y).distinctBy(up => up.groupId -> up.artifactId)
+
+  //  Strategy: superset
+  //  Xa.Ya.Za |+| Xb.Yb.Zb
+  private[repoconfig] def mergeAllow(
+      x: List[UpdatePattern],
+      y: List[UpdatePattern]
+  ): List[UpdatePattern] =
+    (x, y) match {
+      case (Nil, second) => second
+      case (first, Nil)  => first
+      case _             =>
+        //  remove duplicates first by calling .distinct
+        val xm: Map[GroupId, List[UpdatePattern]] = x.distinct.groupBy(_.groupId)
+        val ym: Map[GroupId, List[UpdatePattern]] = y.distinct.groupBy(_.groupId)
+        val builder = new collection.mutable.ListBuffer[UpdatePattern]()
+
+        //  first of all, we only allow intersection (superset)
+        val keys = xm.keySet.intersect(ym.keySet)
+
+        keys.foreach { groupId =>
+          builder ++= mergeAllowGroupId(xm(groupId), ym(groupId))
+        }
+
+        if (builder.isEmpty) nonExistingUpdatePattern
+        else builder.distinct.toList
+    }
+
+  //  merge UpdatePattern for same group id
+  private[this] def mergeAllowGroupId(
+      x: List[UpdatePattern],
+      y: List[UpdatePattern]
+  ): List[UpdatePattern] =
+    (x.exists(_.isWholeGroupIdAllowed), y.exists(_.isWholeGroupIdAllowed)) match {
+      case (true, _) => y
+      case (_, true) => x
+      case _         =>
+        //  case with concrete artifacts / versions
+        val builder = new collection.mutable.ListBuffer[UpdatePattern]()
+        val xByArtifacts = x.groupBy(_.artifactId)
+        val yByArtifacts = y.groupBy(_.artifactId)
+
+        x.foreach { updatePattern =>
+          if (satisfyUpdatePattern(updatePattern, yByArtifacts))
+            builder += updatePattern
+        }
+        y.foreach { updatePattern =>
+          if (satisfyUpdatePattern(updatePattern, xByArtifacts))
+            builder += updatePattern
+        }
+
+        if (builder.isEmpty) nonExistingUpdatePattern
+        else builder.toList
+    }
+
+  private[this] def satisfyUpdatePattern(
+      targetUpdatePattern: UpdatePattern,
+      comparedUpdatePatternsByArtifact: Map[Option[String], List[UpdatePattern]]
+  ): Boolean =
+    comparedUpdatePatternsByArtifact.get(targetUpdatePattern.artifactId).exists { matchedVersions =>
+      //  For simplicity I'm using direct equals here between versions. Feel free to make it more advanced
+      matchedVersions.exists(up => up.version.isEmpty || up.version === targetUpdatePattern.version)
+    }
+
+  //  Strategy: union
+  private[repoconfig] def mergeIgnore(
+      x: List[UpdatePattern],
+      y: List[UpdatePattern]
+  ): List[UpdatePattern] =
+    (x ::: y).distinct
+  private[repoconfig] def mergeFileExtensions(x: List[String], y: List[String]): List[String] =
+    (x, y) match {
+      case (Nil, second) => second
+      case (first, Nil)  => first
+      case _ =>
+        val result = x.intersect(y)
+        //  Since empty result represents [*] any extension, we gonna set artificial extension instead.
+        if (result.nonEmpty) result
+        else nonExistingFileExtension
+    }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala
index 8aa41e2f..b5ba28df 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala
@@ -74,8 +74,8 @@ final class PruningAlg[F[_]](implicit
     val depsWithoutResolvers = dependencies.map(_.value).distinct
     for {
       _ <- logger.info(s"Find updates for ${repo.show}")
-      repoConfig <-
-        OptionT.fromOption[F](repoCache.maybeRepoConfig).getOrElseF(repoConfigAlg.defaultRepoConfig)
+      defaultConfig <- repoConfigAlg.defaultRepoConfig
+      repoConfig = repoCache.maybeRepoConfig.map(_ |+| defaultConfig).getOrElse(defaultConfig)
       updates0 <- updateAlg.findUpdates(dependencies, repoConfig, None)
       updateStates0 <- findAllUpdateStates(repo, repoCache, depsWithoutResolvers, updates0)
       outdatedDeps = collectOutdatedDependencies(updateStates0)


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/CommitsConfigTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/CommitsConfigTest.scala
new file mode 100644
index 00000000..144e8b45
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/CommitsConfigTest.scala
@@ -0,0 +1,20 @@
+package org.scalasteward.core.repoconfig
+
+import cats.kernel.laws.discipline.SemigroupTests
+import org.scalacheck.Arbitrary
+import org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.prop.Configuration
+import org.typelevel.discipline.scalatest.FunSuiteDiscipline
+
+class CommitsConfigTest
+    extends AnyFunSuite
+    with Matchers
+    with FunSuiteDiscipline
+    with Configuration {
+  implicit val commitsConfigArbitrary: Arbitrary[CommitsConfig] = Arbitrary(for {
+    e <- Arbitrary.arbitrary[Option[String]]
+  } yield CommitsConfig(e))
+
+  checkAll("Semigroup[CommitsConfig]", SemigroupTests[CommitsConfig].semigroup)
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/PullRequestsConfigTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/PullRequestsConfigTest.scala
new file mode 100644
index 00000000..ca91f764
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/PullRequestsConfigTest.scala
@@ -0,0 +1,25 @@
+package org.scalasteward.core.repoconfig
+
+import cats.kernel.laws.discipline.SemigroupTests
+import org.scalacheck.{Arbitrary, Gen}
+import org.scalasteward.core.repoconfig.PullRequestFrequency.{Asap, Daily, Monthly, Weekly}
+import org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.matchers.should.Matchers
+import org.scalatest.prop.Configuration
+import org.typelevel.discipline.scalatest.FunSuiteDiscipline
+
+class PullRequestsConfigTest
+    extends AnyFunSuite
+    with Matchers
+    with FunSuiteDiscipline
+    with Configuration {
+
+  implicit val pullRequestFrequencyArbitrary: Arbitrary[PullRequestFrequency] = Arbitrary(
+    Gen.oneOf(Asap, Daily, Weekly, Monthly)
+  )
+  implicit val pullRequestsConfigArbitrary: Arbitrary[PullRequestsConfig] = Arbitrary(for {
+    e <- Arbitrary.arbitrary[Option[PullRequestFrequency]]
+  } yield PullRequestsConfig(e))
+
+  checkAll("Semigroup[PullRequestsConfig]", SemigroupTests[PullRequestsConfig].semigroup)
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
index 8db664c5..45551f95 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
@@ -31,7 +31,7 @@ class RepoConfigAlgTest extends AnyFunSuite with Matchers {
          |commits.message = "Update ${artifactName} from ${currentVersion} to ${nextVersion}"
          |""".stripMargin
     val initialState = MockState.empty.add(configFile, content)
-    val config = repoConfigAlg.readRepoConfigOrDefault(repo).runA(initialState).unsafeRunSync()
+    val config = repoConfigAlg.readRepoConfigWithDefault(repo).runA(initialState).unsafeRunSync()
 
     config shouldBe RepoConfig(
       pullRequests = PullRequestsConfig(frequency = Some(PullRequestFrequency.Weekly)),
@@ -135,7 +135,7 @@ class RepoConfigAlgTest extends AnyFunSuite with Matchers {
     val configFile = File.temp / "ws/fthomas/scala-steward/.scala-steward.conf"
     val initialState = MockState.empty.add(configFile, """updates.ignore = [ "foo """)
     val (state, config) =
-      repoConfigAlg.readRepoConfigOrDefault(repo).run(initialState).unsafeRunSync()
+      repoConfigAlg.readRepoConfigWithDefault(repo).run(initialState).unsafeRunSync()
 
     config shouldBe RepoConfig()
     state.logs.headOption.map { case (_, msg) => msg }.getOrElse("") should
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigTest.scala
new file mode 100644
index 00000000..b4cd5136
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigTest.scala
@@ -0,0 +1,48 @@
+package org.scalasteward.core.repoconfig
+
+import cats.syntax.semigroup._
+import org.scalasteward.core.data.GroupId
+import org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.matchers.should.Matchers
+
+class RepoConfigTest extends AnyFunSuite with Matchers {
+  test("RepoConfig: semigroup") {
+    RepoConfig.empty |+| RepoConfig.empty shouldBe RepoConfig.empty
+
+    val cfg = RepoConfig(
+      commits = CommitsConfig(Some("a")),
+      pullRequests = PullRequestsConfig(Some(PullRequestFrequency.Asap)),
+      updates = UpdatesConfig(
+        allow =
+          List(UpdatePattern(GroupId("A"), None, None), UpdatePattern(GroupId("B"), None, None))
+      ),
+      updatePullRequests = Some(PullRequestUpdateStrategy.Always)
+    )
+
+    cfg |+| RepoConfig.empty shouldBe cfg
+    RepoConfig.empty |+| cfg shouldBe cfg
+    cfg |+| cfg shouldBe cfg
+
+    val cfg2 = RepoConfig(
+      commits = CommitsConfig(Some("b")),
+      pullRequests = PullRequestsConfig(Some(PullRequestFrequency.Daily)),
+      updates = UpdatesConfig(
+        allow =
+          List(UpdatePattern(GroupId("B"), None, None), UpdatePattern(GroupId("C"), None, None))
+      ),
+      updatePullRequests = Some(PullRequestUpdateStrategy.OnConflicts)
+    )
+
+    cfg |+| cfg2 shouldBe cfg.copy(
+      updates = UpdatesConfig(
+        allow = List(UpdatePattern(GroupId("B"), None, None))
+      )
+    )
+
+    cfg2 |+| cfg shouldBe cfg2.copy(
+      updates = UpdatesConfig(
+        allow = List(UpdatePattern(GroupId("B"), None, None))
+      )
+    )
+  }
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/UpdatesConfigTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/UpdatesConfigTest.scala
new file mode 100644
index 00000000..f77a7d78
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/UpdatesConfigTest.scala
@@ -0,0 +1,125 @@
+package org.scalasteward.core.repoconfig
+
+import cats.syntax.semigroup._
+import eu.timepit.refined.types.numeric.PosInt
+import org.scalasteward.core.data.GroupId
+import org.scalasteward.core.repoconfig.UpdatePattern.Version
+import org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.matchers.should.Matchers
+
+class UpdatesConfigTest extends AnyFunSuite with Matchers {
+
+  private val groupIdA = GroupId("A")
+  private val groupIdB = GroupId("B")
+
+  private val a00 = UpdatePattern(groupIdA, None, None)
+  private val aa0 = UpdatePattern(groupIdA, Some("a"), None)
+  private val ab0 = UpdatePattern(groupIdA, Some("b"), None)
+  private val ac0 = UpdatePattern(groupIdA, Some("c"), None)
+
+  private val aa1 = UpdatePattern(groupIdA, Some("a"), Some(Version(Some("1"), None)))
+  private val aa2 = UpdatePattern(groupIdA, Some("a"), Some(Version(Some("2"), None)))
+  private val ac3 = UpdatePattern(groupIdA, Some("c"), Some(Version(Some("3"), None)))
+
+  private val b00 = UpdatePattern(groupIdB, None, None)
+
+  test("semigroup: basic checks") {
+    val emptyCfg = UpdatesConfig()
+    emptyCfg |+| emptyCfg shouldBe emptyCfg
+
+    val cfg = UpdatesConfig(
+      pin = List(aa0),
+      allow = List(aa0),
+      ignore = List(aa0),
+      limit = Some(PosInt(10)),
+      includeScala = Some(false),
+      fileExtensions = List(".txt", ".scala", ".sbt")
+    )
+    cfg |+| cfg shouldBe cfg
+
+    cfg |+| emptyCfg shouldBe cfg
+    emptyCfg |+| cfg shouldBe cfg
+
+    val cfg2 = UpdatesConfig(
+      pin = List(ab0),
+      allow = List(ab0),
+      ignore = List(ab0),
+      limit = Some(PosInt(20)),
+      includeScala = Some(true),
+      fileExtensions = List(".sbt", ".scala")
+    )
+
+    cfg |+| cfg2 shouldBe UpdatesConfig(
+      pin = List(aa0, ab0),
+      allow = UpdatesConfig.nonExistingUpdatePattern,
+      ignore = List(aa0, ab0),
+      limit = Some(PosInt(10)),
+      includeScala = Some(false),
+      fileExtensions = List(".scala", ".sbt")
+    )
+
+    cfg2 |+| cfg shouldBe UpdatesConfig(
+      pin = List(ab0, aa0),
+      allow = UpdatesConfig.nonExistingUpdatePattern,
+      ignore = List(ab0, aa0),
+      limit = Some(PosInt(20)),
+      includeScala = Some(true),
+      fileExtensions = List(".sbt", ".scala")
+    )
+  }
+
+  test("mergePin: basic checks") {
+    UpdatesConfig.mergePin(Nil, Nil) shouldBe Nil
+    UpdatesConfig.mergePin(List(a00), Nil) shouldBe List(a00)
+    UpdatesConfig.mergePin(Nil, List(b00)) shouldBe List(b00)
+    UpdatesConfig.mergePin(List(a00), List(b00)) shouldBe List(a00, b00)
+
+    UpdatesConfig.mergePin(List(aa1), List(aa1, ac3)) shouldBe List(aa1, ac3)
+
+    UpdatesConfig.mergePin(List(aa1), List(aa2)) shouldBe List(aa1)
+    UpdatesConfig.mergePin(List(aa2), List(aa1)) shouldBe List(aa2)
+  }
+
+  test("mergeAllow: basic checks") {
+    UpdatesConfig.mergeAllow(Nil, Nil) shouldBe Nil
+    UpdatesConfig.mergeAllow(List(a00), Nil) shouldBe List(a00)
+    UpdatesConfig.mergeAllow(Nil, List(a00)) shouldBe List(a00)
+
+    UpdatesConfig.mergeAllow(List(a00), List(a00)) shouldBe List(a00)
+    UpdatesConfig.mergeAllow(List(a00), List(b00)) shouldBe UpdatesConfig.nonExistingUpdatePattern
+
+    UpdatesConfig.mergeAllow(List(a00), List(aa1, ab0, ac3)) shouldBe List(aa1, ab0, ac3)
+    UpdatesConfig.mergeAllow(List(aa1, ab0, ac3), List(a00)) shouldBe List(aa1, ab0, ac3)
+
+    UpdatesConfig.mergeAllow(List(aa0), List(aa1, aa2)) shouldBe List(aa1, aa2)
+    UpdatesConfig.mergeAllow(List(aa1, aa2), List(aa0)) shouldBe List(aa1, aa2)
+
+    UpdatesConfig.mergeAllow(List(aa0), List(aa0, ab0)) shouldBe List(aa0)
+    UpdatesConfig.mergeAllow(List(aa0, ab0), List(aa0, ab0)) shouldBe List(aa0, ab0)
+
+    UpdatesConfig.mergeAllow(List(aa1), List(aa1)) shouldBe List(aa1)
+    UpdatesConfig.mergeAllow(List(aa1), List(aa2)) shouldBe UpdatesConfig.nonExistingUpdatePattern
+    UpdatesConfig.mergeAllow(List(aa1, ab0, ac0), List(aa2, ac0)) shouldBe List(ac0)
+    UpdatesConfig.mergeAllow(List(aa1, aa2), List(aa1, aa2)) shouldBe List(aa1, aa2)
+    UpdatesConfig.mergeAllow(List(aa1, aa2), List(aa1, ac3)) shouldBe List(aa1)
+  }
+
+  test("mergeIgnore: basic checks") {
+    UpdatesConfig.mergeIgnore(Nil, Nil) shouldBe Nil
+    UpdatesConfig.mergeIgnore(List(a00), Nil) shouldBe List(a00)
+    UpdatesConfig.mergeIgnore(Nil, List(b00)) shouldBe List(b00)
+    UpdatesConfig.mergeIgnore(List(aa1, b00), List(aa1, aa2)) shouldBe List(aa1, b00, aa2)
+  }
+
+  test("mergeFileExtensions: basic checks") {
+    UpdatesConfig.mergeFileExtensions(Nil, Nil) shouldBe Nil
+    UpdatesConfig.mergeFileExtensions(Nil, List("txt")) shouldBe List("txt")
+    UpdatesConfig.mergeFileExtensions(List("txt"), Nil) shouldBe List("txt")
+    UpdatesConfig.mergeFileExtensions(List("txt"), List("txt")) shouldBe List("txt")
+    UpdatesConfig.mergeFileExtensions(List("a", "b", "c"), List("b", "d")) shouldBe List("b")
+    UpdatesConfig.mergeFileExtensions(
+      List("a"),
+      List("b")
+    ) shouldBe UpdatesConfig.nonExistingFileExtension
+  }
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala
index fcbf7f8a..2232f543 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala
@@ -7,6 +7,8 @@ import org.scalatest.funsuite.AnyFunSuite
 import org.scalatest.matchers.should.Matchers
 
 class PruningAlgTest extends AnyFunSuite with Matchers {
+  private val defaultConf = config.defaultRepoConfigFile.map(_.toString).getOrElse("")
+
   test("needsAttention") {
     val repo = Repo("fthomas", "scalafix-test")
     val repoCacheFile =
@@ -60,6 +62,7 @@ class PruningAlgTest extends AnyFunSuite with Matchers {
     state shouldBe initial.copy(
       commands = Vector(
         List("read", repoCacheFile.toString),
+        List("read", defaultConf),
         List("read", pullRequestsFile.toString)
       ),
       logs = Vector(
@@ -167,6 +170,7 @@ class PruningAlgTest extends AnyFunSuite with Matchers {
     state shouldBe initial.copy(
       commands = Vector(
         List("read", repoCacheFile.toString),
+        List("read", defaultConf),
         List("read", pullRequestsFile.toString)
       ),
       logs = Vector(
@@ -284,6 +288,7 @@ class PruningAlgTest extends AnyFunSuite with Matchers {
     state shouldBe initial.copy(
       commands = Vector(
         List("read", repoCacheFile.toString),
+        List("read", defaultConf),
         List("read", versionsFile.toString),
         List("read", pullRequestsFile.toString),
         List("read", versionsFile.toString),