Repository: scala-steward-org/scala-steward
Pull request #2748 (2022-10-23 14:11:40)

Rename the `AnUpdate` hierarchy
This new ADT was introduced in #2714 for enabling supporting grouping updates, but the naming was not very good. This PR fixes the hierarchy names.

## Renames in this PR
- `Update.Single` => `Update.ForArtifactId`
- `Update.Group` => `Update.ForGroupId`
- `Update` => `Update.Single`
- `GroupedUpdate` => `Update.Grouped`
- `AnUpdate` => `Update`

## Functional changes in this PR

Instead of deriving circe's `Codec` for the whole `Update` ADT, individual `Decoder` are created to ensure JSON created using older versions of Scala Steward will still be decoded.

```scala
implicit val ForGroupIdDecoder: Decoder[ForGroupId] = {
  val derived = deriveDecoder[ForGroupId]
  derived
    .prepare(_.downField("ForGroupId"))
    .or(derived.prepare(_.downField("Group")))
}

implicit val UpdateEncoder: Encoder[Update] = deriveEncoder

implicit val UpdateDecoder: Decoder[Update] =
    ForArtifactIdDecoder
      .widen[Update]
      .or(ForGroupIdDecoder.widen[Update])
      .or(Decoder[Grouped].widen[Update])
```

vs

```scala
implicit val UpdateCodec: Codec[Update] = deriveCodec
```


