Repository: scala-steward-org/scala-steward
Pull request #1763 (2020-11-24 10:44:52)

Add FileGitAlg
Currently the main implementation of `GitAlg` is wrapper for the Git CLI. It uses the concrete [`Repo`](https://github.com/scala-steward-org/scala-steward/blob/866daee597d31fbd3af128bd364c181c96a71d80/modules/core/src/main/scala/org/scalasteward/core/vcs/data/Repo.scala) type to decide on which repository to operate on but the Git CLI requires a directory on the filesystem in which to execute its commands. So the first thing every method in the current implementation does is to get a directory for the `Repo` parameter by using `WorkspaceAlg.repoDir`. This is a sign of a mismatch between the parameter that is passed to the methods and what they actually require. A consequence of this mismatch is that testing `GitAlg` is more complicated because it needs to handle both `Repo`s and `File`s at the same time and requires manual conversion from `Repo` to `File`.

This PR tries to improve the situation by adding a type parameter to `GitAlg` for the `Repo` type and by renaming `GitAlg` to `GenGitAlg`. The Git CLI wrapper is now a new class (`FileGitAlg`) that is a `GenGitAlg[F, File]`. To get back the original `GitAlg`, we just call `contramapRepoF(workspaceAlg.repoDir)` on `FileGitAlg`. The tests are now only done with `FileGitAlg` and don't need to handle `Repo`s anymore.

codecov[bot]: # [Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763?src=pr&el=h1) Report
> Merging [#1763](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763?src=pr&el=desc) (7d40866) into [master](https://codecov.io/gh/scala-steward-org/scala-steward/commit/04b9d423d4830f1fd86d680ecef2d6372c8ec937?el=desc) (04b9d42) will **decrease** coverage by `0.66%`.
> The diff coverage is `70.58%`.

[![Impacted file tree graph](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763/graphs/tree.svg?width=650&height=150&src=pr&token=udzMsc1Dne)](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #1763      +/-   ##
==========================================
- Coverage   76.18%   75.51%   -0.67%     
==========================================
  Files         117      119       +2     
  Lines        1982     1985       +3     
  Branches       49       57       +8     
==========================================
- Hits         1510     1499      -11     
- Misses        472      486      +14     
```


| [Impacted Files](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [...la/org/scalasteward/core/application/Context.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763/diff?src=pr&el=tree#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9hcHBsaWNhdGlvbi9Db250ZXh0LnNjYWxh) | `0.00% <ø> (ø)` | |
| [.../main/scala/org/scalasteward/core/git/GitAlg.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763/diff?src=pr&el=tree#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9naXQvR2l0QWxnLnNjYWxh) | `27.77% <27.77%> (-61.46%)` | :arrow_down: |
| [...n/scala/org/scalasteward/core/git/FileGitAlg.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763/diff?src=pr&el=tree#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9naXQvRmlsZUdpdEFsZy5zY2FsYQ==) | `85.41% <85.41%> (ø)` | |
| [...in/scala/org/scalasteward/core/git/GenGitAlg.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763/diff?src=pr&el=tree#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9naXQvR2VuR2l0QWxnLnNjYWxh) | `100.00% <100.00%> (ø)` | |
| [.../scala/org/scalasteward/core/io/WorkspaceAlg.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763/diff?src=pr&el=tree#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9pby9Xb3Jrc3BhY2VBbGcuc2NhbGE=) | `25.00% <0.00%> (-12.50%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763?src=pr&el=footer). Last update [04b9d42...7d40866](https://codecov.io/gh/scala-steward-org/scala-steward/pull/1763?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/README.md b/README.md
index f1e2ebf1..8ba27ed8 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
 [![Scala Steward badge](https://img.shields.io/badge/Scala_Steward-helping-blue.svg?style=flat&logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAQCAMAAAARSr4IAAAAVFBMVEUAAACHjojlOy5NWlrKzcYRKjGFjIbp293YycuLa3pYY2LSqql4f3pCUFTgSjNodYRmcXUsPD/NTTbjRS+2jomhgnzNc223cGvZS0HaSD0XLjbaSjElhIr+AAAAAXRSTlMAQObYZgAAAHlJREFUCNdNyosOwyAIhWHAQS1Vt7a77/3fcxxdmv0xwmckutAR1nkm4ggbyEcg/wWmlGLDAA3oL50xi6fk5ffZ3E2E3QfZDCcCN2YtbEWZt+Drc6u6rlqv7Uk0LdKqqr5rk2UCRXOk0vmQKGfc94nOJyQjouF9H/wCc9gECEYfONoAAAAASUVORK5CYII=)](https://scala-steward.org)
 [![Docker Pulls](https://img.shields.io/docker/pulls/fthomas/scala-steward.svg?style=flat&color=blue)](https://hub.docker.com/r/fthomas/scala-steward/)
 
-Scala Steward is a bot that helps you keeping library dependencies
+Scala Steward is a bot that helps you keep library dependencies
 and sbt plugins up-to-date.
 
 See also the announcement blog post:
diff --git a/build.sbt b/build.sbt
index feab6713..a256ab6b 100644
--- a/build.sbt
+++ b/build.sbt
@@ -178,7 +178,8 @@ lazy val `sbt-plugin` = myCrossProject("sbt-plugin")
 lazy val `mill-plugin` = myCrossProject("mill-plugin")
   .settings(
     crossScalaVersions := Seq(Scala213, Scala212),
-    libraryDependencies += Dependencies.millScalalib.value % Provided
+    libraryDependencies += Dependencies.millScalalib.value % Provided,
+    scalacOptions -= "-Xfatal-warnings"
   )
 
 /// settings
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
index 807984ed..c3e82a33 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Context.scala
@@ -30,7 +30,7 @@ import org.scalasteward.core.buildtool.sbt.SbtAlg
 import org.scalasteward.core.coursier.{CoursierAlg, VersionsCache}
 import org.scalasteward.core.edit.EditAlg
 import org.scalasteward.core.edit.hooks.HookExecutor
-import org.scalasteward.core.git.GitAlg
+import org.scalasteward.core.git.{GenGitAlg, GitAlg}
 import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.nurture.{NurtureAlg, PullRequestRepository}
 import org.scalasteward.core.persistence.JsonKeyValueStore
@@ -69,7 +69,7 @@ object Context {
       implicit val workspaceAlg: WorkspaceAlg[F] = WorkspaceAlg.create[F](config)
       implicit val repoConfigAlg: RepoConfigAlg[F] = new RepoConfigAlg[F](config)
       implicit val filterAlg: FilterAlg[F] = new FilterAlg[F]
-      implicit val gitAlg: GitAlg[F] = GitAlg.create[F](config)
+      implicit val gitAlg: GitAlg[F] = GenGitAlg.create[F](config)
       implicit val hookExecutor: HookExecutor[F] = new HookExecutor[F]
       implicit val httpJsonClient: HttpJsonClient[F] = new HttpJsonClient[F]
       implicit val repoCacheRepository: RepoCacheRepository[F] =
diff --git a/modules/core/src/main/scala/org/scalasteward/core/buildtool/sbt/package.scala b/modules/core/src/main/scala/org/scalasteward/core/buildtool/sbt/package.scala
index e80001f8..3997cb0b 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/buildtool/sbt/package.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/buildtool/sbt/package.scala
@@ -35,7 +35,7 @@ package object sbt {
   val scalaStewardScalafixSbt: FileData =
     FileData(
       "scala-steward-scalafix.sbt",
-      """addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.23")"""
+      """addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.24")"""
     )
 
   def scalaStewardScalafixOptions(scalacOptions: List[String]): FileData = {
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala
new file mode 100644
index 00000000..6f23f7cf
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala
@@ -0,0 +1,131 @@
+/*
+ * Copyright 2018-2020 Scala Steward contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.scalasteward.core.git
+
+import better.files.File
+import cats.syntax.all._
+import org.http4s.Uri
+import org.scalasteward.core.application.Config
+import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
+import org.scalasteward.core.util.{BracketThrow, Nel}
+
+final class FileGitAlg[F[_]](config: Config)(implicit
+    fileAlg: FileAlg[F],
+    processAlg: ProcessAlg[F],
+    workspaceAlg: WorkspaceAlg[F],
+    F: BracketThrow[F]
+) extends GenGitAlg[F, File] {
+  override def branchAuthors(repo: File, branch: Branch, base: Branch): F[List[String]] =
+    git("log", "--pretty=format:'%an'", dotdot(base, branch))(repo)
+
+  override def checkoutBranch(repo: File, branch: Branch): F[Unit] =
+    git("checkout", branch.name)(repo).void
+
+  override def clone(repo: File, url: Uri): F[Unit] =
+    workspaceAlg.rootDir.flatMap { rootDir =>
+      git("clone", "--recursive", url.toString, repo.pathAsString)(rootDir).void
+    }
+
+  override def cloneExists(repo: File): F[Boolean] =
+    fileAlg.isDirectory(repo / ".git")
+
+  override def commitAll(repo: File, message: String): F[Commit] =
+    git("commit", "--all", sign, "-m", message)(repo).as(Commit())
+
+  override def containsChanges(repo: File): F[Boolean] =
+    git("status", "--porcelain", "--untracked-files=no", "--ignore-submodules")(repo)
+      .map(_.nonEmpty)
+
+  override def createBranch(repo: File, branch: Branch): F[Unit] =
+    git("checkout", "-b", branch.name)(repo).void
+
+  override def currentBranch(repo: File): F[Branch] =
+    git("rev-parse", "--abbrev-ref", Branch.head.name)(repo)
+      .map(lines => Branch(lines.mkString.trim))
+
+  override def findFilesContaining(repo: File, string: String): F[List[String]] =
+    git("grep", "-I", "--fixed-strings", "--files-with-matches", string)(repo)
+      .handleError(_ => List.empty[String])
+      .map(_.filter(_.nonEmpty))
+
+  override def hasConflicts(repo: File, branch: Branch, base: Branch): F[Boolean] = {
+    val tryMerge = git("merge", "--no-commit", "--no-ff", branch.name)(repo)
+    val abortMerge = git("merge", "--abort")(repo).void
+
+    returnToCurrentBranch(repo) {
+      checkoutBranch(repo, base) >> F.guarantee(tryMerge)(abortMerge).attempt.map(_.isLeft)
+    }
+  }
+
+  override def isMerged(repo: File, branch: Branch, base: Branch): F[Boolean] =
+    git("log", "--pretty=format:'%h'", dotdot(base, branch))(repo).map(_.isEmpty)
+
+  override def latestSha1(repo: File, branch: Branch): F[Sha1] =
+    git("rev-parse", "--verify", branch.name)(repo)
+      .flatMap(lines => F.fromEither(Sha1.from(lines.mkString("").trim)))
+
+  override def mergeTheirs(repo: File, branch: Branch): F[Option[Commit]] =
+    for {
+      before <- latestSha1(repo, Branch.head)
+      _ <- git("merge", "--strategy-option=theirs", sign, branch.name)(repo).void
+        .handleErrorWith { throwable =>
+          // Resolve CONFLICT (modify/delete) by deleting unmerged files:
+          for {
+            unmergedFiles <- git("diff", "--name-only", "--diff-filter=U")(repo)
+            _ <- Nel
+              .fromList(unmergedFiles.filter(_.nonEmpty))
+              .fold(F.raiseError[Unit](throwable))(_.traverse_(file => git("rm", file)(repo)))
+            _ <- git("commit", "--all", "--no-edit", sign)(repo)
+          } yield ()
+        }
+      after <- latestSha1(repo, Branch.head)
+    } yield Option.when(before =!= after)(Commit())
+
+  override def push(repo: File, branch: Branch): F[Unit] =
+    git("push", "--force", "--set-upstream", "origin", branch.name)(repo).void
+
+  override def removeClone(repo: File): F[Unit] =
+    fileAlg.deleteForce(repo)
+
+  override def setAuthor(repo: File, author: Author): F[Unit] =
+    for {
+      _ <- git("config", "user.email", author.email)(repo)
+      _ <- git("config", "user.name", author.name)(repo)
+    } yield ()
+
+  override def syncFork(repo: File, upstreamUrl: Uri, defaultBranch: Branch): F[Unit] =
+    for {
+      _ <- F.unit
+      remote = "upstream"
+      branch = defaultBranch.name
+      remoteBranch = s"$remote/$branch"
+      _ <- git("remote", "add", remote, upstreamUrl.toString)(repo)
+      _ <- git("fetch", "--tags", remote, branch)(repo)
+      _ <- git("checkout", "-B", branch, "--track", remoteBranch)(repo)
+      _ <- git("merge", remoteBranch)(repo)
+      _ <- push(repo, defaultBranch)
+    } yield ()
+
+  override def version: F[String] =
+    workspaceAlg.rootDir.flatMap(git("--version")).map(_.mkString.trim)
+
+  private def git(args: String*)(repo: File): F[List[String]] =
+    processAlg.exec(Nel.of("git", args: _*), repo, "GIT_ASKPASS" -> config.gitAskPass.pathAsString)
+
+  private val sign: String =
+    if (config.signCommits) "--gpg-sign" else "--no-gpg-sign"
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala
new file mode 100644
index 00000000..50cb3e97
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala
@@ -0,0 +1,140 @@
+/*
+ * Copyright 2018-2020 Scala Steward contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.scalasteward.core.git
+
+import cats.effect.Bracket
+import cats.syntax.all._
+import cats.{FlatMap, Monad}
+import org.http4s.Uri
+import org.scalasteward.core.application.Config
+import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
+import org.scalasteward.core.util.BracketThrow
+
+trait GenGitAlg[F[_], Repo] {
+  def branchAuthors(repo: Repo, branch: Branch, base: Branch): F[List[String]]
+
+  def checkoutBranch(repo: Repo, branch: Branch): F[Unit]
+
+  def clone(repo: Repo, url: Uri): F[Unit]
+
+  def cloneExists(repo: Repo): F[Boolean]
+
+  def commitAll(repo: Repo, message: String): F[Commit]
+
+  def containsChanges(repo: Repo): F[Boolean]
+
+  def createBranch(repo: Repo, branch: Branch): F[Unit]
+
+  def currentBranch(repo: Repo): F[Branch]
+
+  def findFilesContaining(repo: Repo, string: String): F[List[String]]
+
+  /** Returns `true` if merging `branch` into `base` results in merge conflicts. */
+  def hasConflicts(repo: Repo, branch: Branch, base: Branch): F[Boolean]
+
+  def isMerged(repo: Repo, branch: Branch, base: Branch): F[Boolean]
+
+  def latestSha1(repo: Repo, branch: Branch): F[Sha1]
+
+  /** Merges `branch` into the current branch using `theirs` as merge strategy option. */
+  def mergeTheirs(repo: Repo, branch: Branch): F[Option[Commit]]
+
+  def push(repo: Repo, branch: Branch): F[Unit]
+
+  def removeClone(repo: Repo): F[Unit]
+
+  def setAuthor(repo: Repo, author: Author): F[Unit]
+
+  def syncFork(repo: Repo, upstreamUrl: Uri, defaultBranch: Branch): F[Unit]
+
+  def version: F[String]
+
+  final def commitAllIfDirty(repo: Repo, message: String)(implicit F: Monad[F]): F[Option[Commit]] =
+    containsChanges(repo).ifM(commitAll(repo, message).map(Some.apply), F.pure(None))
+
+  final def returnToCurrentBranch[A, E](repo: Repo)(fa: F[A])(implicit F: Bracket[F, E]): F[A] =
+    F.bracket(currentBranch(repo))(_ => fa)(checkoutBranch(repo, _))
+
+  final def contramapRepoF[A](f: A => F[Repo])(implicit F: FlatMap[F]): GenGitAlg[F, A] = {
+    val self = this
+    new GenGitAlg[F, A] {
+      override def branchAuthors(repo: A, branch: Branch, base: Branch): F[List[String]] =
+        f(repo).flatMap(self.branchAuthors(_, branch, base))
+
+      override def checkoutBranch(repo: A, branch: Branch): F[Unit] =
+        f(repo).flatMap(self.checkoutBranch(_, branch))
+
+      override def clone(repo: A, url: Uri): F[Unit] =
+        f(repo).flatMap(self.clone(_, url))
+
+      override def cloneExists(repo: A): F[Boolean] =
+        f(repo).flatMap(self.cloneExists)
+
+      override def commitAll(repo: A, message: String): F[Commit] =
+        f(repo).flatMap(self.commitAll(_, message))
+
+      override def containsChanges(repo: A): F[Boolean] =
+        f(repo).flatMap(self.containsChanges)
+
+      override def createBranch(repo: A, branch: Branch): F[Unit] =
+        f(repo).flatMap(self.createBranch(_, branch))
+
+      override def currentBranch(repo: A): F[Branch] =
+        f(repo).flatMap(self.currentBranch)
+
+      override def findFilesContaining(repo: A, string: String): F[List[String]] =
+        f(repo).flatMap(self.findFilesContaining(_, string))
+
+      override def hasConflicts(repo: A, branch: Branch, base: Branch): F[Boolean] =
+        f(repo).flatMap(self.hasConflicts(_, branch, base))
+
+      override def isMerged(repo: A, branch: Branch, base: Branch): F[Boolean] =
+        f(repo).flatMap(self.isMerged(_, branch, base))
+
+      override def latestSha1(repo: A, branch: Branch): F[Sha1] =
+        f(repo).flatMap(self.latestSha1(_, branch))
+
+      override def mergeTheirs(repo: A, branch: Branch): F[Option[Commit]] =
+        f(repo).flatMap(self.mergeTheirs(_, branch))
+
+      override def push(repo: A, branch: Branch): F[Unit] =
+        f(repo).flatMap(self.push(_, branch))
+
+      override def removeClone(repo: A): F[Unit] =
+        f(repo).flatMap(self.removeClone)
+
+      override def setAuthor(repo: A, author: Author): F[Unit] =
+        f(repo).flatMap(self.setAuthor(_, author))
+
+      override def syncFork(repo: A, upstreamUrl: Uri, defaultBranch: Branch): F[Unit] =
+        f(repo).flatMap(self.syncFork(_, upstreamUrl, defaultBranch))
+
+      override def version: F[String] =
+        self.version
+    }
+  }
+}
+
+object GenGitAlg {
+  def create[F[_]](config: Config)(implicit
+      fileAlg: FileAlg[F],
+      processAlg: ProcessAlg[F],
+      workspaceAlg: WorkspaceAlg[F],
+      F: BracketThrow[F]
+  ): GitAlg[F] =
+    new FileGitAlg[F](config).contramapRepoF(workspaceAlg.repoDir)
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/GitAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/git/GitAlg.scala
deleted file mode 100644
index b39721d8..00000000
--- a/modules/core/src/main/scala/org/scalasteward/core/git/GitAlg.scala
+++ /dev/null
@@ -1,219 +0,0 @@
-/*
- * Copyright 2018-2020 Scala Steward contributors
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.scalasteward.core.git
-
-import better.files.File
-import cats.Monad
-import cats.effect.Bracket
-import cats.syntax.all._
-import org.http4s.Uri
-import org.scalasteward.core.application.Config
-import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
-import org.scalasteward.core.util.{BracketThrow, Nel}
-import org.scalasteward.core.vcs.data.Repo
-
-trait GitAlg[F[_]] {
-  def branchAuthors(repo: Repo, branch: Branch, base: Branch): F[List[String]]
-
-  def checkoutBranch(repo: Repo, branch: Branch): F[Unit]
-
-  def clone(repo: Repo, url: Uri): F[Unit]
-
-  def cloneExists(repo: Repo): F[Boolean]
-
-  def commitAll(repo: Repo, message: String): F[Commit]
-
-  def containsChanges(repo: Repo): F[Boolean]
-
-  def createBranch(repo: Repo, branch: Branch): F[Unit]
-
-  def currentBranch(repo: Repo): F[Branch]
-
-  def findFilesContaining(repo: Repo, string: String): F[List[String]]
-
-  /** Returns `true` if merging `branch` into `base` results in merge conflicts. */
-  def hasConflicts(repo: Repo, branch: Branch, base: Branch): F[Boolean]
-
-  def isMerged(repo: Repo, branch: Branch, base: Branch): F[Boolean]
-
-  def latestSha1(repo: Repo, branch: Branch): F[Sha1]
-
-  /** Merges `branch` into the current branch using `theirs` as merge strategy option. */
-  def mergeTheirs(repo: Repo, branch: Branch): F[Option[Commit]]
-
-  def push(repo: Repo, branch: Branch): F[Unit]
-
-  def removeClone(repo: Repo): F[Unit]
-
-  def setAuthor(repo: Repo, author: Author): F[Unit]
-
-  def syncFork(repo: Repo, upstreamUrl: Uri, defaultBranch: Branch): F[Unit]
-
-  def version: F[String]
-
-  final def commitAllIfDirty(repo: Repo, message: String)(implicit F: Monad[F]): F[Option[Commit]] =
-    containsChanges(repo).ifM(commitAll(repo, message).map(Some.apply), F.pure(None))
-
-  final def returnToCurrentBranch[A, E](repo: Repo)(fa: F[A])(implicit F: Bracket[F, E]): F[A] =
-    F.bracket(currentBranch(repo))(_ => fa)(checkoutBranch(repo, _))
-}
-
-object GitAlg {
-  def create[F[_]](config: Config)(implicit
-      fileAlg: FileAlg[F],
-      processAlg: ProcessAlg[F],
-      workspaceAlg: WorkspaceAlg[F],
-      F: BracketThrow[F]
-  ): GitAlg[F] =
-    new GitAlg[F] {
-      override def branchAuthors(repo: Repo, branch: Branch, base: Branch): F[List[String]] =
-        workspaceAlg.repoDir(repo).flatMap { repoDir =>
-          git("log", "--pretty=format:'%an'", dotdot(base, branch))(repoDir)
-        }
-
-      override def checkoutBranch(repo: Repo, branch: Branch): F[Unit] =
-        for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          _ <- git("checkout", branch.name)(repoDir)
-        } yield ()
-
-      override def clone(repo: Repo, url: Uri): F[Unit] =
-        for {
-          rootDir <- workspaceAlg.rootDir
-          repoDir <- workspaceAlg.repoDir(repo)
-          _ <- git("clone", "--recursive", url.toString, repoDir.pathAsString)(rootDir)
-        } yield ()
-
-      override def cloneExists(repo: Repo): F[Boolean] =
-        for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          dotGitExists <- fileAlg.isDirectory(repoDir / ".git")
-        } yield dotGitExists
-
-      override def commitAll(repo: Repo, message: String): F[Commit] =
-        for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          _ <- git("commit", "--all", sign, "-m", message)(repoDir)
-        } yield Commit()
-
-      override def containsChanges(repo: Repo): F[Boolean] =
-        workspaceAlg.repoDir(repo).flatMap { repoDir =>
-          val args = List("status", "--porcelain", "--untracked-files=no", "--ignore-submodules")
-          git(args: _*)(repoDir).map(_.nonEmpty)
-        }
-
-      override def createBranch(repo: Repo, branch: Branch): F[Unit] =
-        for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          _ <- git("checkout", "-b", branch.name)(repoDir)
-        } yield ()
-
-      override def currentBranch(repo: Repo): F[Branch] =
-        for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          lines <- git("rev-parse", "--abbrev-ref", Branch.head.name)(repoDir)
-        } yield Branch(lines.mkString.trim)
-
-      override def findFilesContaining(repo: Repo, string: String): F[List[String]] =
-        for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          args = List("grep", "-I", "--fixed-strings", "--files-with-matches", string)
-          lines <- git(args: _*)(repoDir).handleError(_ => List.empty)
-        } yield lines.filter(_.nonEmpty)
-
-      override def hasConflicts(repo: Repo, branch: Branch, base: Branch): F[Boolean] =
-        workspaceAlg.repoDir(repo).flatMap { repoDir =>
-          val tryMerge = git("merge", "--no-commit", "--no-ff", branch.name)(repoDir)
-          val abortMerge = git("merge", "--abort")(repoDir).void
-
-          returnToCurrentBranch(repo) {
-            checkoutBranch(repo, base) >> F.guarantee(tryMerge)(abortMerge).attempt.map(_.isLeft)
-          }
-        }
-
-      override def isMerged(repo: Repo, branch: Branch, base: Branch): F[Boolean] =
-        workspaceAlg.repoDir(repo).flatMap { repoDir =>
-          git("log", "--pretty=format:'%h'", dotdot(base, branch))(repoDir).map(_.isEmpty)
-        }
-
-      override def latestSha1(repo: Repo, branch: Branch): F[Sha1] =
-        for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          lines <- git("rev-parse", "--verify", branch.name)(repoDir)
-          sha1 <- F.fromEither(Sha1.from(lines.mkString("").trim))
-        } yield sha1
-
-      override def mergeTheirs(repo: Repo, branch: Branch): F[Option[Commit]] =
-        for {
-          before <- latestSha1(repo, Branch.head)
-          repoDir <- workspaceAlg.repoDir(repo)
-          _ <- git("merge", "--strategy-option=theirs", sign, branch.name)(repoDir).void
-            .handleErrorWith { throwable =>
-              // Resolve CONFLICT (modify/delete) by deleting unmerged files:
-              for {
-                unmergedFiles <- git("diff", "--name-only", "--diff-filter=U")(repoDir)
-                _ <- Nel.fromList(unmergedFiles.filter(_.nonEmpty)) match {
-                  case Some(files) => files.traverse(file => git("rm", file)(repoDir))
-                  case None        => F.raiseError(throwable)
-                }
-                _ <- git("commit", "--all", "--no-edit", sign)(repoDir)
-              } yield ()
-            }
-          after <- latestSha1(repo, Branch.head)
-        } yield Option.when(before =!= after)(Commit())
-
-      override def push(repo: Repo, branch: Branch): F[Unit] =
-        for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          _ <- git("push", "--force", "--set-upstream", "origin", branch.name)(repoDir)
-        } yield ()
-
-      override def removeClone(repo: Repo): F[Unit] =
-        workspaceAlg.repoDir(repo).flatMap(fileAlg.deleteForce)
-
-      override def setAuthor(repo: Repo, author: Author): F[Unit] =
-        for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          _ <- git("config", "user.email", author.email)(repoDir)
-          _ <- git("config", "user.name", author.name)(repoDir)
-        } yield ()
-
-      override def syncFork(repo: Repo, upstreamUrl: Uri, defaultBranch: Branch): F[Unit] =
-        for {
-          repoDir <- workspaceAlg.repoDir(repo)
-          remote = "upstream"
-          branch = defaultBranch.name
-          remoteBranch = s"$remote/$branch"
-          _ <- git("remote", "add", remote, upstreamUrl.toString)(repoDir)
-          _ <- git("fetch", "--tags", remote, branch)(repoDir)
-          _ <- git("checkout", "-B", branch, "--track", remoteBranch)(repoDir)
-          _ <- git("merge", remoteBranch)(repoDir)
-          _ <- push(repo, defaultBranch)
-        } yield ()
-
-      override def version: F[String] =
-        workspaceAlg.rootDir.flatMap(git("--version")).map(_.mkString.trim)
-
-      private def git(args: String*)(cwd: File): F[List[String]] = {
-        val extraEnv = "GIT_ASKPASS" -> config.gitAskPass.pathAsString
-        processAlg.exec(Nel.of("git", args: _*), cwd, extraEnv)
-      }
-
-      private val sign: String =
-        if (config.signCommits) "--gpg-sign" else "--no-gpg-sign"
-    }
-}
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 33e35618..bc204a64 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
@@ -19,8 +19,11 @@ package org.scalasteward.core
 import org.scalasteward.core.data.Update
 import org.scalasteward.core.repoconfig.CommitsConfig
 import org.scalasteward.core.update.show
+import org.scalasteward.core.vcs.data.Repo
 
 package object git {
+  type GitAlg[F[_]] = GenGitAlg[F, Repo]
+
   def branchFor(update: Update): Branch =
     Branch(s"update/${update.name}-${update.nextVersion}")
 
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 a668dc25..65e91e34 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
@@ -132,6 +132,11 @@ object FilterAlg {
           // https://github.com/scala-steward-org/scala-steward/issues/1413
           "36845576"
         ).contains
+      case ("commons-codec", "commons-codec") =>
+        List(
+          // https://github.com/scala-steward-org/scala-steward/issues/1753
+          "20041127.091804"
+        ).contains
       case ("commons-collections", "commons-collections") =>
         List(
           "20030418.083655",
@@ -141,6 +146,11 @@ object FilterAlg {
           "20040102.233541",
           "20040616"
         ).contains
+      case ("commons-io", "commons-io") =>
+        List(
+          // https://github.com/scala-steward-org/scala-steward/issues/1753
+          "20030203.000550"
+        ).contains
       case ("io.monix", _) =>
         List(
           // https://github.com/scala-steward-org/scala-steward/issues/105
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index 63e52d76..587c7191 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -7,8 +7,8 @@ object Dependencies {
   val betterFiles = "com.github.pathikrit" %% "better-files" % "3.9.1"
   val betterMonadicFor = "com.olegpy" %% "better-monadic-for" % "0.3.1"
   val caseApp = "com.github.alexarchambault" %% "case-app" % "2.0.4"
-  val catsEffect = "org.typelevel" %% "cats-effect" % "2.2.0"
-  val catsCore = "org.typelevel" %% "cats-core" % "2.2.0"
+  val catsEffect = "org.typelevel" %% "cats-effect" % "2.3.0"
+  val catsCore = "org.typelevel" %% "cats-core" % "2.3.0"
   val catsLaws = "org.typelevel" %% "cats-laws" % catsCore.revision
   val circeConfig = "io.circe" %% "circe-config" % "0.8.0"
   val circeGeneric = "io.circe" %% "circe-generic" % "0.13.0"
@@ -22,9 +22,9 @@ object Dependencies {
   val cron4sCore = "com.github.alonsodomin.cron4s" %% "cron4s-core" % "0.6.1"
   val disciplineScalatest = ("org.typelevel" %% "discipline-scalatest" % "2.1.0")
     .excludeAll(ExclusionRule().withOrganization("org.scalatest"))
-  val fs2Core = "co.fs2" %% "fs2-core" % "2.4.5"
+  val fs2Core = "co.fs2" %% "fs2-core" % "2.4.6"
   val fs2Io = "co.fs2" %% "fs2-io" % fs2Core.revision
-  val http4sCore = "org.http4s" %% "http4s-core" % "0.21.11"
+  val http4sCore = "org.http4s" %% "http4s-core" % "0.21.13"
   val http4sCirce = "org.http4s" %% "http4s-circe" % http4sCore.revision
   val http4sClient = "org.http4s" %% "http4s-client" % http4sCore.revision
   val http4sDsl = "org.http4s" %% "http4s-dsl" % http4sCore.revision
@@ -32,7 +32,7 @@ object Dependencies {
   val kindProjector = "org.typelevel" % "kind-projector" % "0.11.1"
   val log4catsSlf4j = "io.chrisdavenport" %% "log4cats-slf4j" % "1.1.1"
   val logbackClassic = "ch.qos.logback" % "logback-classic" % "1.2.3"
-  val millVersion = Def.setting(if (scalaBinaryVersion.value == "2.12") "0.6.3" else "0.8.0")
+  val millVersion = Def.setting(if (scalaBinaryVersion.value == "2.12") "0.6.3" else "0.9.3")
   val millScalalib = Def.setting("com.lihaoyi" %% "mill-scalalib" % millVersion.value)
   val monocleCore = "com.github.julien-truffaut" %% "monocle-core" % "2.1.0"
   val refined = "eu.timepit" %% "refined" % "0.9.18"
diff --git a/project/plugins.sbt b/project/plugins.sbt
index efcaca21..b5b8790a 100644
--- a/project/plugins.sbt
+++ b/project/plugins.sbt
@@ -13,4 +13,4 @@ addSbtPlugin("org.scoverage" % "sbt-scoverage" % "1.6.1")
 addSbtPlugin("org.wartremover" % "sbt-wartremover" % "2.4.13")
 
 // This is only here so that Scala Steward updates the version in sbt/package.scala too.
-addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.23")
+addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.24")


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala
index 5f9f7292..bfd4a502 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/hooks/HookExecutorTest.scala
@@ -2,7 +2,7 @@ package org.scalasteward.core.edit.hooks
 
 import org.scalasteward.core.TestSyntax._
 import org.scalasteward.core.data.Update
-import org.scalasteward.core.mock.MockContext.{hookExecutor, workspaceAlg}
+import org.scalasteward.core.mock.MockContext.{config, hookExecutor, workspaceAlg}
 import org.scalasteward.core.mock.MockState
 import org.scalasteward.core.repoconfig.{RepoConfig, ScalafmtConfig}
 import org.scalasteward.core.scalafmt.{scalafmtArtifactId, scalafmtGroupId}
@@ -14,7 +14,7 @@ import org.scalatest.matchers.should.Matchers
 class HookExecutorTest extends AnyFunSuite with Matchers {
   private val repo = Repo("scala-steward-org", "scala-steward")
   private val repoDir = workspaceAlg.repoDir(repo).runA(MockState.empty).unsafeRunSync()
-  private val envVars = List("GIT_ASKPASS=/tmp/askpass.sh", "VAR1=val1", "VAR2=val2")
+  private val envVars = List(s"GIT_ASKPASS=${config.gitAskPass}", "VAR1=val1", "VAR2=val2")
 
   test("no hook") {
     val update = Update.Single("org.typelevel" % "cats-core" % "1.2.0", Nel.of("1.3.0"))
diff --git a/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala
new file mode 100644
index 00000000..41ad8ae9
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala
@@ -0,0 +1,197 @@
+package org.scalasteward.core.git
+
+import better.files.File
+import cats.Monad
+import cats.effect.IO
+import cats.syntax.all._
+import org.scalasteward.core.TestInstances.ioLogger
+import org.scalasteward.core.git.FileGitAlgTest.{master, Supplement}
+import org.scalasteward.core.io.FileAlgTest.ioFileAlg
+import org.scalasteward.core.io.ProcessAlgTest.ioProcessAlg
+import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
+import org.scalasteward.core.mock.MockContext.config
+import org.scalasteward.core.util.Nel
+import org.scalatest.funsuite.AnyFunSuite
+import org.scalatest.matchers.should.Matchers
+
+class FileGitAlgTest extends AnyFunSuite with Matchers {
+  implicit private val ioWorkspaceAlg: WorkspaceAlg[IO] = WorkspaceAlg.create[IO](config)
+  implicit private val ioGitAlg: GenGitAlg[IO, File] =
+    new FileGitAlg[IO](config).contramapRepoF(IO.pure)
+  private val supplement = new Supplement[IO]
+  private val rootDir = File.temp / "scala-steward" / "git-tests"
+
+  test("branchAuthors") {
+    val repo = rootDir / "branchAuthors"
+    val p = for {
+      _ <- supplement.createRepo(repo)
+      _ <- supplement.createConflict(repo)
+      authors <- ioGitAlg.branchAuthors(repo, Branch("conflicts-no"), master)
+    } yield authors
+    p.unsafeRunSync() shouldBe List("'Bot Doe'")
+  }
+
+  test("cloneExists") {
+    val repo = rootDir / "cloneExists"
+    val p = for {
+      e1 <- ioGitAlg.cloneExists(repo)
+      _ <- supplement.createRepo(repo)
+      e2 <- ioGitAlg.cloneExists(repo)
+      _ <- ioGitAlg.removeClone(repo)
+      e3 <- ioGitAlg.cloneExists(repo)
+    } yield (e1, e2, e3)
+    p.unsafeRunSync() shouldBe ((false, true, false))
+  }
+
+  test("containsChanges") {
+    val repo = rootDir / "containsChanges"
+    val p = for {
+      _ <- supplement.createRepo(repo)
+      _ <- ioFileAlg.writeFile(repo / "test.txt", "hello")
+      _ <- supplement.git("add", "test.txt")(repo)
+      _ <- ioGitAlg.commitAll(repo, "Add test.txt")
+      c1 <- ioGitAlg.containsChanges(repo)
+      _ <- ioFileAlg.writeFile(repo / "test.txt", "hello world")
+      c2 <- ioGitAlg.containsChanges(repo)
+      _ <- ioGitAlg.commitAllIfDirty(repo, "Modify test.txt")
+      c3 <- ioGitAlg.containsChanges(repo)
+    } yield (c1, c2, c3)
+    p.unsafeRunSync() shouldBe ((false, true, false))
+  }
+
+  test("currentBranch") {
+    val repo = rootDir / "currentBranch"
+    val p = for {
+      _ <- supplement.createRepo(repo)
+      _ <- supplement.git("commit", "--allow-empty", "-m", "Initial commit")(repo)
+      branch <- ioGitAlg.currentBranch(repo)
+      _ <- ioGitAlg.latestSha1(repo, branch)
+    } yield branch
+    p.unsafeRunSync() shouldBe master
+  }
+
+  test("findFilesContaining") {
+    val repo = rootDir / "findFilesContaining"
+    val p = for {
+      _ <- supplement.createRepo(repo)
+      _ <- supplement.createConflict(repo)
+      files <- ioGitAlg.findFilesContaining(repo, "line1")
+    } yield files
+    p.unsafeRunSync() shouldBe List("file1", "file2")
+  }
+
+  test("hasConflicts") {
+    val repo = rootDir / "hasConflicts"
+    val p = for {
+      _ <- supplement.createRepo(repo)
+      _ <- supplement.createConflict(repo)
+      c1 <- ioGitAlg.hasConflicts(repo, Branch("conflicts-yes"), master)
+      c2 <- ioGitAlg.hasConflicts(repo, Branch("conflicts-no"), master)
+    } yield (c1, c2)
+    p.unsafeRunSync() shouldBe ((true, false))
+  }
+
+  test("mergeTheirs") {
+    val repo = rootDir / "mergeTheirs"
+    val p = for {
+      _ <- supplement.createRepo(repo)
+      _ <- supplement.createConflict(repo)
+      branch = Branch("conflicts-yes")
+      c1 <- ioGitAlg.hasConflicts(repo, branch, master)
+      m1 <- ioGitAlg.isMerged(repo, master, branch)
+      _ <- ioGitAlg.checkoutBranch(repo, branch)
+      _ <- ioGitAlg.mergeTheirs(repo, master)
+      c2 <- ioGitAlg.hasConflicts(repo, branch, master)
+      m2 <- ioGitAlg.isMerged(repo, master, branch)
+    } yield (c1, m1, c2, m2)
+    p.unsafeRunSync() shouldBe ((true, false, false, true))
+  }
+
+  test("mergeTheirs: CONFLICT (modify/delete)") {
+    val repo = rootDir / "mergeTheirs-modify-delete"
+    val p = for {
+      _ <- supplement.createRepo(repo)
+      _ <- supplement.createConflictFileRemovedOnMaster(repo)
+      branch = Branch("conflicts-yes")
+      c1 <- ioGitAlg.hasConflicts(repo, branch, master)
+      m1 <- ioGitAlg.isMerged(repo, master, branch)
+      _ <- ioGitAlg.checkoutBranch(repo, branch)
+      _ <- ioGitAlg.mergeTheirs(repo, master)
+      c2 <- ioGitAlg.hasConflicts(repo, branch, master)
+      m2 <- ioGitAlg.isMerged(repo, master, branch)
+    } yield (c1, m1, c2, m2)
+    p.unsafeRunSync() shouldBe ((true, false, false, true))
+  }
+
+  test("version") {
+    ioGitAlg.version.unsafeRunSync().nonEmpty shouldBe true
+  }
+}
+
+object FileGitAlgTest {
+  val master: Branch = Branch("master")
+
+  final class Supplement[F[_]](implicit
+      fileAlg: FileAlg[F],
+      gitAlg: GenGitAlg[F, File],
+      processAlg: ProcessAlg[F],
+      F: Monad[F]
+  ) {
+    def git(args: String*)(repo: File): F[Unit] =
+      processAlg.exec(Nel.of("git", args: _*), repo).void
+
+    def createRepo(repo: File): F[Unit] =
+      for {
+        _ <- gitAlg.removeClone(repo)
+        _ <- fileAlg.ensureExists(repo)
+        _ <- git("init", ".")(repo)
+        _ <- gitAlg.setAuthor(repo, config.gitAuthor)
+      } yield ()
+
+    def createConflict(repo: File): F[Unit] =
+      for {
+        // work on master
+        _ <- fileAlg.writeFile(repo / "file1", "file1, line1")
+        _ <- fileAlg.writeFile(repo / "file2", "file2, line1")
+        _ <- git("add", "file1")(repo)
+        _ <- git("add", "file2")(repo)
+        _ <- gitAlg.commitAll(repo, "Initial commit")
+        // work on conflicts-no
+        _ <- gitAlg.createBranch(repo, Branch("conflicts-no"))
+        _ <- fileAlg.writeFile(repo / "file3", "file3, line1")
+        _ <- git("add", "file3")(repo)
+        _ <- gitAlg.commitAll(repo, "Add file3 on conflicts-no")
+        _ <- gitAlg.checkoutBranch(repo, master)
+        // work on conflicts-yes
+        _ <- gitAlg.createBranch(repo, Branch("conflicts-yes"))
+        _ <- fileAlg.writeFile(repo / "file2", "file2, line1\nfile2, line2 on conflicts-yes")
+        _ <- git("add", "file2")(repo)
+        _ <- gitAlg.commitAll(repo, "Modify file2 on conflicts-yes")
+        _ <- gitAlg.checkoutBranch(repo, master)
+        // work on master
+        _ <- fileAlg.writeFile(repo / "file2", "file2, line1\nfile2, line2 on master")
+        _ <- git("add", "file2")(repo)
+        _ <- gitAlg.commitAll(repo, "Modify file2 on master")
+      } yield ()
+
+    def createConflictFileRemovedOnMaster(repo: File): F[Unit] =
+      for {
+        // work on master
+        _ <- fileAlg.writeFile(repo / "file1", "file1, line1")
+        _ <- fileAlg.writeFile(repo / "file2", "file2, line1")
+        _ <- git("add", "file1")(repo)
+        _ <- git("add", "file2")(repo)
+        _ <- gitAlg.commitAll(repo, "Initial commit")
+        // work on conflicts-yes
+        _ <- gitAlg.createBranch(repo, Branch("conflicts-yes"))
+        _ <- fileAlg.writeFile(repo / "file2", "file2, line1\nfile2, line2 on conflicts-yes")
+        _ <- git("add", "file2")(repo)
+        _ <- gitAlg.commitAll(repo, "Modify file2 on conflicts-yes")
+        _ <- gitAlg.checkoutBranch(repo, master)
+        // work on master
+        _ <- git("rm", "file2")(repo)
+        _ <- git("add", "-A")(repo)
+        _ <- gitAlg.commitAll(repo, "Remove file2 on master")
+      } yield ()
+  }
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/git/GitAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/git/GitAlgTest.scala
deleted file mode 100644
index e2d1fefd..00000000
--- a/modules/core/src/test/scala/org/scalasteward/core/git/GitAlgTest.scala
+++ /dev/null
@@ -1,233 +0,0 @@
-package org.scalasteward.core.git
-
-import better.files.File
-import cats.Monad
-import cats.effect.IO
-import cats.syntax.all._
-import org.http4s.syntax.literals._
-import org.scalasteward.core.TestInstances.ioLogger
-import org.scalasteward.core.io.FileAlgTest.ioFileAlg
-import org.scalasteward.core.io.ProcessAlgTest.ioProcessAlg
-import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
-import org.scalasteward.core.mock.MockContext._
-import org.scalasteward.core.mock.MockState
-import org.scalasteward.core.util.Nel
-import org.scalasteward.core.vcs.data.Repo
-import org.scalatest.funsuite.AnyFunSuite
-import org.scalatest.matchers.should.Matchers
-
-class GitAlgTest extends AnyFunSuite with Matchers {
-  implicit val workspaceAlg: WorkspaceAlg[IO] = WorkspaceAlg.create[IO](config)
-  implicit val ioGitAlg: GitAlg[IO] = GitAlg.create[IO](config)
-
-  val repo: Repo = Repo("fthomas", "datapackage")
-  val repoDir: String = (config.workspace / "fthomas/datapackage").toString
-  val askPass = s"GIT_ASKPASS=${config.gitAskPass}"
-  val envVars = List(askPass, "VAR1=val1", "VAR2=val2")
-
-  test("clone") {
-    val url = uri"https://scala-steward@github.com/fthomas/datapackage"
-    val state = gitAlg.clone(repo, url).runS(MockState.empty).unsafeRunSync()
-
-    state shouldBe MockState.empty.copy(
-      commands = Vector(
-        envVars ++ List(
-          config.workspace.toString,
-          "git",
-          "clone",
-          "--recursive",
-          "https://scala-steward@github.com/fthomas/datapackage",
-          repoDir
-        )
-      )
-    )
-  }
-
-  test("branchAuthors") {
-    val state = gitAlg
-      .branchAuthors(repo, Branch("update/cats-1.0.0"), Branch("master"))
-      .runS(MockState.empty)
-      .unsafeRunSync()
-
-    state shouldBe MockState.empty.copy(
-      commands = Vector(
-        envVars ++ List(repoDir, "git", "log", "--pretty=format:'%an'", "master..update/cats-1.0.0")
-      )
-    )
-  }
-
-  test("commitAll") {
-    val state = gitAlg
-      .commitAll(repo, "Initial commit")
-      .runS(MockState.empty)
-      .unsafeRunSync()
-
-    state shouldBe MockState.empty.copy(
-      commands = Vector(
-        envVars ++ List(repoDir, "git", "commit", "--all", "--no-gpg-sign", "-m", "Initial commit")
-      )
-    )
-  }
-
-  test("syncFork") {
-    val url = uri"http://github.com/fthomas/datapackage"
-    val defaultBranch = Branch("master")
-
-    val state = gitAlg
-      .syncFork(repo, url, defaultBranch)
-      .runS(MockState.empty)
-      .unsafeRunSync()
-
-    state shouldBe MockState.empty.copy(
-      commands = Vector(
-        envVars ++ List(
-          repoDir,
-          "git",
-          "remote",
-          "add",
-          "upstream",
-          "http://github.com/fthomas/datapackage"
-        ),
-        envVars ++ List(repoDir, "git", "fetch", "--tags", "upstream", "master"),
-        envVars ++ List(repoDir, "git", "checkout", "-B", "master", "--track", "upstream/master"),
-        envVars ++ List(repoDir, "git", "merge", "upstream/master"),
-        envVars ++ List(repoDir, "git", "push", "--force", "--set-upstream", "origin", "master")
-      )
-    )
-  }
-
-  test("containsChanges") {
-    val repo = Repo("contains", "changes")
-    val p = for {
-      repoDir <- workspaceAlg.repoDir(repo)
-      _ <- GitAlgTest.createRepo[IO](repo, repoDir)
-      _ <- ioFileAlg.writeFile(repoDir / "test.txt", "hello")
-      _ <- ioProcessAlg.exec(Nel.of("git", "add", "test.txt"), repoDir)
-      _ <- ioGitAlg.commitAll(repo, "Add test.txt")
-      c1 <- ioGitAlg.containsChanges(repo)
-      _ <- ioFileAlg.writeFile(repoDir / "test.txt", "hello world")
-      c2 <- ioGitAlg.containsChanges(repo)
-      _ <- ioGitAlg.commitAllIfDirty(repo, "Modify test.txt")
-      c3 <- ioGitAlg.containsChanges(repo)
-    } yield (c1, c2, c3)
-    p.unsafeRunSync() shouldBe ((false, true, false))
-  }
-
-  test("hasConflicts") {
-    val repo = Repo("merge", "conflict")
-    val p = for {
-      repoDir <- workspaceAlg.repoDir(repo)
-      _ <- GitAlgTest.createRepoWithConflict[IO](repo, repoDir)
-      c1 <- ioGitAlg.hasConflicts(repo, Branch("conflicts-yes"), Branch("master"))
-      c2 <- ioGitAlg.hasConflicts(repo, Branch("conflicts-no"), Branch("master"))
-    } yield (c1, c2)
-    p.unsafeRunSync() shouldBe ((true, false))
-  }
-
-  test("mergeTheirs") {
-    val repo = Repo("merge", "theirs")
-    val p = for {
-      repoDir <- workspaceAlg.repoDir(repo)
-      _ <- GitAlgTest.createRepoWithConflict[IO](repo, repoDir)
-      master = Branch("master")
-      branch = Branch("conflicts-yes")
-      c1 <- ioGitAlg.hasConflicts(repo, branch, master)
-      m1 <- ioGitAlg.isMerged(repo, master, branch)
-      _ <- ioGitAlg.checkoutBranch(repo, branch)
-      _ <- ioGitAlg.mergeTheirs(repo, master)
-      c2 <- ioGitAlg.hasConflicts(repo, branch, master)
-      m2 <- ioGitAlg.isMerged(repo, master, branch)
-    } yield (c1, m1, c2, m2)
-    p.unsafeRunSync() shouldBe ((true, false, false, true))
-  }
-
-  test("mergeTheirs: CONFLICT (modify/delete)") {
-    val repo = Repo("merge", "theirs2")
-    val p = for {
-      repoDir <- workspaceAlg.repoDir(repo)
-      _ <- GitAlgTest.createRepoWithConflictFileRemovedOnMaster[IO](repo, repoDir)
-      master = Branch("master")
-      branch = Branch("conflicts-yes")
-      c1 <- ioGitAlg.hasConflicts(repo, branch, master)
-      m1 <- ioGitAlg.isMerged(repo, master, branch)
-      _ <- ioGitAlg.checkoutBranch(repo, branch)
-      _ <- ioGitAlg.mergeTheirs(repo, master)
-      c2 <- ioGitAlg.hasConflicts(repo, branch, master)
-      m2 <- ioGitAlg.isMerged(repo, master, branch)
-    } yield (c1, m1, c2, m2)
-    p.unsafeRunSync() shouldBe ((true, false, false, true))
-  }
-}
-
-object GitAlgTest {
-  def createRepo[F[_]](repo: Repo, repoDir: File)(implicit
-      fileAlg: FileAlg[F],
-      gitAlg: GitAlg[F],
-      processAlg: ProcessAlg[F],
-      F: Monad[F]
-  ): F[Unit] =
-    for {
-      _ <- gitAlg.removeClone(repo)
-      _ <- fileAlg.ensureExists(repoDir)
-      _ <- processAlg.exec(Nel.of("git", "init", "."), repoDir)
-      _ <- gitAlg.setAuthor(repo, config.gitAuthor)
-    } yield ()
-
-  def createRepoWithConflict[F[_]](repo: Repo, repoDir: File)(implicit
-      fileAlg: FileAlg[F],
-      gitAlg: GitAlg[F],
-      processAlg: ProcessAlg[F],
-      F: Monad[F]
-  ): F[Unit] =
-    for {
-      _ <- createRepo[F](repo, repoDir)
-      // work on master
-      _ <- fileAlg.writeFile(repoDir / "file1", "file1, line1")
-      _ <- fileAlg.writeFile(repoDir / "file2", "file2, line1")
-      _ <- processAlg.exec(Nel.of("git", "add", "file1"), repoDir)
-      _ <- processAlg.exec(Nel.of("git", "add", "file2"), repoDir)
-      _ <- processAlg.exec(Nel.of("git", "commit", "-m", "Initial commit"), repoDir)
-      // work on conflicts-no
-      _ <- gitAlg.createBranch(repo, Branch("conflicts-no"))
-      _ <- fileAlg.writeFile(repoDir / "file3", "file3, line1")
-      _ <- processAlg.exec(Nel.of("git", "add", "file3"), repoDir)
-      _ <- processAlg.exec(Nel.of("git", "commit", "-m", "Add file3 on conflicts-no"), repoDir)
-      _ <- gitAlg.checkoutBranch(repo, Branch("master"))
-      // work on conflicts-yes
-      _ <- gitAlg.createBranch(repo, Branch("conflicts-yes"))
-      _ <- fileAlg.writeFile(repoDir / "file2", "file2, line1\nfile2, line2 on conflicts-yes")
-      _ <- processAlg.exec(Nel.of("git", "add", "file2"), repoDir)
-      _ <- processAlg.exec(Nel.of("git", "commit", "-m", "Modify file2 on conflicts-yes"), repoDir)
-      _ <- gitAlg.checkoutBranch(repo, Branch("master"))
-      // work on master
-      _ <- fileAlg.writeFile(repoDir / "file2", "file2, line1\nfile2, line2 on master")
-      _ <- processAlg.exec(Nel.of("git", "add", "file2"), repoDir)
-      _ <- processAlg.exec(Nel.of("git", "commit", "-m", "Modify file2 on master"), repoDir)
-    } yield ()
-
-  def createRepoWithConflictFileRemovedOnMaster[F[_]](repo: Repo, repoDir: File)(implicit
-      fileAlg: FileAlg[F],
-      gitAlg: GitAlg[F],
-      processAlg: ProcessAlg[F],
-      F: Monad[F]
-  ): F[Unit] =
-    for {
-      _ <- createRepo[F](repo, repoDir)
-      // work on master
-      _ <- fileAlg.writeFile(repoDir / "file1", "file1, line1")
-      _ <- fileAlg.writeFile(repoDir / "file2", "file2, line1")
-      _ <- processAlg.exec(Nel.of("git", "add", "file1"), repoDir)
-      _ <- processAlg.exec(Nel.of("git", "add", "file2"), repoDir)
-      _ <- processAlg.exec(Nel.of("git", "commit", "-m", "Initial commit"), repoDir)
-      // work on conflicts-yes
-      _ <- gitAlg.createBranch(repo, Branch("conflicts-yes"))
-      _ <- fileAlg.writeFile(repoDir / "file2", "file2, line1\nfile2, line2 on conflicts-yes")
-      _ <- processAlg.exec(Nel.of("git", "add", "file2"), repoDir)
-      _ <- processAlg.exec(Nel.of("git", "commit", "-m", "Modify file2 on conflicts-yes"), repoDir)
-      _ <- gitAlg.checkoutBranch(repo, Branch("master"))
-      // work on master
-      _ <- processAlg.exec(Nel.of("git", "rm", "file2"), repoDir)
-      _ <- processAlg.exec(Nel.of("git", "add", "-A"), repoDir)
-      _ <- processAlg.exec(Nel.of("git", "commit", "-m", "Remove file2 on master"), repoDir)
-    } yield ()
-}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala b/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
index 3a5a47f7..b9aa3fe8 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
@@ -15,7 +15,7 @@ import org.scalasteward.core.buildtool.sbt.SbtAlg
 import org.scalasteward.core.coursier.{CoursierAlg, VersionsCache}
 import org.scalasteward.core.edit.EditAlg
 import org.scalasteward.core.edit.hooks.HookExecutor
-import org.scalasteward.core.git.GitAlg
+import org.scalasteward.core.git.{GenGitAlg, GitAlg}
 import org.scalasteward.core.io._
 import org.scalasteward.core.nurture.PullRequestRepository
 import org.scalasteward.core.persistence.JsonKeyValueStore
@@ -62,7 +62,7 @@ object MockContext {
 
   implicit val coursierAlg: CoursierAlg[MockEff] = CoursierAlg.create
   implicit val dateTimeAlg: DateTimeAlg[MockEff] = DateTimeAlg.create
-  implicit val gitAlg: GitAlg[MockEff] = GitAlg.create(config)
+  implicit val gitAlg: GitAlg[MockEff] = GenGitAlg.create(config)
   implicit val hookExecutor: HookExecutor[MockEff] = new HookExecutor[MockEff]
   implicit val user: AuthenticatedUser = AuthenticatedUser("scala-steward", "token")
   implicit val vcsRepoAlg: VCSRepoAlg[MockEff] = VCSRepoAlg.create(config)