Patch:
diff --git a/modules/benchmark/src/main/scala/org/scalasteward/benchmark/UpdatesConfigBenchmark.scala b/modules/benchmark/src/main/scala/org/scalasteward/benchmark/UpdatesConfigBenchmark.scala
index c29ce040..a560834e 100644
--- a/modules/benchmark/src/main/scala/org/scalasteward/benchmark/UpdatesConfigBenchmark.scala
+++ b/modules/benchmark/src/main/scala/org/scalasteward/benchmark/UpdatesConfigBenchmark.scala
@@ -33,7 +33,7 @@ class UpdatesConfigBenchmark {
     val newerVersions = Nel
       .of("2.0.0", "2.1.0", "2.1.1", "2.2.0", "3.0.0", "3.1.0", "3.2.1", "3.3.3", "4.0", "5.0")
       .map(Version.apply)
-    val update = Update.Single(dependency, newerVersions)
+    val update = Update.ForArtifactId(dependency, newerVersions)
 
     UpdatesConfig().keep(update)
     UpdatesConfig(allow = List(UpdatePattern(groupId, None, None))).keep(update)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala
index cc6f3905..fa46b8e4 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/mill/MillAlg.scala
@@ -102,7 +102,7 @@ object MillAlg {
   private def millMainArtifact(version: Version): Dependency =
     Dependency(millMainGroupId, millMainArtifactId, version)
 
-  def isMillMainUpdate(update: Update): Boolean =
+  def isMillMainUpdate(update: Update.Single): Boolean =
     update.groupId === millMainGroupId && update.artifactIds.exists(
       _.name === millMainArtifactId.name
     )
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/Update.scala b/modules/core/src/main/scala/org/scalasteward/core/data/Update.scala
index 4f1d3133..c589a4ca 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/Update.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/Update.scala
@@ -18,116 +18,77 @@ package org.scalasteward.core.data
 
 import cats.Order
 import cats.implicits._
-import io.circe.Codec
-import io.circe.syntax._
 import io.circe.generic.semiauto._
-import org.scalasteward.core.data.Update.Group
-import org.scalasteward.core.data.Update.Single
 import org.scalasteward.core.repoconfig.PullRequestGroup
 import org.scalasteward.core.util
 import org.scalasteward.core.util.Nel
 import org.scalasteward.core.util.string.MinLengthString
 import io.circe.Decoder
+import io.circe.Encoder
 
-sealed trait AnUpdate {
+sealed trait Update {
 
-  def on[A](update: Update => A, grouped: GroupedUpdate => A): A = this match {
-    case g: GroupedUpdate => grouped(g)
-    case u: Update        => update(u)
+  def on[A](update: Update.Single => A, grouped: Update.Grouped => A): A = this match {
+    case g: Update.Grouped => grouped(g)
+    case u: Update.Single  => update(u)
   }
 
   def show: String
 
 }
 
-object AnUpdate {
+object Update {
 
-  implicit val AnUpdateCodec: Codec[AnUpdate] = Codec.from(
-    Decoder[GroupedUpdate].widen[AnUpdate].or(Decoder[Update].widen[AnUpdate]),
-    _.on(_.asJson, _.asJson)
-  )
+  final case class Grouped(
+      name: String,
+      title: Option[String],
+      updates: List[Update.ForArtifactId]
+  ) extends Update {
 
-}
+    override def show: String = name
 
-final case class GroupedUpdate(name: String, title: Option[String], updates: List[Update])
-    extends AnUpdate {
+  }
 
-  override def show: String = name
+  sealed trait Single extends Product with Serializable with Update {
+    def crossDependencies: Nel[CrossDependency]
+    def dependencies: Nel[Dependency]
+    def groupId: GroupId
+    def artifactIds: Nel[ArtifactId]
+    def mainArtifactId: String
+    def currentVersion: Version
+    def newerVersions: Nel[Version]
 
-}
+    final def name: String = Update.nameOf(groupId, mainArtifactId)
 
-object GroupedUpdate {
+    final def nextVersion: Version = newerVersions.head
 
-  /**
-    * Processes the provided updates using the group configuration. Each update will only be present in the
-    * first group it falls into.
-    *
-    * Updates that do not fall into any group will be returned back in the second return parameter.
-    */
-  /**
-    * Processes the provided updates using the group configuration. Each update will only be present in the
-    * first group it falls into.
-    *
-    * Updates that do not fall into any group will be returned back in the second return parameter.
-    */
-  def from(
-      groups: List[PullRequestGroup],
-      updates: List[Update.Single]
-  ): (List[GroupedUpdate], List[Update.Single]) =
-    groups.foldLeft((List.empty[GroupedUpdate], updates)) { case ((grouped, notGrouped), group) =>
-      notGrouped.partition(group.matches) match {
-        case (Nil, rest)     => (grouped, rest)
-        case (matched, rest) => (grouped :+ GroupedUpdate(group.name, group.title, matched), rest)
+    final override def show: String = {
+      val artifacts = this match {
+        case s: ForArtifactId => s.crossDependency.showArtifactNames
+        case g: ForGroupId => g.crossDependencies.map(_.showArtifactNames).mkString_("{", ", ", "}")
+      }
+      val versions = {
+        val vs0 = (currentVersion :: newerVersions).toList
+        val vs1 = if (vs0.size > 6) vs0.take(3) ++ ("..." :: vs0.takeRight(3)) else vs0
+        vs1.mkString("", " -> ", "")
       }
+      s"$groupId:$artifacts : $versions"
     }
 
-  implicit val GroupedUpdateCodec: Codec[GroupedUpdate] = deriveCodec
-
-}
-
-sealed trait Update extends Product with Serializable with AnUpdate {
-  def crossDependencies: Nel[CrossDependency]
-  def dependencies: Nel[Dependency]
-  def groupId: GroupId
-  def artifactIds: Nel[ArtifactId]
-  def mainArtifactId: String
-  def currentVersion: Version
-  def newerVersions: Nel[Version]
-
-  final def name: String =
-    Update.nameOf(groupId, mainArtifactId)
-
-  final def nextVersion: Version =
-    newerVersions.head
-
-  final override def show: String = {
-    val artifacts = this match {
-      case s: Single => s.crossDependency.showArtifactNames
-      case g: Group  => g.crossDependencies.map(_.showArtifactNames).mkString_("{", ", ", "}")
+    def withNewerVersions(versions: Nel[Version]): Update.Single = this match {
+      case s @ ForArtifactId(_, _, _, _) =>
+        s.copy(newerVersions = versions)
+      case g @ ForGroupId(_, _) =>
+        g.copy(newerVersions = versions)
     }
-    val versions = {
-      val vs0 = (currentVersion :: newerVersions).toList
-      val vs1 = if (vs0.size > 6) vs0.take(3) ++ ("..." :: vs0.takeRight(3)) else vs0
-      vs1.mkString("", " -> ", "")
-    }
-    s"$groupId:$artifacts : $versions"
-  }
-
-  def withNewerVersions(versions: Nel[Version]): Update = this match {
-    case s @ Single(_, _, _, _) =>
-      s.copy(newerVersions = versions)
-    case g @ Group(_, _) =>
-      g.copy(newerVersions = versions)
   }
-}
 
-object Update {
-  final case class Single(
+  final case class ForArtifactId(
       crossDependency: CrossDependency,
       newerVersions: Nel[Version],
       newerGroupId: Option[GroupId] = None,
       newerArtifactId: Option[String] = None
-  ) extends Update {
+  ) extends Single {
     override def crossDependencies: Nel[CrossDependency] =
       Nel.one(crossDependency)
 
@@ -150,10 +111,10 @@ object Update {
       crossDependency.head.artifactId
   }
 
-  final case class Group(
+  final case class ForGroupId(
       crossDependencies: Nel[CrossDependency],
       newerVersions: Nel[Version]
-  ) extends Update {
+  ) extends Single {
     override def dependencies: Nel[Dependency] =
       crossDependencies.flatMap(_.dependencies)
 
@@ -191,29 +152,79 @@ object Update {
     else
       artifactId
 
-  def groupByArtifactIdName(updates: List[Single]): List[Single] = {
+  def groupByArtifactIdName(updates: List[ForArtifactId]): List[ForArtifactId] = {
     val groups0 =
       updates.groupByNel(s => (s.groupId, s.artifactId.name, s.currentVersion, s.newerVersions))
     val groups1 = groups0.values.map { group =>
       val dependencies = group.flatMap(_.crossDependency.dependencies).distinct.sorted
       group.head.copy(crossDependency = CrossDependency(dependencies))
     }
-    groups1.toList.distinct.sortBy(u => u: Update)
+    groups1.toList.distinct.sortBy(u => u: Update.Single)
   }
 
-  def groupByGroupId(updates: List[Single]): List[Update] = {
+  def groupByGroupId(updates: List[ForArtifactId]): List[Single] = {
     val groups0 =
       updates.groupByNel(s => (s.groupId, s.currentVersion, s.newerVersions))
     val groups1 = groups0.values.map { group =>
       if (group.tail.isEmpty) group.head
-      else Group(group.map(_.crossDependency), group.head.newerVersions)
+      else ForGroupId(group.map(_.crossDependency), group.head.newerVersions)
     }
     groups1.toList.distinct.sorted
   }
 
-  implicit val updateCodec: Codec[Update] =
-    deriveCodec
+  /**
+    * Processes the provided updates using the group configuration. Each update will only be present in the
+    * first group it falls into.
+    *
+    * Updates that do not fall into any group will be returned back in the second return parameter.
+    */
+  def groupByPullRequestGroup(
+      groups: List[PullRequestGroup],
+      updates: List[Update.ForArtifactId]
+  ): (List[Grouped], List[Update.ForArtifactId]) =
+    groups.foldLeft((List.empty[Grouped], updates)) { case ((grouped, notGrouped), group) =>
+      notGrouped.partition(group.matches) match {
+        case (Nil, rest)     => (grouped, rest)
+        case (matched, rest) => (grouped :+ Grouped(group.name, group.title, matched), rest)
+      }
+    }
+
+  // TODO: Derive all instances of `Encoder`/`Decoder` here using `deriveCodec`
+  // Partially manually implemented so we don't fail reading old caches (those
+  // still using `Single`/`Group`)
+
+  implicit val ForArtifactIdEncoder: Encoder[ForArtifactId] = deriveEncoder
+
+  implicit val ForArtifactIdDecoder: Decoder[ForArtifactId] = {
+    val derived = deriveDecoder[ForArtifactId]
+    derived
+      .prepare(_.downField("ForArtifactId"))
+      .or(derived.prepare(_.downField("Single")))
+  }
+
+  implicit val ForGroupIdEncoder: Encoder[ForGroupId] = deriveEncoder
+
+  implicit val ForGroupIdDecoder: Decoder[ForGroupId] = {
+    val derived = deriveDecoder[ForGroupId]
+    derived
+      .prepare(_.downField("ForGroupId"))
+      .or(derived.prepare(_.downField("Group")))
+  }
+
+  implicit val GroupedEncoder: Encoder[Grouped] = deriveEncoder
+
+  implicit val GroupedDecoder: Decoder[Grouped] =
+    deriveDecoder[Grouped].prepare(_.downField("Grouped"))
+
+  implicit val SingleOrder: Order[Single] =
+    Order.by((u: Single) => (u.crossDependencies, u.newerVersions))
+
+  implicit val UpdateEncoder: Encoder[Update] = deriveEncoder
+
+  implicit val UpdateDecoder: Decoder[Update] =
+    ForArtifactIdDecoder
+      .widen[Update]
+      .or(ForGroupIdDecoder.widen[Update])
+      .or(Decoder[Grouped].widen[Update])
 
-  implicit val updateOrder: Order[Update] =
-    Order.by((u: Update) => (u.crossDependencies, u.newerVersions))
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/UpdateData.scala b/modules/core/src/main/scala/org/scalasteward/core/data/UpdateData.scala
index 459cc11b..49bf1b2b 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/UpdateData.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/UpdateData.scala
@@ -23,7 +23,7 @@ import org.scalasteward.core.vcs.data.Repo
 final case class UpdateData(
     repoData: RepoData,
     fork: Repo,
-    update: AnUpdate,
+    update: Update,
     baseBranch: Branch,
     baseSha1: Sha1,
     updateBranch: Branch
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 3fe2f116..2121e103 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
@@ -46,7 +46,7 @@ final class EditAlg[F[_]](implicit
 ) {
   def applyUpdate(
       data: RepoData,
-      update: Update,
+      update: Update.Single,
       preCommit: F[Unit] = F.unit
   ): F[List[EditAttempt]] =
     findFilesContainingCurrentVersion(data.repo, data.config, update).flatMap {
@@ -77,7 +77,7 @@ final class EditAlg[F[_]](implicit
   private def findFilesContainingCurrentVersion(
       repo: Repo,
       config: RepoConfig,
-      update: Update
+      update: Update.Single
   ): F[Option[Nel[File]]] =
     workspaceAlg.repoDir(repo).flatMap { repoDir =>
       val fileFilter = isSourceFile(update, config.updates.fileExtensionsOrDefault) _
@@ -105,7 +105,7 @@ final class EditAlg[F[_]](implicit
       maybeCommit <- gitAlg.commitAllIfDirty(repo, migration.commitMessage(result))
     } yield ScalafixEdit(migration, result, maybeCommit)
 
-  private def bumpVersion(update: Update, files: Nel[File]): F[Boolean] = {
+  private def bumpVersion(update: Update.Single, files: Nel[File]): F[Boolean] = {
     val actions = UpdateHeuristic.all.map { heuristic =>
       logger.info(s"Trying heuristic '${heuristic.name}'") >>
         fileAlg.editFiles(files, heuristic.replaceVersion(update))
@@ -126,7 +126,7 @@ final class EditAlg[F[_]](implicit
   private def createUpdateEdit(
       repo: Repo,
       config: RepoConfig,
-      update: Update
+      update: Update.Single
   ): F[Option[EditAttempt]] = {
     val commitMsg = CommitMsg.replaceVariables(config.commits.messageOrDefault)(update, repo.branch)
     gitAlg.commitAllIfDirty(repo, commitMsg).map(_.map(commit => UpdateEdit(update, commit)))
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/EditAttempt.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/EditAttempt.scala
index 58e6e8f5..364357bd 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/EditAttempt.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/EditAttempt.scala
@@ -26,7 +26,7 @@ sealed trait EditAttempt extends Product with Serializable {
 }
 
 object EditAttempt {
-  final case class UpdateEdit(update: Update, commit: Commit) extends EditAttempt {
+  final case class UpdateEdit(update: Update.Single, commit: Commit) extends EditAttempt {
     override def commits: List[Commit] = List(commit)
   }
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/UpdateHeuristic.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/UpdateHeuristic.scala
index 97fd55fc..cf4c9cb1 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/UpdateHeuristic.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/UpdateHeuristic.scala
@@ -32,7 +32,7 @@ import scala.util.matching.Regex
   */
 final case class UpdateHeuristic(
     name: String,
-    replaceVersion: Update => String => Option[String]
+    replaceVersion: Update.Single => String => Option[String]
 )
 
 object UpdateHeuristic {
@@ -60,9 +60,9 @@ object UpdateHeuristic {
     raw"""(.*)(["|`]($ident)(?:"\s*%+\s*"|:+)($ident)(?:"\s*%\s*|:+))("?)($v)("|`)""".r
   }
 
-  private def replaceArtifactF(update: Update): String => Option[String] = { target =>
+  private def replaceArtifactF(update: Update.Single): String => Option[String] = { target =>
     update match {
-      case s @ Update.Single(_, _, Some(newerGroupId), Some(newerArtifactId)) =>
+      case s @ Update.ForArtifactId(_, _, Some(newerGroupId), Some(newerArtifactId)) =>
         val currentGroupId = Regex.quote(s.groupId.value)
         val currentArtifactId = Regex.quote(s.artifactId.name)
         val regex = s"""(?i)(.*)$currentGroupId(.*)$currentArtifactId""".r
@@ -80,20 +80,20 @@ object UpdateHeuristic {
   }
 
   private def defaultReplaceVersion(
-      getSearchTerms: Update => List[String],
-      getPrefixRegex: Update => Option[String] = _ => None
-  ): Update => String => Option[String] = {
+      getSearchTerms: Update.Single => List[String],
+      getPrefixRegex: Update.Single => Option[String] = _ => None
+  ): Update.Single => String => Option[String] = {
     def searchTermsToAlternation(terms: List[String]): Option[String] =
       Nel.fromList(terms.map(toFlexibleRegex).filterNot(_.isEmpty)).map(alternation(_))
 
-    def mkRegex(update: Update): Option[Regex] =
+    def mkRegex(update: Update.Single): Option[Regex] =
       searchTermsToAlternation(getSearchTerms(update).map(removeCommonSuffix)).map { searchTerms =>
         val prefix = getPrefixRegex(update).getOrElse("")
         val currentVersion = Regex.quote(update.currentVersion.value)
         s"(?i)(.*)($prefix$searchTerms.*?)$currentVersion(.?)".r
       }
 
-    def replaceVersionF(update: Update): String => Option[String] =
+    def replaceVersionF(update: Update.Single): String => Option[String] =
       mkRegex(update).fold((_: String) => Option.empty[String]) { regex => target =>
         replaceSomeInAllowedParts(
           regex,
@@ -124,10 +124,10 @@ object UpdateHeuristic {
       case _ => true
     }
 
-  private def searchTerms(update: Update): List[String] = {
+  private def searchTerms(update: Update.Single): List[String] = {
     val terms = update match {
-      case s: Update.Single => Nel.one(s.artifactId.name)
-      case g: Update.Group =>
+      case s: Update.ForArtifactId => Nel.one(s.artifactId.name)
+      case g: Update.ForGroupId =>
         g.artifactIds.map(_.name).concat(g.artifactIdsPrefix.map(_.value).toList)
     }
     terms.map(Update.nameOf(update.groupId, _)).toList
@@ -210,9 +210,9 @@ object UpdateHeuristic {
   val specific: UpdateHeuristic = UpdateHeuristic(
     name = "specific",
     replaceVersion = {
-      case update: Update.Single if isScalafmtUpdate(update) =>
+      case update: Update.ForArtifactId if isScalafmtUpdate(update) =>
         defaultReplaceVersion(_ => List("version"))(update)
-      case update: Update.Single if MillAlg.isMillMainUpdate(update) =>
+      case update: Update.ForArtifactId if MillAlg.isMillMainUpdate(update) =>
         // this is intended to update the `.mill-version`
         content =>
           Option.when(content.trim === update.currentVersion.value)(update.nextVersion.value)
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 3fdcab66..01de4fae 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
@@ -45,7 +45,7 @@ final class HookExecutor[F[_]](implicit
     workspaceAlg: WorkspaceAlg[F],
     F: MonadThrow[F]
 ) {
-  def execPostUpdateHooks(data: RepoData, update: Update): F[List[EditAttempt]] =
+  def execPostUpdateHooks(data: RepoData, update: Update.Single): F[List[EditAttempt]] =
     (HookExecutor.postUpdateHooks ++ data.config.postUpdateHooksOrDefault)
       .filter { hook =>
         hook.groupId.forall(update.groupId === _) &&
@@ -56,7 +56,11 @@ final class HookExecutor[F[_]](implicit
       .distinctBy(_.command)
       .traverse(execPostUpdateHook(data.repo, update, _))
 
-  private def execPostUpdateHook(repo: Repo, update: Update, hook: PostUpdateHook): F[EditAttempt] =
+  private def execPostUpdateHook(
+      repo: Repo,
+      update: Update.Single,
+      hook: PostUpdateHook
+  ): F[EditAttempt] =
     for {
       _ <- logger.info(
         s"Executing post-update hook for ${update.groupId}:${update.mainArtifactId} with command ${hook.showCommand}"
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/PostUpdateHook.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/PostUpdateHook.scala
index 439ce8d4..fd2ed185 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/PostUpdateHook.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/PostUpdateHook.scala
@@ -28,7 +28,7 @@ final case class PostUpdateHook(
     artifactId: Option[ArtifactId],
     command: Nel[String],
     useSandbox: Boolean,
-    commitMessage: Update => CommitMsg,
+    commitMessage: Update.Single => CommitMsg,
     enabledByCache: RepoCache => Boolean,
     enabledByConfig: RepoConfig => Boolean,
     addToGitBlameIgnoreRevs: Boolean
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 15471bd5..8a91b972 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
@@ -21,7 +21,7 @@ import org.scalasteward.core.data.Update
 import org.scalasteward.core.edit.scalafix.ScalafixMigration.ExecutionOrder
 
 final class ScalafixMigrationsFinder(migrations: List[ScalafixMigration]) {
-  def findMigrations(update: Update): (List[ScalafixMigration], List[ScalafixMigration]) =
+  def findMigrations(update: Update.Single): (List[ScalafixMigration], List[ScalafixMigration]) =
     migrations
       .filter { migration =>
         update.groupId === migration.groupId &&
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/CommitMsg.scala b/modules/core/src/main/scala/org/scalasteward/core/git/CommitMsg.scala
index 360960ae..2b9dfe7f 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/git/CommitMsg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/CommitMsg.scala
@@ -17,7 +17,7 @@
 package org.scalasteward.core.git
 
 import cats.syntax.all._
-import org.scalasteward.core.data.AnUpdate
+import org.scalasteward.core.data.Update
 import org.scalasteward.core.update.show
 import org.scalasteward.core.util.Nel
 
@@ -39,7 +39,7 @@ final case class CommitMsg(
 }
 
 object CommitMsg {
-  def replaceVariables(s: String)(update: AnUpdate, baseBranch: Option[Branch]): CommitMsg =
+  def replaceVariables(s: String)(update: Update, baseBranch: Option[Branch]): CommitMsg =
     update.on(
       u => {
         val artifactNameValue = show.oneLiner(u)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/package.scala b/modules/core/src/main/scala/org/scalasteward/core/git/package.scala
index 98b5dbd3..df7fecb4 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/git/package.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/package.scala
@@ -16,7 +16,7 @@
 
 package org.scalasteward.core
 
-import org.scalasteward.core.data.{AnUpdate, GroupedUpdate, Update}
+import org.scalasteward.core.data.Update
 import org.scalasteward.core.repoconfig.CommitsConfig
 import org.scalasteward.core.vcs.data.Repo
 
@@ -27,16 +27,16 @@ package object git {
 
   val updateBranchPrefix = "update"
 
-  def branchFor(update: AnUpdate, baseBranch: Option[Branch]): Branch = {
+  def branchFor(update: Update, baseBranch: Option[Branch]): Branch = {
     val base = baseBranch.fold("")(branch => s"${branch.name}/")
-    update match {
-      case g: GroupedUpdate => Branch(s"$updateBranchPrefix/$base${g.name}")
-      case u: Update        => Branch(s"$updateBranchPrefix/$base${u.name}-${u.nextVersion}")
-    }
+    update.on(
+      update = u => Branch(s"$updateBranchPrefix/$base${u.name}-${u.nextVersion}"),
+      grouped = g => Branch(s"$updateBranchPrefix/$base${g.name}")
+    )
   }
 
   def commitMsgFor(
-      update: Update,
+      update: Update.Single,
       commitsConfig: CommitsConfig,
       branch: Option[Branch]
   ): CommitMsg =
diff --git a/modules/core/src/main/scala/org/scalasteward/core/io/package.scala b/modules/core/src/main/scala/org/scalasteward/core/io/package.scala
index 13f883a1..6103a374 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/io/package.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/io/package.scala
@@ -24,12 +24,12 @@ import org.scalasteward.core.data.Update
 import org.scalasteward.core.scalafmt.{scalafmtArtifactId, scalafmtConfName, scalafmtGroupId}
 
 package object io {
-  def isSourceFile(update: Update, fileExtensions: Set[String])(file: File): Boolean = {
+  def isSourceFile(update: Update.Single, fileExtensions: Set[String])(file: File): Boolean = {
     val notInGitDir = !file.pathAsString.contains(".git/")
     notInGitDir && isSpecificOrGenericSourceFile(update, fileExtensions)(file)
   }
 
-  private def isSpecificOrGenericSourceFile(update: Update, fileExtensions: Set[String])(
+  private def isSpecificOrGenericSourceFile(update: Update.Single, fileExtensions: Set[String])(
       file: File
   ): Boolean =
     () match {
@@ -42,11 +42,11 @@ package object io {
   private def isGenericSourceFile(file: File, fileExtensions: Set[String]): Boolean =
     fileExtensions.exists(file.name.endsWith)
 
-  private def isSbtUpdate(update: Update): Boolean =
+  private def isSbtUpdate(update: Update.Single): Boolean =
     update.groupId === sbtGroupId &&
       update.artifactIds.exists(_.name === sbtArtifactId.name)
 
-  private def isScalafmtCoreUpdate(update: Update): Boolean =
+  private def isScalafmtCoreUpdate(update: Update.Single): Boolean =
     update.groupId === scalafmtGroupId &&
       update.artifactIds.exists(_.name === scalafmtArtifactId.name)
 }
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 0ee3ffbb..acd71116 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
@@ -46,11 +46,14 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
     urlChecker: UrlChecker[F],
     F: Concurrent[F]
 ) {
-  def nurture(data: RepoData, fork: RepoOut, updates: Nel[Update.Single]): F[Unit] =
+  def nurture(data: RepoData, fork: RepoOut, updates: Nel[Update.ForArtifactId]): F[Unit] =
     for {
       _ <- logger.info(s"Nurture ${data.repo.show}")
       baseBranch <- cloneAndSync(data.repo, fork)
-      (grouped, notGrouped) = GroupedUpdate.from(data.config.pullRequests.grouping, updates.toList)
+      (grouped, notGrouped) = Update.groupByPullRequestGroup(
+        data.config.pullRequests.grouping,
+        updates.toList
+      )
       finalUpdates = Update.groupByGroupId(notGrouped) ++ grouped
       _ <- updateDependencies(data, fork.repo, baseBranch, finalUpdates)
     } yield ()
@@ -65,7 +68,7 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
       data: RepoData,
       fork: Repo,
       baseBranch: Branch,
-      updates: List[AnUpdate]
+      updates: List[Update]
   ): F[Unit] =
     for {
       _ <- logger.info(util.logger.showUpdates(updates))
@@ -92,7 +95,7 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
       head = vcs.listingBranch(config.tpe, data.fork, data.updateBranch)
       pullRequests <- vcsApiAlg.listPullRequests(data.repo, head, data.baseBranch)
       result <- pullRequests.headOption match {
-        case Some(pr) if pr.state.isClosed && data.update.isInstanceOf[Update] =>
+        case Some(pr) if pr.state.isClosed && data.update.isInstanceOf[Update.Single] =>
           logger.info(s"PR ${pr.html_url} is closed") >>
             deleteRemoteBranch(data.repo, data.updateBranch).as(Ignored)
         case Some(pr) if !pr.state.isClosed =>
@@ -184,7 +187,7 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
       } yield Updated
 
   private def dependenciesUpdatedWithNextAndCurrentVersion(
-      update: AnUpdate
+      update: Update
   ): List[(Version, Dependency)] =
     update.on(
       u => u.dependencies.map(_.copy(version = u.nextVersion)).tupleLeft(u.currentVersion).toList,
diff --git a/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestData.scala b/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestData.scala
index 4d5aa230..15525283 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestData.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestData.scala
@@ -17,14 +17,14 @@
 package org.scalasteward.core.nurture
 
 import org.http4s.Uri
-import org.scalasteward.core.data.AnUpdate
+import org.scalasteward.core.data.Update
 import org.scalasteward.core.git.{Branch, Sha1}
 import org.scalasteward.core.vcs.data.{PullRequestNumber, PullRequestState}
 
 final case class PullRequestData[F[_]](
     url: Uri,
     baseSha1: Sha1,
-    update: AnUpdate,
+    update: Update,
     state: PullRequestState,
     number: F[PullRequestNumber],
     updateBranch: F[Branch]
diff --git a/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestRepository.scala b/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestRepository.scala
index 073687d1..4089ec15 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestRepository.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestRepository.scala
@@ -21,7 +21,7 @@ import cats.{Id, Monad}
 import io.circe.Codec
 import io.circe.generic.semiauto.deriveCodec
 import org.http4s.Uri
-import org.scalasteward.core.data.{AnUpdate, CrossDependency, GroupId, Update, Version}
+import org.scalasteward.core.data.{CrossDependency, GroupId, Update, Version}
 import org.scalasteward.core.git
 import org.scalasteward.core.git.{Branch, Sha1}
 import org.scalasteward.core.nurture.PullRequestRepository.Entry
@@ -66,10 +66,10 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri,
       }
       .void
 
-  def getObsoleteOpenPullRequests(repo: Repo, update: Update): F[List[PullRequestData[Id]]] =
+  def getObsoleteOpenPullRequests(repo: Repo, update: Update.Single): F[List[PullRequestData[Id]]] =
     kvStore.getOrElse(repo, Map.empty).map {
       _.collect {
-        case (url, Entry(baseSha1, u: Update, state, _, number, updateBranch))
+        case (url, Entry(baseSha1, u: Update.Single, state, _, number, updateBranch))
             if state === PullRequestState.Open &&
               u.withNewerVersions(update.newerVersions) === update &&
               u.nextVersion < update.nextVersion =>
@@ -87,7 +87,7 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri,
   ): F[Option[PullRequestData[Option]]] =
     kvStore.getOrElse(repo, Map.empty).map {
       _.filter {
-        case (_, Entry(_, u: Update, _, _, _, _)) =>
+        case (_, Entry(_, u: Update.Single, _, _, _, _)) =>
           UpdateAlg.isUpdateFor(u, crossDependency) && u.nextVersion === newVersion
         case _ => false
       }
@@ -112,7 +112,7 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri,
       case None => Map.empty
       case Some(pullRequests) =>
         pullRequests.values
-          .collect { case Entry(_, u: Update, _, entryCreatedAt, _, _) =>
+          .collect { case Entry(_, u: Update.Single, _, entryCreatedAt, _, _) =>
             (u.groupId, u.mainArtifactId, entryCreatedAt)
           }
           .groupBy { case (groupId, mainArtifactId, _) => (groupId, mainArtifactId) }
@@ -125,7 +125,7 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri,
 object PullRequestRepository {
   final case class Entry(
       baseSha1: Sha1,
-      update: AnUpdate,
+      update: Update,
       state: PullRequestState,
       entryCreatedAt: Timestamp,
       number: Option[PullRequestNumber],
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/GroupRepoConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/GroupRepoConfig.scala
index 0506825f..bb4255a2 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/GroupRepoConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/GroupRepoConfig.scala
@@ -19,7 +19,7 @@ package org.scalasteward.core.repoconfig
 import io.circe.Codec
 import io.circe.generic.extras.Configuration
 import io.circe.generic.semiauto.deriveCodec
-import org.scalasteward.core.data.{AnUpdate, Update}
+import org.scalasteward.core.data.Update
 import org.scalasteward.core.util.string.indentLines
 
 final case class GroupRepoConfig(
@@ -34,14 +34,14 @@ object GroupRepoConfig {
   implicit val groupPullConfigCodec: Codec[GroupRepoConfig] =
     deriveCodec
 
-  def configToSlowDownUpdatesFrequency(update: AnUpdate): String = {
-    val forUpdate: Update => String = {
-      case s: Update.Single =>
+  def configToSlowDownUpdatesFrequency(update: Update): String = {
+    val forUpdate: Update.Single => String = {
+      case s: Update.ForArtifactId =>
         s"""{
            |  pullRequests = { frequency = "@monthly" },
            |  dependency = { groupId = "${s.groupId}", artifactId = "${s.artifactId.name}" }
            |}""".stripMargin
-      case g: Update.Group =>
+      case g: Update.ForGroupId =>
         s"""{
            |  pullRequests = { frequency = "@monthly" },
            |  dependency = { groupId = "${g.groupId}" }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestGroup.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestGroup.scala
index 5447dbdf..cd0db88b 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestGroup.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestGroup.scala
@@ -31,7 +31,7 @@ case class PullRequestGroup(
   /**
     * Returns `true` if an update falls into this group; returns `false` otherwise.
     */
-  def matches(update: Update.Single): Boolean = filter.exists(_.matches(update))
+  def matches(update: Update.ForArtifactId): Boolean = filter.exists(_.matches(update))
 
 }
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestUpdateFilter.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestUpdateFilter.scala
index d4c4170a..b3bd36fe 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestUpdateFilter.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestUpdateFilter.scala
@@ -33,7 +33,7 @@ final case class PullRequestUpdateFilter private (
   /**
     * Returns `true` if an update falls into this filter; returns `false` otherwise.
     */
-  def matches(update: Update.Single): Boolean =
+  def matches(update: Update.ForArtifactId): Boolean =
     groupRegex.forall(_.matches(update.groupId.value)) &&
       artifactRegex.forall(_.matches(update.mainArtifactId)) &&
       version.forall(isMatchedVersion(_, update))
@@ -46,7 +46,7 @@ final case class PullRequestUpdateFilter private (
     new Regex(pattern)
   }
 
-  private def isMatchedVersion(versionType: SemVer.Change, update: Update.Single): Boolean =
+  private def isMatchedVersion(versionType: SemVer.Change, update: Update.ForArtifactId): Boolean =
     (SemVer.parse(update.currentVersion.value), SemVer.parse(update.nextVersion.value)).tupled
       .flatMap { case (current, next) => SemVer.getChangeEarly(current, next) }
       .map(_.render === versionType.render)
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 df8f27a2..33bfb0f4 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
@@ -20,7 +20,7 @@ import better.files.File
 import cats.MonadThrow
 import cats.syntax.all._
 import io.circe.config.parser
-import org.scalasteward.core.data.{AnUpdate, Update}
+import org.scalasteward.core.data.Update
 import org.scalasteward.core.io.{FileAlg, WorkspaceAlg}
 import org.scalasteward.core.repoconfig.RepoConfigAlg._
 import org.scalasteward.core.vcs.data.Repo
@@ -60,11 +60,11 @@ object RepoConfigAlg {
   def parseRepoConfig(input: String): Either[io.circe.Error, RepoConfig] =
     parser.decode[RepoConfig](input)
 
-  def configToIgnoreFurtherUpdates(update: AnUpdate): String = {
-    val forUpdate: Update => String = {
-      case s: Update.Single =>
+  def configToIgnoreFurtherUpdates(update: Update): String = {
+    val forUpdate: Update.Single => String = {
+      case s: Update.ForArtifactId =>
         s"""{ groupId = "${s.groupId}", artifactId = "${s.artifactId.name}" }""".stripMargin
-      case g: Update.Group =>
+      case g: Update.ForGroupId =>
         s"""{ groupId = "${g.groupId}" }""".stripMargin
     }
 
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 efc5242f..c84927ab 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
@@ -37,7 +37,7 @@ object UpdatePattern {
 
   def findMatch(
       patterns: List[UpdatePattern],
-      update: Update.Single,
+      update: Update.ForArtifactId,
       include: Boolean
   ): MatchResult = {
     val byGroupId = patterns.filter(_.groupId === update.groupId)
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 ebbd67fe..94cec9ce 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
@@ -43,20 +43,20 @@ final case class UpdatesConfig(
   def fileExtensionsOrDefault: Set[String] =
     fileExtensions.fold(UpdatesConfig.defaultFileExtensions)(_.toSet)
 
-  def keep(update: Update.Single): FilterResult =
+  def keep(update: Update.ForArtifactId): FilterResult =
     isAllowed(update).flatMap(isPinned).flatMap(isIgnored)
 
-  def preRelease(update: Update.Single): FilterResult =
+  def preRelease(update: Update.ForArtifactId): FilterResult =
     isAllowedPreReleases(update)
 
-  private def isAllowedPreReleases(update: Update.Single): FilterResult = {
+  private def isAllowedPreReleases(update: Update.ForArtifactId): FilterResult = {
     val m = UpdatePattern.findMatch(allowPreReleases, update, include = true)
     if (m.filteredVersions.nonEmpty)
       Right(update)
     else Left(NotAllowedByConfig(update))
   }
 
-  private def isAllowed(update: Update.Single): FilterResult = {
+  private def isAllowed(update: Update.ForArtifactId): FilterResult = {
     val m = UpdatePattern.findMatch(allow, update, include = true)
     if (m.filteredVersions.nonEmpty)
       Right(update.copy(newerVersions = Nel.fromListUnsafe(m.filteredVersions)))
@@ -65,7 +65,7 @@ final case class UpdatesConfig(
     else Left(NotAllowedByConfig(update))
   }
 
-  private def isPinned(update: Update.Single): FilterResult = {
+  private def isPinned(update: Update.ForArtifactId): FilterResult = {
     val m = UpdatePattern.findMatch(pin, update, include = true)
     if (m.filteredVersions.nonEmpty)
       Right(update.copy(newerVersions = Nel.fromListUnsafe(m.filteredVersions)))
@@ -74,7 +74,7 @@ final case class UpdatesConfig(
     else Left(VersionPinnedByConfig(update))
   }
 
-  private def isIgnored(update: Update.Single): FilterResult = {
+  private def isIgnored(update: Update.ForArtifactId): FilterResult = {
     val m = UpdatePattern.findMatch(ignore, update, include = false)
     if (m.filteredVersions.nonEmpty)
       Right(update.copy(newerVersions = Nel.fromListUnsafe(m.filteredVersions)))
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 22457082..6e4af90b 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
@@ -32,7 +32,7 @@ package object scalafmt {
   val scalafmtModule: (GroupId, ArtifactId) =
     (scalafmtGroupId, scalafmtArtifactId)
 
-  def isScalafmtUpdate(update: Update.Single): Boolean =
+  def isScalafmtUpdate(update: Update.ForArtifactId): Boolean =
     update.groupId === scalafmtGroupId && update.artifactId.name === scalafmtArtifactId.name
 
   private def scalafmtGroupIdBy(version: Version): GroupId =
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala
index cb9665d6..0be54625 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/FilterAlg.scala
@@ -30,11 +30,11 @@ final class FilterAlg[F[_]](implicit
 ) {
   def localFilterMany[G[_]: TraverseFilter](
       config: RepoConfig,
-      updates: G[Update.Single]
-  ): F[G[Update.Single]] =
+      updates: G[Update.ForArtifactId]
+  ): F[G[Update.ForArtifactId]] =
     updates.traverseFilter(update => logIfRejected(localFilter(update, config)))
 
-  private def logIfRejected(result: FilterResult): F[Option[Update.Single]] =
+  private def logIfRejected(result: FilterResult): F[Option[Update.ForArtifactId]] =
     result match {
       case Right(update) => F.pure(update.some)
       case Left(reason) =>
@@ -43,10 +43,10 @@ final class FilterAlg[F[_]](implicit
 }
 
 object FilterAlg {
-  type FilterResult = Either[RejectionReason, Update.Single]
+  type FilterResult = Either[RejectionReason, Update.ForArtifactId]
 
   sealed trait RejectionReason {
-    def update: Update.Single
+    def update: Update.ForArtifactId
     def show: String =
       this match {
         case IgnoredByConfig(_)         => "ignored by config"
@@ -57,16 +57,16 @@ object FilterAlg {
       }
   }
 
-  final case class IgnoredByConfig(update: Update.Single) extends RejectionReason
-  final case class VersionPinnedByConfig(update: Update.Single) extends RejectionReason
-  final case class NotAllowedByConfig(update: Update.Single) extends RejectionReason
-  final case class NoSuitableNextVersion(update: Update.Single) extends RejectionReason
-  final case class VersionOrderingConflict(update: Update.Single) extends RejectionReason
+  final case class IgnoredByConfig(update: Update.ForArtifactId) extends RejectionReason
+  final case class VersionPinnedByConfig(update: Update.ForArtifactId) extends RejectionReason
+  final case class NotAllowedByConfig(update: Update.ForArtifactId) extends RejectionReason
+  final case class NoSuitableNextVersion(update: Update.ForArtifactId) extends RejectionReason
+  final case class VersionOrderingConflict(update: Update.ForArtifactId) extends RejectionReason
 
-  def localFilter(update: Update.Single, repoConfig: RepoConfig): FilterResult =
+  def localFilter(update: Update.ForArtifactId, repoConfig: RepoConfig): FilterResult =
     repoConfig.updates.keep(update).flatMap(globalFilter(_, repoConfig))
 
-  private def globalFilter(update: Update.Single, repoConfig: RepoConfig): FilterResult =
+  private def globalFilter(update: Update.ForArtifactId, repoConfig: RepoConfig): FilterResult =
     selectSuitableNextVersion(update, repoConfig).flatMap(checkVersionOrdering)
 
   def isDependencyConfigurationIgnored(dependency: Dependency): Boolean =
@@ -80,7 +80,7 @@ object FilterAlg {
     }
 
   private def selectSuitableNextVersion(
-      update: Update.Single,
+      update: Update.ForArtifactId,
       repoConfig: RepoConfig
   ): FilterResult = {
     val newerVersions = update.newerVersions.toList
@@ -94,7 +94,7 @@ object FilterAlg {
     }
   }
 
-  private def checkVersionOrdering(update: Update.Single): FilterResult = {
+  private def checkVersionOrdering(update: Update.ForArtifactId): FilterResult = {
     val current = coursier.core.Version(update.currentVersion.value)
     val next = coursier.core.Version(update.nextVersion.value)
     if (current > next) Left(VersionOrderingConflict(update)) else Right(update)
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 8985eb97..22f1b872 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
@@ -71,7 +71,7 @@ final class PruningAlg[F[_]](implicit
           } yield (freshStates, freshUpdates)
       }
       (updateStates1, updates1) = res
-      _ <- logger.info(util.logger.showUpdates(updates1.widen[Update]))
+      _ <- logger.info(util.logger.showUpdates(updates1.widen[Update.Single]))
       result <- filterUpdateStates(repo, repoConfig, updateStates1)
     } yield result
   }
@@ -80,8 +80,8 @@ final class PruningAlg[F[_]](implicit
       repoConfig: RepoConfig,
       dependencies: List[Scope.Dependency],
       outdatedDeps: List[DependencyOutdated],
-      allUpdates: List[Update.Single]
-  ): F[List[Update.Single]] = {
+      allUpdates: List[Update.ForArtifactId]
+  ): F[List[Update.ForArtifactId]] = {
     val unseenUpdates = outdatedDeps.map(_.update)
     val maybeOutdatedDeps = dependencies.filter { d =>
       unseenUpdates.exists { u =>
@@ -98,14 +98,18 @@ final class PruningAlg[F[_]](implicit
       repo: Repo,
       repoCache: RepoCache,
       dependencies: List[Dependency],
-      updates: List[Update.Single]
+      updates: List[Update.ForArtifactId]
   ): F[List[UpdateState]] = {
     val groupedDependencies = CrossDependency.group(dependencies)
     val groupedUpdates = Update.groupByArtifactIdName(updates)
     groupedDependencies.traverse(findUpdateState(repo, repoCache, groupedUpdates))
   }
 
-  private def findUpdateState(repo: Repo, repoCache: RepoCache, updates: List[Update.Single])(
+  private def findUpdateState(
+      repo: Repo,
+      repoCache: RepoCache,
+      updates: List[Update.ForArtifactId]
+  )(
       crossDependency: CrossDependency
   ): F[UpdateState] =
     updates.find(UpdateAlg.isUpdateFor(_, crossDependency)) match {
@@ -201,8 +205,8 @@ object PruningAlg {
 
   def removeOvertakingUpdates(
       dependencies: List[Dependency],
-      updates: List[Update.Single]
-  ): List[Update.Single] =
+      updates: List[Update.ForArtifactId]
+  ): List[Update.ForArtifactId] =
     updates.filterNot { update =>
       dependencies.exists { dependency =>
         dependency.groupId === update.groupId &&
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/UpdateAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/update/UpdateAlg.scala
index b854aaee..10e73caa 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/UpdateAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/UpdateAlg.scala
@@ -37,7 +37,7 @@ final class UpdateAlg[F[_]](implicit
   def findUpdate(
       dependency: Scope[Dependency],
       maxAge: Option[FiniteDuration]
-  ): F[Option[Update.Single]] =
+  ): F[Option[Update.ForArtifactId]] =
     findUpdateWithoutMigration(dependency, maxAge)
       .orElse(findUpdateWithMigration(dependency, maxAge))
       .value
@@ -46,7 +46,7 @@ final class UpdateAlg[F[_]](implicit
       dependencies: List[Scope.Dependency],
       repoConfig: RepoConfig,
       maxAge: Option[FiniteDuration]
-  ): F[List[Update.Single]] = {
+  ): F[List[Update.ForArtifactId]] = {
     val updates = dependencies.parTraverseFilter(findUpdate(_, maxAge))
     updates.flatMap(filterAlg.localFilterMany(repoConfig, _))
   }
@@ -54,20 +54,20 @@ final class UpdateAlg[F[_]](implicit
   private def findUpdateWithoutMigration(
       dependency: Scope[Dependency],
       maxAge: Option[FiniteDuration]
-  ): OptionT[F, Update.Single] =
+  ): OptionT[F, Update.ForArtifactId] =
     findNewerVersions(dependency, maxAge).map { newerVersions =>
-      Update.Single(CrossDependency(dependency.value), newerVersions)
+      Update.ForArtifactId(CrossDependency(dependency.value), newerVersions)
     }
 
   private def findUpdateWithMigration(
       dependency: Scope[Dependency],
       maxAge: Option[FiniteDuration]
-  ): OptionT[F, Update.Single] =
+  ): OptionT[F, Update.ForArtifactId] =
     OptionT.fromOption(artifactMigrationsFinder.findArtifactChange(dependency.value)).flatMap {
       artifactChange =>
         findNewerVersions(dependency.map(migrateDependency(_, artifactChange)), maxAge).map {
           newerVersions =>
-            Update.Single(
+            Update.ForArtifactId(
               CrossDependency(dependency.value),
               newerVersions,
               Some(artifactChange.groupIdAfter),
@@ -86,7 +86,7 @@ final class UpdateAlg[F[_]](implicit
 }
 
 object UpdateAlg {
-  def isUpdateFor(update: Update, crossDependency: CrossDependency): Boolean =
+  def isUpdateFor(update: Update.Single, crossDependency: CrossDependency): Boolean =
     crossDependency.dependencies.forall { dependency =>
       update.groupId === dependency.groupId &&
       update.currentVersion === dependency.version &&
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/show.scala b/modules/core/src/main/scala/org/scalasteward/core/update/show.scala
index 52de9d5a..bef363af 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/show.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/show.scala
@@ -23,7 +23,7 @@ import org.scalasteward.core.util
 import org.scalasteward.core.util.Nel
 
 object show {
-  def oneLiner(update: Update): String =
+  def oneLiner(update: Update.Single): String =
     commaSeparated(update.groupId, update.crossDependencies.map(_.head.artifactId.name))
 
   private def commaSeparated(groupId: GroupId, artifactIds: Nel[String]): String = {
diff --git a/modules/core/src/main/scala/org/scalasteward/core/util/logger.scala b/modules/core/src/main/scala/org/scalasteward/core/util/logger.scala
index f8d80a2a..7cea4d9c 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/util/logger.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/util/logger.scala
@@ -18,9 +18,7 @@ package org.scalasteward.core.util
 
 import cats.syntax.all._
 import cats.{Monad, MonadThrow}
-import org.scalasteward.core.data.GroupedUpdate
 import org.scalasteward.core.data.Update
-import org.scalasteward.core.data.AnUpdate
 import org.typelevel.log4cats.Logger
 import scala.concurrent.duration.FiniteDuration
 
@@ -68,11 +66,11 @@ object logger {
     }
   }
 
-  def showUpdates(allUpdates: List[AnUpdate]): String = {
+  def showUpdates(allUpdates: List[Update]): String = {
     val updates = allUpdates.map {
-      case g: GroupedUpdate =>
+      case g: Update.Grouped =>
         g.updates.map(_.show).mkString(s"${g.name} (group) {\n    ", "\n    ", "\n  }")
-      case u: Update => u.show
+      case u: Update.Single => u.show
     }
 
     val list = string.indentLines(updates)
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 27ec47bc..b4afa0e7 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
@@ -43,7 +43,7 @@ object NewPullRequestData {
     deriveEncoder
 
   def bodyFor(
-      update: AnUpdate,
+      update: Update,
       edits: List[EditAttempt],
       artifactIdToUrl: Map[String, Uri],
       releaseRelatedUrls: Map[String, List[ReleaseRelatedUrl]],
@@ -122,14 +122,14 @@ object NewPullRequestData {
         .mkString(" - ")
     }
 
-  def fromTo(update: Update): String =
+  def fromTo(update: Update.Single): String =
     s"from ${update.currentVersion} to ${update.nextVersion}"
 
-  def artifactsWithOptionalUrl(update: Update, artifactIdToUrl: Map[String, Uri]): String =
+  def artifactsWithOptionalUrl(update: Update.Single, artifactIdToUrl: Map[String, Uri]): String =
     update match {
-      case s: Update.Single =>
+      case s: Update.ForArtifactId =>
         artifactWithOptionalUrl(s.groupId, s.artifactId.name, artifactIdToUrl)
-      case g: Update.Group =>
+      case g: Update.ForGroupId =>
         g.crossDependencies
           .map(crossDependency =>
             s"* ${artifactWithOptionalUrl(g.groupId, crossDependency.head.artifactId.name, artifactIdToUrl)}\n"
@@ -147,7 +147,7 @@ object NewPullRequestData {
       case None      => s"$groupId:$artifactId"
     }
 
-  def oldVersionNote(files: List[String], update: AnUpdate): Option[Details] =
+  def oldVersionNote(files: List[String], update: Update): Option[Details] =
     Option.when(files.nonEmpty) {
       val (number, numberWithVersion) = update.on(
         update = u => ("number", s"number (${u.currentVersion})"),
@@ -165,7 +165,7 @@ object NewPullRequestData {
       )
     }
 
-  def adjustFutureUpdates(update: AnUpdate): Details = Details(
+  def adjustFutureUpdates(update: Update): Details = Details(
     "Adjust future updates",
     update.on(
       update = u =>
@@ -253,8 +253,8 @@ object NewPullRequestData {
     )
   }
 
-  def updateType(anUpdate: AnUpdate): List[String] = {
-    def forUpdate(update: Update) = {
+  def updateType(anUpdate: Update): List[String] = {
+    def forUpdate(update: Update.Single) = {
       val dependencies = update.dependencies
       if (dependencies.forall(_.configurations.contains("test")))
         "test-library-update"
@@ -270,7 +270,7 @@ object NewPullRequestData {
   }
 
   def labelsFor(
-      update: AnUpdate,
+      update: Update,
       edits: List[EditAttempt],
       filesWithOldVersion: List[String],
       includeMatchedLabels: Option[Regex]
@@ -281,7 +281,7 @@ object NewPullRequestData {
       case n           => s"n:$n"
     })
 
-    def semverForUpdate(u: Update): List[String] = {
+    def semverForUpdate(u: Update.Single): List[String] = {
       val semVerVersions =
         (SemVer.parse(u.currentVersion.value), SemVer.parse(u.nextVersion.value)).tupled
       val earlySemVerLabel = semVerVersions.flatMap { case (curr, next) =>


Test patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/data/UpdateState.scala b/modules/core/src/main/scala/org/scalasteward/core/update/data/UpdateState.scala
index 3bcc032e..65858ceb 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/data/UpdateState.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/data/UpdateState.scala
@@ -25,7 +25,7 @@ sealed trait UpdateState extends Product with Serializable {
 
 object UpdateState {
   sealed trait WithUpdate extends UpdateState {
-    def update: Update.Single
+    def update: Update.ForArtifactId
   }
 
   sealed trait WithPullRequest extends WithUpdate {
@@ -38,24 +38,24 @@ object UpdateState {
 
   final case class DependencyOutdated(
       crossDependency: CrossDependency,
-      update: Update.Single
+      update: Update.ForArtifactId
   ) extends WithUpdate
 
   final case class PullRequestUpToDate(
       crossDependency: CrossDependency,
-      update: Update.Single,
+      update: Update.ForArtifactId,
       pullRequest: Uri
   ) extends WithPullRequest
 
   final case class PullRequestOutdated(
       crossDependency: CrossDependency,
-      update: Update.Single,
+      update: Update.ForArtifactId,
       pullRequest: Uri
   ) extends WithPullRequest
 
   final case class PullRequestClosed(
       crossDependency: CrossDependency,
-      update: Update.Single,
+      update: Update.ForArtifactId,
       pullRequest: Uri
   ) extends WithPullRequest
 
diff --git a/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala b/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala
index 4636a489..c8aadd45 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala
@@ -43,7 +43,7 @@ object TestInstances {
   implicit def scopeCogen[T](implicit cogenT: Cogen[T]): Cogen[Scope[T]] =
     cogenT.contramap(_.value)
 
-  implicit val updateArbitrary: Arbitrary[Update] =
+  implicit val updateArbitrary: Arbitrary[Update.Single] =
     Arbitrary(
       for {
         groupId <- Gen.alphaStr
diff --git a/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala b/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala
index 27535c92..ad1fdb4b 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/TestSyntax.scala
@@ -80,43 +80,46 @@ object TestSyntax {
   implicit class DependencyAndNextVersionOps(
       private val self: (Dependency, String)
   ) extends AnyVal {
-    def single: Update.Single = Update.Single(CrossDependency(self._1), Nel.of(self._2.v))
+    def single: Update.ForArtifactId =
+      Update.ForArtifactId(CrossDependency(self._1), Nel.of(self._2.v))
   }
 
   implicit class DependencyAndNewerVersionsOps(
       private val self: (Dependency, Nel[String])
   ) extends AnyVal {
-    def single: Update.Single = Update.Single(CrossDependency(self._1), self._2.map(_.v))
+    def single: Update.ForArtifactId =
+      Update.ForArtifactId(CrossDependency(self._1), self._2.map(_.v))
   }
 
   implicit class DependenciesAndNextVersionOps(
       private val self: (Nel[Dependency], String)
   ) extends AnyVal {
-    def single: Update.Single = Update.Single(CrossDependency(self._1), Nel.of(self._2.v))
+    def single: Update.ForArtifactId =
+      Update.ForArtifactId(CrossDependency(self._1), Nel.of(self._2.v))
   }
 
   implicit class GroupIdAndArtifactIdsAndVersionAndNextVersionOps(
       private val self: (GroupId, Nel[ArtifactId], String, String)
   ) extends AnyVal {
-    def single: Update.Single = {
+    def single: Update.ForArtifactId = {
       val crossDependency = CrossDependency(self._2.map(aId => Dependency(self._1, aId, self._3.v)))
-      Update.Single(crossDependency, Nel.of(self._4.v))
+      Update.ForArtifactId(crossDependency, Nel.of(self._4.v))
     }
 
-    def group: Update.Group = {
+    def group: Update.ForGroupId = {
       val crossDependencies =
         self._2.map(aId => CrossDependency(Dependency(self._1, aId, self._3.v)))
-      Update.Group(crossDependencies, Nel.of(self._4.v))
+      Update.ForGroupId(crossDependencies, Nel.of(self._4.v))
     }
   }
 
   implicit class GroupIdAndManyArtifactIdsAndVersionAndNextVersionOps(
       private val self: (GroupId, Nel[Nel[ArtifactId]], String, String)
   ) extends AnyVal {
-    def group: Update.Group = {
+    def group: Update.ForGroupId = {
       val crossDependencies =
         self._2.map(aIds => CrossDependency(aIds.map(aId => Dependency(self._1, aId, self._3.v))))
-      Update.Group(crossDependencies, Nel.of(self._4.v))
+      Update.ForGroupId(crossDependencies, Nel.of(self._4.v))
     }
   }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/data/GroupedUpdateTest.scala b/modules/core/src/test/scala/org/scalasteward/core/data/GroupedUpdateTest.scala
index 5c876413..b5aa20d1 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/data/GroupedUpdateTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/data/GroupedUpdateTest.scala
@@ -7,16 +7,16 @@ import munit.FunSuite
 import org.scalasteward.core.repoconfig.{PullRequestGroup, PullRequestUpdateFilter}
 
 class GroupedUpdateTest extends FunSuite {
-  val updateSingleSpecs2Core: Update.Single =
+  val updateSingleSpecs2Core: Update.ForArtifactId =
     ("org.specs2".g % "specs2-core".a % "3.9.3" %> "3.9.5").single
 
-  val updateSingleSpecs2Scalacheck: Update.Single =
+  val updateSingleSpecs2Scalacheck: Update.ForArtifactId =
     ("org.specs2".g % "specs2-scalacheck".a % "3.9.3" %> "3.9.5").single
 
-  val updateSingleTypelevelAlgebra: Update.Single =
+  val updateSingleTypelevelAlgebra: Update.ForArtifactId =
     ("org.typelevel".g % "algebra".a % "3.9.4" %> "3.9.5").single
 
-  val updateSingleCirceCore: Update.Single =
+  val updateSingleCirceCore: Update.ForArtifactId =
     ("circe".g % "core".a % "1.4.2" %> "1.5.0").single
 
   test(
@@ -31,7 +31,10 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(List(pullRequestGroupSpecs2), List(updateSingleTypelevelAlgebra))
+      Update.groupByPullRequestGroup(
+        List(pullRequestGroupSpecs2),
+        List(updateSingleTypelevelAlgebra)
+      )
     assertEquals(grouped, List.empty)
     assertEquals(notGrouped, List(updateSingleTypelevelAlgebra))
   }
@@ -47,7 +50,7 @@ class GroupedUpdateTest extends FunSuite {
       )
     )
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestGroupTypeLevel),
         List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck)
       )
@@ -65,14 +68,16 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestGroupTypeLevel),
         List(updateSingleTypelevelAlgebra)
       )
 
     assertEquals(
       grouped,
-      List(GroupedUpdate("typelevel", Some("update typelevel"), List(updateSingleTypelevelAlgebra)))
+      List(
+        Update.Grouped("typelevel", Some("update typelevel"), List(updateSingleTypelevelAlgebra))
+      )
     )
     assertEquals(notGrouped, List.empty)
   }
@@ -87,7 +92,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestGroupSpecs2),
         List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck)
       )
@@ -95,7 +100,7 @@ class GroupedUpdateTest extends FunSuite {
     assertEquals(
       grouped,
       List(
-        GroupedUpdate(
+        Update.Grouped(
           "specs2",
           Some("update specs2"),
           List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck)
@@ -130,17 +135,17 @@ class GroupedUpdateTest extends FunSuite {
     )
     val pullRequestGroups = List(pullRequestGroupSpecs2, pullRequestGroupTypeLevel)
 
-    val (grouped, notGrouped) = GroupedUpdate.from(pullRequestGroups, updates)
+    val (grouped, notGrouped) = Update.groupByPullRequestGroup(pullRequestGroups, updates)
 
     assertEquals(
       grouped,
       List(
-        GroupedUpdate(
+        Update.Grouped(
           "specs2",
           Some("update specs2"),
           List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck)
         ),
-        GroupedUpdate("typelevel", Some("update typelevel"), List(updateSingleTypelevelAlgebra))
+        Update.Grouped("typelevel", Some("update typelevel"), List(updateSingleTypelevelAlgebra))
       )
     )
     assertEquals(notGrouped, List(updateSingleCirceCore))
@@ -161,12 +166,12 @@ class GroupedUpdateTest extends FunSuite {
     )
     val pullRequestGroups = List(pullRequestGroupOrgWildcard)
 
-    val (grouped, notGrouped) = GroupedUpdate.from(pullRequestGroups, updates)
+    val (grouped, notGrouped) = Update.groupByPullRequestGroup(pullRequestGroups, updates)
 
     assertEquals(
       grouped,
       List(
-        GroupedUpdate(
+        Update.Grouped(
           "org",
           Some("update org"),
           List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck, updateSingleTypelevelAlgebra)
@@ -184,7 +189,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestGroupWildcardAll),
         List(
           updateSingleTypelevelAlgebra,
@@ -195,7 +200,7 @@ class GroupedUpdateTest extends FunSuite {
     assertEquals(
       grouped,
       List(
-        GroupedUpdate(
+        Update.Grouped(
           "all wildcard",
           Some("update all wildcard"),
           List(
@@ -218,14 +223,14 @@ class GroupedUpdateTest extends FunSuite {
       )
     )
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2CoreWithoutGroup),
         List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core)
       )
     assertEquals(
       grouped,
       List(
-        GroupedUpdate(
+        Update.Grouped(
           "specs2 core",
           Some("update specs2 core"),
           List(updateSingleSpecs2Core)
@@ -247,14 +252,14 @@ class GroupedUpdateTest extends FunSuite {
 
   test("GroupedUpdate.from: Group multiple Update.Single for an ArtifactId filter with a group") {
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2CoreWithGroup),
         List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core)
       )
     assertEquals(
       grouped,
       List(
-        GroupedUpdate(
+        Update.Grouped(
           "specs2 core",
           Some("update specs2 core"),
           List(updateSingleSpecs2Core)
@@ -278,7 +283,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2CoreWithInvalidGroup),
         List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core)
       )
@@ -299,7 +304,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2WildcardWithoutGroup),
         List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core, updateSingleTypelevelAlgebra)
       )
@@ -307,7 +312,7 @@ class GroupedUpdateTest extends FunSuite {
     assertEquals(
       grouped,
       List(
-        GroupedUpdate(
+        Update.Grouped(
           "specs2",
           Some("update specs2"),
           List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core)
@@ -330,14 +335,14 @@ class GroupedUpdateTest extends FunSuite {
       )
     )
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2WildcardWithGroup),
         List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core, updateSingleTypelevelAlgebra)
       )
     assertEquals(
       grouped,
       List(
-        GroupedUpdate(
+        Update.Grouped(
           "specs2",
           Some("update specs2"),
           List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core)
@@ -361,7 +366,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
         List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core, updateSingleTypelevelAlgebra)
       )
@@ -373,9 +378,9 @@ class GroupedUpdateTest extends FunSuite {
     )
   }
 
-  val majorUpdate: Update.Single = ("org.major".g % "major".a % "3.1.2" %> "4.0.0").single
-  val minorUpdate: Update.Single = ("org.minor".g % "minor".a % "3.1.2" %> "3.2.0").single
-  val patchUpdate: Update.Single = ("org.patch".g % "patch".a % "3.1.2" %> "3.1.3").single
+  val majorUpdate: Update.ForArtifactId = ("org.major".g % "major".a % "3.1.2" %> "4.0.0").single
+  val minorUpdate: Update.ForArtifactId = ("org.minor".g % "minor".a % "3.1.2" %> "3.2.0").single
+  val patchUpdate: Update.ForArtifactId = ("org.patch".g % "patch".a % "3.1.2" %> "3.1.3").single
 
   test(
     "GroupedUpdate.from: Group major updates"
@@ -390,7 +395,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
         List(majorUpdate, minorUpdate, patchUpdate)
       )
@@ -398,7 +403,7 @@ class GroupedUpdateTest extends FunSuite {
     assertEquals(
       grouped,
       List(
-        GroupedUpdate("major", Some("update major"), List(majorUpdate))
+        Update.Grouped("major", Some("update major"), List(majorUpdate))
       )
     )
 
@@ -418,7 +423,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
         List(majorUpdate, minorUpdate, patchUpdate)
       )
@@ -426,7 +431,7 @@ class GroupedUpdateTest extends FunSuite {
     assertEquals(
       grouped,
       List(
-        GroupedUpdate("minor", Some("update minor"), List(minorUpdate))
+        Update.Grouped("minor", Some("update minor"), List(minorUpdate))
       )
     )
 
@@ -446,7 +451,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
         List(majorUpdate, minorUpdate, patchUpdate)
       )
@@ -454,7 +459,7 @@ class GroupedUpdateTest extends FunSuite {
     assertEquals(
       grouped,
       List(
-        GroupedUpdate("patch", Some("update patch"), List(patchUpdate))
+        Update.Grouped("patch", Some("update patch"), List(patchUpdate))
       )
     )
 
@@ -476,7 +481,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
         List(majorUpdate, minorUpdate, patchUpdate)
       )
@@ -484,7 +489,11 @@ class GroupedUpdateTest extends FunSuite {
     assertEquals(
       grouped,
       List(
-        GroupedUpdate("patch & minor", Some("update patch & minor"), List(minorUpdate, patchUpdate))
+        Update.Grouped(
+          "patch & minor",
+          Some("update patch & minor"),
+          List(minorUpdate, patchUpdate)
+        )
       )
     )
 
@@ -494,11 +503,11 @@ class GroupedUpdateTest extends FunSuite {
   test(
     "GroupedUpdate.from: Group major & minor updates with version numbers containing a date and commit hash"
   ) {
-    val majorUpdate: Update.Single =
+    val majorUpdate: Update.ForArtifactId =
       ("org.major".g % "major".a % "1.0.0-20220920-180024-dd318047" %> "2.0.0-20220922-180024-dd318047").single
-    val minorUpdate: Update.Single =
+    val minorUpdate: Update.ForArtifactId =
       ("org.minor".g % "minor".a % "1.0.0-20220920-180024-dd318047" %> "1.1.0-20220922-180024-dd318047").single
-    val patchUpdate: Update.Single =
+    val patchUpdate: Update.ForArtifactId =
       ("org.patch".g % "patch".a % "1.0.0-20220920-180024-dd318047" %> "1.0.1-20220922-180024-dd318047").single
 
     val pullRequestArtifactSpecs2WildcardWithInvalidGroup: PullRequestGroup = PullRequestGroup(
@@ -513,7 +522,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
         List(majorUpdate, minorUpdate, patchUpdate)
       )
@@ -521,7 +530,11 @@ class GroupedUpdateTest extends FunSuite {
     assertEquals(
       grouped,
       List(
-        GroupedUpdate("major & minor", Some("update major & minor"), List(majorUpdate, minorUpdate))
+        Update.Grouped(
+          "major & minor",
+          Some("update major & minor"),
+          List(majorUpdate, minorUpdate)
+        )
       )
     )
 
@@ -531,11 +544,11 @@ class GroupedUpdateTest extends FunSuite {
   test(
     "GroupedUpdate.from: Group patch updates with version numbers containing a date and commit hash"
   ) {
-    val majorUpdate: Update.Single =
+    val majorUpdate: Update.ForArtifactId =
       ("org.major".g % "major".a % "1.0.0-20220920-180024-dd318047" %> "2.0.0-20220922-180024-dd318047").single
-    val minorUpdate: Update.Single =
+    val minorUpdate: Update.ForArtifactId =
       ("org.minor".g % "minor".a % "1.0.0-20220920-180024-dd318047" %> "1.1.0-20220922-180024-dd318047").single
-    val patchUpdate: Update.Single =
+    val patchUpdate: Update.ForArtifactId =
       ("org.patch".g % "patch".a % "1.0.0-20220920-180024-dd318047" %> "1.0.1-20220920-180024-dd318047").single
 
     val pullRequestArtifactSpecs2WildcardWithInvalidGroup: PullRequestGroup = PullRequestGroup(
@@ -548,7 +561,7 @@ class GroupedUpdateTest extends FunSuite {
     )
 
     val (grouped, notGrouped) =
-      GroupedUpdate.from(
+      Update.groupByPullRequestGroup(
         List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
         List(majorUpdate, minorUpdate, patchUpdate)
       )
@@ -556,7 +569,7 @@ class GroupedUpdateTest extends FunSuite {
     assertEquals(
       grouped,
       List(
-        GroupedUpdate("patch", Some("update patch"), List(patchUpdate))
+        Update.Grouped("patch", Some("update patch"), List(patchUpdate))
       )
     )
 
diff --git a/modules/core/src/test/scala/org/scalasteward/core/data/UpdateTest.scala b/modules/core/src/test/scala/org/scalasteward/core/data/UpdateTest.scala
index 4c622484..901a073c 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/data/UpdateTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/data/UpdateTest.scala
@@ -57,7 +57,7 @@ class UpdateTest extends FunSuite {
     assertEquals(Update.groupByArtifactIdName(List(update0, update1, update2)), expected)
   }
 
-  test("Single.show") {
+  test("Update.show") {
     val update = ("org.specs2".g % "specs2-core".a % "3.9.4" % "test" %> "3.9.5").single
     assertEquals(update.show, "org.specs2:specs2-core : 3.9.4 -> 3.9.5")
   }
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 395a4179..8f1d18c0 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
@@ -288,7 +288,7 @@ class EditAlgTest extends FunSuite {
 
   private def runApplyUpdate(
       repo: Repo,
-      update: Update,
+      update: Update.Single,
       files: Map[String, String]
   ): Map[String, String] = {
     val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/UpdateHeuristicTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/UpdateHeuristicTest.scala
index aca0893b..e38d6492 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/UpdateHeuristicTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/UpdateHeuristicTest.scala
@@ -563,7 +563,7 @@ class UpdateHeuristicTest extends FunSuite {
 }
 
 object UpdateHeuristicTest {
-  implicit class UpdateOps(update: Update) {
+  implicit class UpdateOps(update: Update.Single) {
     def replaceVersionIn(target: String): (Option[String], String) =
       UpdateHeuristic.all.foldLeft((Option.empty[String], "")) {
         case ((None, _), heuristic) => (heuristic.replaceVersion(update)(target), heuristic.name)
diff --git a/modules/core/src/test/scala/org/scalasteward/core/git/gitTest.scala b/modules/core/src/test/scala/org/scalasteward/core/git/gitTest.scala
index e114e7b1..094cbd2b 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/git/gitTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/git/gitTest.scala
@@ -10,7 +10,7 @@ import org.scalasteward.core.update.show
 class gitTest extends ScalaCheckSuite {
   test("commitMsgFor should work with static message") {
     val commitsConfig = CommitsConfig(Some("Static message"))
-    forAll { update: Update =>
+    forAll { update: Update.Single =>
       assertEquals(commitMsgFor(update, commitsConfig, None).title, "Static message")
     }
   }
@@ -18,7 +18,7 @@ class gitTest extends ScalaCheckSuite {
   test("commitMsgFor adds branch if provided") {
     val commitsConfig = CommitsConfig(Some(s"$${default}"))
     val branch = Branch("some-branch")
-    forAll { update: Update =>
+    forAll { update: Update.Single =>
       val expected = s"Update ${show.oneLiner(update)} to ${update.nextVersion} in ${branch.name}"
       assertEquals(commitMsgFor(update, commitsConfig, Some(branch)).title, expected)
     }
@@ -26,7 +26,7 @@ class gitTest extends ScalaCheckSuite {
 
   test("commitMsgFor should work with default message") {
     val commitsConfig = CommitsConfig(Some(s"$${default}"))
-    forAll { update: Update =>
+    forAll { update: Update.Single =>
       val expected = s"Update ${show.oneLiner(update)} to ${update.nextVersion}"
       assertEquals(commitMsgFor(update, commitsConfig, None).title, expected)
     }
@@ -35,7 +35,7 @@ class gitTest extends ScalaCheckSuite {
   test("commitMsgFor should work with templated message") {
     val commitsConfig =
       CommitsConfig(Some(s"Update $${artifactName} from $${currentVersion} to $${nextVersion}"))
-    forAll { update: Update =>
+    forAll { update: Update.Single =>
       val expected =
         s"Update ${show.oneLiner(update)} from ${update.currentVersion} to ${update.nextVersion}"
       assertEquals(commitMsgFor(update, commitsConfig, None).title, expected)
@@ -50,7 +50,7 @@ class gitTest extends ScalaCheckSuite {
         )
       )
     val branch = Branch("some-branch")
-    forAll { update: Update =>
+    forAll { update: Update.Single =>
       val expected =
         s"Update ${show.oneLiner(update)} from ${update.currentVersion} to ${update.nextVersion} in ${branch.name}"
       assertEquals(commitMsgFor(update, commitsConfig, Some(branch)).title, expected)
diff --git a/modules/core/src/test/scala/org/scalasteward/core/nurture/PullRequestRepositoryTest.scala b/modules/core/src/test/scala/org/scalasteward/core/nurture/PullRequestRepositoryTest.scala
index a9bd8731..c103f6bc 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/nurture/PullRequestRepositoryTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/nurture/PullRequestRepositoryTest.scala
@@ -5,7 +5,7 @@ import cats.effect.unsafe.implicits.global
 import munit.FunSuite
 import org.http4s.syntax.literals._
 import org.scalasteward.core.TestSyntax._
-import org.scalasteward.core.data.GroupedUpdate
+import org.scalasteward.core.data.Update
 import org.scalasteward.core.git.Sha1.HexString
 import org.scalasteward.core.git.{Branch, Sha1}
 import org.scalasteward.core.mock.MockConfig.config
@@ -142,7 +142,7 @@ class PullRequestRepositoryTest extends FunSuite {
   test("findLatestPullRequest ignores grouped updates") {
     val repo = Repo("pr-repo-test", "repo5")
     val update = portableScala
-    val grouped = GroupedUpdate("group", None, List(update))
+    val grouped = Update.Grouped("group", None, List(update))
     val data = PullRequestData[Id](url, sha1, grouped, Open, number, branch)
 
     val p = for {
@@ -168,7 +168,7 @@ class PullRequestRepositoryTest extends FunSuite {
   test("lastPullRequestCreatedAt returns timestamp for grouped updates") {
     val repo = Repo("pr-repo-test", "repo7")
     val update = catsCore
-    val grouped = GroupedUpdate("group", None, List(update))
+    val grouped = Update.Grouped("group", None, List(update))
     val data = PullRequestData[Id](url, sha1, grouped, Open, number, branch)
 
     val p = for {
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 fea9ed99..ea4d7653 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
@@ -5,7 +5,7 @@ import cats.syntax.all._
 import eu.timepit.refined.types.numeric.NonNegInt
 import munit.FunSuite
 import org.scalasteward.core.TestSyntax._
-import org.scalasteward.core.data.{GroupId, GroupedUpdate, SemVer}
+import org.scalasteward.core.data.{GroupId, SemVer, Update}
 import org.scalasteward.core.mock.MockContext.context.repoConfigAlg
 import org.scalasteward.core.mock.MockState.TraceEntry.Log
 import org.scalasteward.core.mock.{MockConfig, MockState}
@@ -285,7 +285,7 @@ class RepoConfigAlgTest extends FunSuite {
   test("configToIgnoreFurtherUpdates with grouped update") {
     val update1 = ("a".g % "b".a % "1" %> "2").single
     val update2 = ("c".g % "d".a % "1" %> "2").single
-    val update = GroupedUpdate("my-group", None, List(update1, update2))
+    val update = Update.Grouped("my-group", None, List(update1, update2))
     val config = RepoConfigAlg
       .parseRepoConfig(RepoConfigAlg.configToIgnoreFurtherUpdates(update))
       .getOrElse(RepoConfig())
diff --git a/modules/core/src/test/scala/org/scalasteward/core/util/loggerTest.scala b/modules/core/src/test/scala/org/scalasteward/core/util/loggerTest.scala
index cb14cf86..1f285b1c 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/util/loggerTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/util/loggerTest.scala
@@ -7,7 +7,7 @@ import org.scalasteward.core.mock.{MockEff, MockState}
 import org.scalasteward.core.util.logger.LoggerOps
 import org.scalasteward.core.util.logger.showUpdates
 import org.scalasteward.core.TestSyntax._
-import org.scalasteward.core.data.GroupedUpdate
+import org.scalasteward.core.data.Update
 
 class loggerTest extends CatsEffectSuite {
   test("attemptError.label_") {
@@ -36,8 +36,8 @@ class loggerTest extends CatsEffectSuite {
     val c = ("a".g % "c".a % "1.0.0" %> "2.0.0").single
 
     val list = List(
-      GroupedUpdate("all", None, List(a, b, c)),
-      GroupedUpdate("some", None, List(a, b)),
+      Update.Grouped("all", None, List(a, b, c)),
+      Update.Grouped("some", None, List(a, b)),
       a,
       b,
       c
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala
index 43763a20..9516003a 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala
@@ -5,7 +5,7 @@ import org.http4s.syntax.literals._
 import org.scalasteward.core.TestSyntax._
 import org.scalasteward.core.git
 import org.scalasteward.core.vcs.VCSType.{GitHub, GitLab}
-import org.scalasteward.core.data.GroupedUpdate
+import org.scalasteward.core.data.Update
 import org.scalasteward.core.vcs.data.Repo
 
 class VCSPackageTest extends FunSuite {
@@ -240,7 +240,7 @@ class VCSPackageTest extends FunSuite {
 
   {
 
-    val update = GroupedUpdate(
+    val update = Update.Grouped(
       name = "my-group",
       title = None,
       updates = List(("ch.qos.logback".g % "logback-classic".a % "1.2.0" %> "1.2.3").single)
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 e652f489..e2f30c58 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
@@ -7,7 +7,7 @@ import org.http4s.syntax.literals._
 import org.scalasteward.core.TestInstances._
 import org.scalasteward.core.TestSyntax._
 import org.scalasteward.core.buildtool.sbt.data.SbtVersion
-import org.scalasteward.core.data.{GroupedUpdate, ReleaseRelatedUrl, RepoData, UpdateData, Version}
+import org.scalasteward.core.data.{ReleaseRelatedUrl, RepoData, Update, UpdateData, Version}
 import org.scalasteward.core.edit.EditAttempt.{ScalafixEdit, UpdateEdit}
 import org.scalasteward.core.edit.scalafix.ScalafixMigration
 import org.scalasteward.core.git.{Branch, Commit}
@@ -323,7 +323,7 @@ class NewPullRequestDataTest extends FunSuite {
   test("label for grouped updates add labels for all update types & version changes") {
     val update1 = ("a".g % "b".a % "1" -> "2").single
     val update2 = ("c".g % "d".a % "1.1.0" % "test" %> "1.2.0").single
-    val update = GroupedUpdate("my-group", None, List(update1, update2))
+    val update = Update.Grouped("my-group", None, List(update1, update2))
 
     val labels = labelsFor(update, Nil, Nil, None)
 
@@ -342,7 +342,7 @@ class NewPullRequestDataTest extends FunSuite {
     val files = List("Readme.md", "travis.yml")
     val update1 = ("a".g % "b".a % "1" -> "2").single
     val update2 = ("c".g % "d".a % "1.1.0" % "test" %> "1.2.0").single
-    val update = GroupedUpdate("my-group", None, List(update1, update2))
+    val update = Update.Grouped("my-group", None, List(update1, update2))
 
     val note = oldVersionNote(files, update)
 
@@ -365,7 +365,7 @@ class NewPullRequestDataTest extends FunSuite {
   test("adjustFutureUpdates for grouped udpates shows settings for each update") {
     val update1 = ("a".g % "b".a % "1" -> "2").single
     val update2 = ("c".g % "d".a % "1.1.0" % "test" %> "1.2.0").single
-    val update = GroupedUpdate("my-group", None, List(update1, update2))
+    val update = Update.Grouped("my-group", None, List(update1, update2))
 
     val note = adjustFutureUpdates(update)
 
@@ -402,7 +402,7 @@ class NewPullRequestDataTest extends FunSuite {
   test("NewPullRequestData.from works for `GroupedUpdate`") {
     val update1 = ("ch.qos.logback".g % "logback-classic".a % "1.2.0" %> "1.2.3").single
     val update2 = ("com.example".g % "foo".a % "1.0.0" %> "2.0.0").single
-    val update = GroupedUpdate("my-group", "The PR title".some, List(update1, update2))
+    val update = Update.Grouped("my-group", "The PR title".some, List(update1, update2))
     val data = UpdateData(
       RepoData(Repo("foo", "bar"), dummyRepoCache, RepoConfig.empty),
       Repo("scala-steward", "bar"),