Repository: scala-steward-org/scala-steward
Pull request #3215 (2023-11-17 08:38:44)

Use `GitAlg.findFilesContaining` in `ScannerAlg`
This implements the idea mentioned in https://github.com/scala-steward-org/scala-steward/pull/2994#issuecomment-1455174931

`ScannerAlg` now uses `GitAlg.findFilesContaining` instead of our own `FileAlg.findFiles` to find files that contain versions and groupIds. This simplifies `findPathsContaining` because we do not need the gitignore dependency anymore to skip files that are ignored by Git since `git grep`, which is used by `GitAlg.findFilesContaining`, does this automatically for us.

The biggest impact of this change in the code base is in the tests. `EditAlgTest` and `RewriteTest` now need to actually execute `git grep` to find files that should be edited. For this, the `execCommands` flag is used (#3005) and Git repositories are created for these tests. One advantage of this change is that we mock less and the `MockState` traces are closer to what Steward is actually doing in production.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scala-steward-org/scala-steward/pull/3215?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Comparison is base [(`107f04a`)](https://app.codecov.io/gh/scala-steward-org/scala-steward/commit/107f04a421f61cdc38a02414d928d1964bc3874b?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) 91.07% compared to head [(`5386042`)](https://app.codecov.io/gh/scala-steward-org/scala-steward/pull/3215?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) 91.01%.


<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #3215      +/-   ##
==========================================
- Coverage   91.07%   91.01%   -0.07%     
==========================================
  Files         163      163              
  Lines        3407     3394      -13     
  Branches      310      301       -9     
==========================================
- Hits         3103     3089      -14     
- Misses        304      305       +1     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/scala-steward-org/scala-steward/pull/3215?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).


Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 4379c5cf..5bf4927f 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -67,6 +67,10 @@ jobs:
       - name: Check that workflows are up to date
         run: sbt githubWorkflowCheck
 
+      - uses: coursier/setup-action@v1
+        with:
+          apps: scalafmt
+
       - name: Build project
         run: sbt '++ ${{ matrix.scala }}' validate
 
diff --git a/build.sbt b/build.sbt
index 84ab3402..d8b1ddcc 100644
--- a/build.sbt
+++ b/build.sbt
@@ -53,6 +53,8 @@ ThisBuild / githubWorkflowPublish := Seq(
 ThisBuild / githubWorkflowJavaVersions := Seq(JavaSpec(Temurin, "17"), JavaSpec(Temurin, "11"))
 ThisBuild / githubWorkflowBuild :=
   Seq(
+    WorkflowStep
+      .Use(UseRef.Public("coursier", "setup-action", "v1"), params = Map("apps" -> "scalafmt")),
     WorkflowStep.Sbt(List("validate"), name = Some("Build project")),
     WorkflowStep.Use(
       UseRef.Public("codecov", "codecov-action", "v3"),
@@ -122,7 +124,6 @@ lazy val core = myCrossProject("core")
       Dependencies.decline,
       Dependencies.fs2Core,
       Dependencies.fs2Io,
-      Dependencies.gitignore,
       Dependencies.http4sCirce,
       Dependencies.http4sClient,
       Dependencies.http4sCore,
diff --git a/modules/core/src/main/scala/org/scalasteward/core/edit/update/ScannerAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/edit/update/ScannerAlg.scala
index 3c1d9aa5..8caef265 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/edit/update/ScannerAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/edit/update/ScannerAlg.scala
@@ -16,13 +16,12 @@
 
 package org.scalasteward.core.edit.update
 
-import better.files.File
 import cats.effect.Concurrent
-import cats.syntax.functor._
-import com.github.arturopala.gitignore.GitIgnore
+import cats.syntax.all._
 import fs2.Stream
 import org.scalasteward.core.data.{Dependency, Repo, Version}
 import org.scalasteward.core.edit.update.data.{ModulePosition, VersionPosition}
+import org.scalasteward.core.git.GitAlg
 import org.scalasteward.core.io.{FileAlg, FileData, WorkspaceAlg}
 import org.scalasteward.core.repoconfig.RepoConfig
 import org.scalasteward.core.util.Nel
@@ -30,6 +29,7 @@ import org.scalasteward.core.util.Nel
 /** Scans all files that Scala Steward is allowed to edit for version and module positions. */
 final class ScannerAlg[F[_]](implicit
     fileAlg: FileAlg[F],
+    gitAlg: GitAlg[F],
     workspaceAlg: WorkspaceAlg[F],
     F: Concurrent[F]
 ) {
@@ -58,35 +58,15 @@ final class ScannerAlg[F[_]](implicit
       .compile
       .foldMonoid
 
-  private def getGitIgnore(repoDir: File): F[Option[GitIgnore]] = {
-    val gitIgnore = repoDir / ".gitignore"
-    fileAlg.readFile(gitIgnore).map(_.map(GitIgnore.parse))
-  }
-
   private def findPathsContaining(
       repo: Repo,
       config: RepoConfig,
       string: String
-  ): Stream[F, FileData] = {
-    def fileFilter(repoDir: File, maybeGitIgnore: Option[GitIgnore]) = (file: File) => {
-      val path = repoDir.relativize(file).toString
-      val notDotGit = !path.startsWith(".git/")
-      val onlyKeepConfiguredExtensions =
-        config.updates.fileExtensionsOrDefault.exists(path.endsWith)
-      val allowedByGitIgnore = maybeGitIgnore.map(_.isAllowed(path)).getOrElse(true)
-      val cond = notDotGit &&
-        onlyKeepConfiguredExtensions &&
-        allowedByGitIgnore
-      Option.when(cond)(path)
+  ): Stream[F, FileData] =
+    Stream.eval(workspaceAlg.repoDir(repo)).flatMap { repoDir =>
+      Stream
+        .evalSeq(gitAlg.findFilesContaining(repo, string))
+        .filter(path => config.updates.fileExtensionsOrDefault.exists(path.endsWith))
+        .evalMapFilter(path => fileAlg.readFile(repoDir / path).map(_.map(FileData(path, _))))
     }
-    val contentFilter = (content: String) => Some(content).filter(_.contains(string))
-
-    for {
-      repoDir <- Stream.eval(workspaceAlg.repoDir(repo))
-      maybeRootGitIgnore <- Stream.eval(getGitIgnore(repoDir))
-      files <- fileAlg
-        .findFiles(repoDir, fileFilter(repoDir, maybeRootGitIgnore), contentFilter)
-        .map(FileData.tupled)
-    } yield files
-  }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/io/FileAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/io/FileAlg.scala
index 78c96792..1c845201 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/io/FileAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/io/FileAlg.scala
@@ -19,7 +19,7 @@ package org.scalasteward.core.io
 import better.files.File
 import cats.effect.{Resource, Sync}
 import cats.syntax.all._
-import cats.{ApplicativeError, Monad, MonadThrow}
+import cats.{ApplicativeError, MonadThrow}
 import fs2.Stream
 import org.apache.commons.io.FileUtils
 import org.http4s.Uri
@@ -67,22 +67,6 @@ trait FileAlg[F[_]] {
     readFile(file)
       .flatMap(_.fold(F.unit)(edit(_).flatMap(writeFile(file, _))))
       .adaptError { case t => new Throwable(s"failed to edit $file", t) }
-
-  final def findFiles[A, B](
-      dir: File,
-      fileFilter: File => Option[A],
-      contentFilter: String => Option[B]
-  )(implicit F: Monad[F]): Stream[F, (A, B)] = {
-    val none = Option.empty[(A, B)].pure[F]
-    walk(dir).evalMapFilter { file =>
-      isRegularFile(file).ifM(
-        fileFilter(file).fold(none) { a =>
-          readFile(file).map(_.flatMap(contentFilter).tupleLeft(a))
-        },
-        none
-      )
-    }
-  }
 }
 
 object FileAlg {
diff --git a/project/Dependencies.scala b/project/Dependencies.scala
index e5076c0a..e906a77e 100644
--- a/project/Dependencies.scala
+++ b/project/Dependencies.scala
@@ -22,7 +22,6 @@ object Dependencies {
   val disciplineMunit = "org.typelevel" %% "discipline-munit" % "1.0.9"
   val fs2Core = "co.fs2" %% "fs2-core" % "3.9.3"
   val fs2Io = "co.fs2" %% "fs2-io" % fs2Core.revision
-  val gitignore = "com.github.arturopala" %% "gitignore" % "0.6.0"
   val http4sCore = "org.http4s" %% "http4s-core" % "1.0.0-M40"
   val http4sCirce = "org.http4s" %% "http4s-circe" % http4sCore.revision
   val http4sClient = "org.http4s" %% "http4s-client" % http4sCore.revision


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/scalacli/ScalaCliAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/scalacli/ScalaCliAlgTest.scala
index e70768ca..7852ee9e 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/buildtool/scalacli/ScalaCliAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/scalacli/ScalaCliAlgTest.scala
@@ -18,8 +18,7 @@ class ScalaCliAlgTest extends CatsEffectSuite {
     val buildRoot = BuildRoot(repo, ".")
     val repoDir = workspaceAlg.repoDir(repo).unsafeRunSync()
     val fileWithUsingLib = "test.md" // this test fails if the extension is .scala or .sc
-    val grepCmd =
-      Cmd.git(repoDir, "grep", "-I", "--fixed-strings", "--files-with-matches", "//> using lib ")
+    val grepCmd = Cmd.gitGrep(repoDir, "//> using lib ")
     val initial =
       MockState.empty.copy(commandOutputs = Map(grepCmd -> Right(List(fileWithUsingLib))))
     val obtained = scalaCliAlg.containsBuild(buildRoot).runA(initial)
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 8052d7e9..fd806bd2 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
@@ -1,6 +1,5 @@
 package org.scalasteward.core.edit
 
-import better.files.File
 import cats.effect.unsafe.implicits.global
 import munit.FunSuite
 import org.scalasteward.core.TestInstances.dummyRepoCache
@@ -16,46 +15,39 @@ import org.scalasteward.core.scalafmt.ScalafmtAlg.opts
 import org.scalasteward.core.scalafmt.{scalafmtBinary, scalafmtConfName, scalafmtDependency}
 
 class EditAlgTest extends FunSuite {
-  private def gitStatus(repoDir: File): Cmd =
-    Cmd.git(repoDir, "status", "--porcelain", "--untracked-files=no", "--ignore-submodules")
-
   test("applyUpdate") {
     val repo = Repo("edit-alg", "test-1")
     val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
     val repoDir = workspaceAlg.repoDir(repo).unsafeRunSync()
     val update = ("org.typelevel".g % "cats-core".a % "1.2.0" %> "1.3.0").single
-    val file1 = repoDir / "build.sbt"
-    val file2 = repoDir / "project/Dependencies.scala"
+    val buildSbt = repoDir / "build.sbt"
+    val scalaFile = repoDir / "project/Dependencies.scala"
     val gitignore = repoDir / ".gitignore"
 
     val state = MockState.empty
-      .addFiles(file1 -> """val catsVersion = "1.2.0"""", file2 -> "", gitignore -> "")
+      .copy(execCommands = true)
+      .initGitRepo(
+        repoDir,
+        buildSbt -> """val catsVersion = "1.2.0"""",
+        scalaFile -> "",
+        gitignore -> ""
+      )
       .flatMap(editAlg.applyUpdate(data, update).runS)
       .unsafeRunSync()
 
     val expected = MockState.empty.copy(
+      execCommands = true,
       trace = Vector(
-        Cmd("read", gitignore.pathAsString),
-        Cmd("test", "-f", repoDir.pathAsString),
-        Cmd("test", "-f", gitignore.pathAsString),
-        Cmd("test", "-f", file1.pathAsString),
-        Cmd("read", file1.pathAsString),
-        Cmd("test", "-f", (repoDir / "project").pathAsString),
-        Cmd("test", "-f", file2.pathAsString),
-        Cmd("read", file2.pathAsString),
-        Cmd("read", gitignore.pathAsString),
-        Cmd("test", "-f", repoDir.pathAsString),
-        Cmd("test", "-f", gitignore.pathAsString),
-        Cmd("test", "-f", file1.pathAsString),
-        Cmd("read", file1.pathAsString),
-        Cmd("test", "-f", (repoDir / "project").pathAsString),
-        Cmd("test", "-f", file2.pathAsString),
-        Cmd("read", file2.pathAsString),
-        Cmd("read", file1.pathAsString),
-        Cmd("write", file1.pathAsString),
-        gitStatus(repoDir)
+        Cmd.gitGrep(repoDir, update.currentVersion.value),
+        Cmd("read", buildSbt.pathAsString),
+        Cmd.gitGrep(repoDir, update.groupId.value),
+        Cmd("read", buildSbt.pathAsString),
+        Cmd("write", buildSbt.pathAsString),
+        Cmd.gitStatus(repoDir),
+        Cmd.gitCommit(repoDir, "Update cats-core to 1.3.0"),
+        Cmd.gitLatestSha1(repoDir)
       ),
-      files = Map(file1 -> """val catsVersion = "1.3.0"""", file2 -> "", gitignore -> "")
+      files = Map(buildSbt -> """val catsVersion = "1.3.0"""", scalaFile -> "", gitignore -> "")
     )
 
     assertEquals(state, expected)
@@ -69,56 +61,41 @@ class EditAlgTest extends FunSuite {
     val data = RepoData(repo, cache, RepoConfig.empty)
     val repoDir = workspaceAlg.repoDir(repo).unsafeRunSync()
     val update = ("org.scalameta".g % "scalafmt-core".a % "2.0.0" %> "2.1.0").single
-    val gitignore = repoDir / ".gitignore"
+    val gitignore = (repoDir / ".gitignore") -> "target/"
     val scalafmtConf = repoDir / scalafmtConfName
     val scalafmtConfContent = """maxColumn = 100
                                 |version = 2.0.0
                                 |align.openParenCallSite = false
                                 |""".stripMargin
-    val buildSbt = repoDir / "build.sbt"
+    val buildSbt = (repoDir / "build.sbt") -> "\n"
     val target = repoDir / "target"
     // this file should not be read because it's under target which is git ignored
     val targetScalaFile = target / "SomeFile.scala"
 
     val state = MockState.empty
-      .addFiles(
-        scalafmtConf -> scalafmtConfContent,
-        buildSbt -> "",
-        gitignore -> "target/",
-        targetScalaFile -> ""
-      )
+      .copy(execCommands = true)
+      .initGitRepo(repoDir, scalafmtConf -> scalafmtConfContent, buildSbt, gitignore)
+      .flatMap(_.addFiles(targetScalaFile -> s""" object Test {"2.0.0"} """))
       .flatMap(editAlg.applyUpdate(data, update).runS)
       .unsafeRunSync()
 
     val expected = MockState.empty.copy(
+      execCommands = true,
       trace = Vector(
-        Cmd("read", gitignore.pathAsString),
-        Cmd("test", "-f", repoDir.pathAsString),
-        Cmd("test", "-f", gitignore.pathAsString),
-        Cmd("test", "-f", scalafmtConf.pathAsString),
+        Cmd.gitGrep(repoDir, update.currentVersion.value),
         Cmd("read", scalafmtConf.pathAsString),
-        Cmd("test", "-f", buildSbt.pathAsString),
-        Cmd("read", buildSbt.pathAsString),
-        Cmd("test", "-f", target.pathAsString),
-        Cmd("test", "-f", targetScalaFile.pathAsString),
-        Cmd("read", gitignore.pathAsString),
-        Cmd("test", "-f", repoDir.pathAsString),
-        Cmd("test", "-f", gitignore.pathAsString),
-        Cmd("test", "-f", scalafmtConf.pathAsString),
-        Cmd("read", scalafmtConf.pathAsString),
-        Cmd("test", "-f", buildSbt.pathAsString),
-        Cmd("read", buildSbt.pathAsString),
-        Cmd("test", "-f", target.pathAsString),
-        Cmd("test", "-f", targetScalaFile.pathAsString),
+        Cmd.gitGrep(repoDir, update.groupId.value),
         Cmd("read", scalafmtConf.pathAsString),
         Cmd("write", scalafmtConf.pathAsString),
         Cmd.exec(repoDir, scalafmtBinary :: opts.nonInteractive :: opts.modeChanged: _*),
-        gitStatus(repoDir),
+        Cmd.gitStatus(repoDir),
+        Cmd.gitCommit(repoDir, "Update scalafmt-core to 2.1.0"),
+        Cmd.gitLatestSha1(repoDir),
         Log(
           "Executing post-update hook for org.scalameta:scalafmt-core with command 'scalafmt --non-interactive'"
         ),
         Cmd.exec(repoDir, scalafmtBinary, opts.nonInteractive),
-        gitStatus(repoDir)
+        Cmd.gitStatus(repoDir)
       ),
       files = Map(
         scalafmtConf ->
@@ -126,9 +103,9 @@ class EditAlgTest extends FunSuite {
             |version = 2.1.0
             |align.openParenCallSite = false
             |""".stripMargin,
-        buildSbt -> "",
-        gitignore -> "target/",
-        targetScalaFile -> ""
+        buildSbt,
+        gitignore,
+        targetScalaFile -> s"""object Test { "2.0.0" }\n"""
       )
     )
 
@@ -136,13 +113,15 @@ class EditAlgTest extends FunSuite {
   }
 
   test("applyUpdate with build Scalafix") {
-    val repo = Repo("edit-alg", "test-3-1")
+    val repo = Repo("edit-alg", "test-3")
     val data = RepoData(repo, dummyRepoCache, RepoConfig.empty)
     val repoDir = workspaceAlg.repoDir(repo).unsafeRunSync()
     val update = (sbtGroupId % sbtArtifactId % "1.4.9" %> "1.5.5").single
 
     val state = MockState.empty
-      .addFiles(
+      .copy(execCommands = true)
+      .initGitRepo(
+        repoDir,
         repoDir / "build.sbt" -> "",
         repoDir / "project" / "build.properties" -> """sbt.version=1.4.9"""
       )
diff --git a/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala b/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala
index be14d2d4..39107459 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/edit/RewriteTest.scala
@@ -957,7 +957,8 @@ class RewriteTest extends FunSuite {
     val repoDir = workspaceAlg.repoDir(repo).unsafeRunSync()
     val filesInRepoDir = files.map { case (file, content) => repoDir / file -> content }
     val state = MockState.empty
-      .addFiles(filesInRepoDir.toSeq: _*)
+      .copy(execCommands = true)
+      .initGitRepo(repoDir, filesInRepoDir.toSeq: _*)
       .flatMap(editAlg.applyUpdate(data, update).runS)
       .unsafeRunSync()
     val obtained = state.files
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 66fd8d0f..ab6539c4 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
@@ -29,7 +29,7 @@ class HookExecutorTest extends CatsEffectSuite {
     state.map(assertEquals(_, MockState.empty))
   }
 
-  Seq(true, false).foreach { case blameRevIgnored =>
+  Seq(true, false).foreach { blameRevIgnored =>
     val isIgnored = if (blameRevIgnored) "ignored" else "not ignored"
 
     test(s"scalafmt: enabled by config / $gitBlameIgnoreRevsName $isIgnored") {
@@ -37,11 +37,8 @@ class HookExecutorTest extends CatsEffectSuite {
       val update = (scalafmtGroupId % scalafmtArtifactId % "2.7.4" %> "2.7.5").single
       val initial = MockState.empty.copy(commandOutputs =
         Map(
-          Cmd
-            .git(repoDir, "status", "--porcelain", "--untracked-files=no", "--ignore-submodules") ->
-            Right(List("build.sbt")),
-          Cmd.git(repoDir, "rev-parse", "--verify", "HEAD") ->
-            Right(List(dummySha1.value.value)),
+          Cmd.gitStatus(repoDir) -> Right(List("build.sbt")),
+          Cmd.gitLatestSha1(repoDir) -> Right(List(dummySha1.value.value)),
           Cmd.git(repoDir, "check-ignore", gitBlameIgnoreRevs.pathAsString) ->
             (if (blameRevIgnored) Right(List.empty) else Left(dummyProcessError))
         )
@@ -55,22 +52,12 @@ class HookExecutorTest extends CatsEffectSuite {
         else
           Vector(
             Cmd.git(repoDir, "add", gitBlameIgnoreRevs.pathAsString),
-            Cmd.git(
+            Cmd.gitStatus(repoDir),
+            Cmd.gitCommit(
               repoDir,
-              "status",
-              "--porcelain",
-              "--untracked-files=no",
-              "--ignore-submodules"
-            ),
-            Cmd.git(
-              repoDir,
-              "commit",
-              "--all",
-              "--no-gpg-sign",
-              "-m",
               s"Add 'Reformat with scalafmt 2.7.5' to $gitBlameIgnoreRevsName"
             ),
-            Cmd.git(repoDir, "rev-parse", "--verify", "HEAD")
+            Cmd.gitLatestSha1(repoDir)
           )
 
       val traces = Vector(
@@ -80,24 +67,13 @@ class HookExecutorTest extends CatsEffectSuite {
         Cmd(
           "VAR1=val1" :: "VAR2=val2" :: repoDir.toString :: scalafmtBinary :: opts.nonInteractive :: Nil
         ),
-        Cmd.git(
-          repoDir,
-          "status",
-          "--porcelain",
-          "--untracked-files=no",
-          "--ignore-submodules"
-        ),
-        Cmd.git(
+        Cmd.gitStatus(repoDir),
+        Cmd.gitCommit(
           repoDir,
-          "commit",
-          "--all",
-          "--no-gpg-sign",
-          "-m",
           "Reformat with scalafmt 2.7.5",
-          "-m",
           s"Executed command: $scalafmtBinary ${opts.nonInteractive}"
         ),
-        Cmd.git(repoDir, "rev-parse", "--verify", "HEAD"),
+        Cmd.gitLatestSha1(repoDir),
         Cmd("read", gitBlameIgnoreRevs.pathAsString),
         Cmd("write", gitBlameIgnoreRevs.pathAsString),
         Cmd.git(repoDir, "check-ignore", gitBlameIgnoreRevs.pathAsString)
@@ -135,7 +111,7 @@ class HookExecutorTest extends CatsEffectSuite {
           "Executing post-update hook for com.codecommit:sbt-github-actions with command 'sbt githubWorkflowGenerate'"
         ),
         Cmd.execSandboxed(repoDir, "sbt", "githubWorkflowGenerate"),
-        Cmd.git(repoDir, "status", "--porcelain", "--untracked-files=no", "--ignore-submodules")
+        Cmd.gitStatus(repoDir)
       )
     )
 
@@ -161,7 +137,7 @@ class HookExecutorTest extends CatsEffectSuite {
       trace = Vector(
         Log("Executing post-update hook for com.random:cool-lib with command 'sbt mySbtCommand'"),
         Cmd.execSandboxed(repoDir, "sbt", "mySbtCommand"),
-        Cmd.git(repoDir, "status", "--porcelain", "--untracked-files=no", "--ignore-submodules")
+        Cmd.gitStatus(repoDir)
       )
     )
 
diff --git a/modules/core/src/test/scala/org/scalasteward/core/io/MockProcessAlg.scala b/modules/core/src/test/scala/org/scalasteward/core/io/MockProcessAlg.scala
index f0a8dd07..22233745 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/io/MockProcessAlg.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/io/MockProcessAlg.scala
@@ -1,5 +1,6 @@
 package org.scalasteward.core.io
 
+import cats.syntax.all._
 import cats.data.Kleisli
 import cats.effect.IO
 import org.scalasteward.core.application.Config.ProcessCfg
@@ -11,19 +12,30 @@ object MockProcessAlg {
     new ProcessAlg(config)({ args =>
       Kleisli { ctx =>
         for {
-          state <- ctx.get
+          state0 <- ctx.get
           cmd = Cmd(
             args.extraEnv.map { case (k, v) => s"$k=$v" } ++
               args.workingDirectory.map(_.toString).toList ++
               args.command.toList
           )
-          _ <- ctx.set(state.appendTraceEntry(cmd))
-          res <- state.commandOutputs.get(cmd) match {
-            case Some(output)               => IO.fromEither(output)
-            case None if state.execCommands => ProcessAlgTest.ioProcessAlg.execImpl(args)
-            case None                       => IO.pure(List.empty)
+          state1 = state0.appendTraceEntry(cmd)
+          _ <- ctx.set(state1)
+          res <- state1.commandOutputs.get(cmd) match {
+            case Some(output) => IO.fromEither(output).tupleRight(state1.files)
+            case None if state1.execCommands =>
+              for {
+                output <- ProcessAlgTest.ioProcessAlg.execImpl(args)
+                // External processes can modify tracked files. We read them again here so that
+                // they are in sync with the files in the filesystem.
+                files <- state1.files.keys.toList.traverse { file =>
+                  FileAlgTest.ioFileAlg.readFile(file).map(_.getOrElse("")).tupleLeft(file)
+                }
+              } yield (output, files.toMap)
+            case None => IO.pure((List.empty, state1.files))
           }
-        } yield res
+          (output, files) = res
+          _ <- ctx.set(state1.copy(files = files))
+        } yield output
       }
     })
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/mock/MockState.scala b/modules/core/src/test/scala/org/scalasteward/core/mock/MockState.scala
index cd0ec70c..d492dc12 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/mock/MockState.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/mock/MockState.scala
@@ -5,6 +5,7 @@ import cats.effect.{IO, Ref}
 import cats.syntax.all._
 import org.http4s.{HttpApp, Uri}
 import org.scalasteward.core.git.FileGitAlg
+import org.scalasteward.core.git.FileGitAlgTest.ioAuxGitAlg
 import org.scalasteward.core.io.FileAlgTest.ioFileAlg
 import org.scalasteward.core.mock.MockConfig.mockRoot
 import org.scalasteward.core.mock.MockState.TraceEntry
@@ -26,6 +27,13 @@ final case class MockState(
   def addUris(newUris: (Uri, String)*): MockState =
     copy(uris = uris ++ newUris)
 
+  def initGitRepo(repoDir: File, files: (File, String)*): IO[MockState] =
+    for {
+      _ <- ioAuxGitAlg.createRepo(repoDir)
+      state <- addFiles(files: _*)
+      _ <- ioAuxGitAlg.addFiles(repoDir, files.map { case (file, _) => file }: _*)
+    } yield state
+
   def rmFile(file: File): IO[MockState] =
     ioFileAlg.deleteForce(file).as(copy(files = files - file))
 
@@ -75,11 +83,26 @@ object MockState {
           ) ++ args
         )
 
-      def git(repoDir: File, args: String*): Cmd =
-        Cmd(
-          List(s"GIT_ASKPASS=$mockRoot/askpass.sh", "VAR1=val1", "VAR2=val2", repoDir.toString) ++
-            FileGitAlg.gitCmd.toList ++ args
-        )
+      def git(repoDir: File, args: String*): Cmd = {
+        val env =
+          List(s"GIT_ASKPASS=$mockRoot/askpass.sh", "VAR1=val1", "VAR2=val2", repoDir.toString)
+        Cmd(env ++ FileGitAlg.gitCmd.toList ++ args)
+      }
+
+      def gitCommit(repoDir: File, messages: String*): Cmd = {
+        val args =
+          "commit" :: "--all" :: "--no-gpg-sign" :: messages.toList.flatMap(m => List("-m", m))
+        git(repoDir, args: _*)
+      }
+
+      def gitGrep(repoDir: File, string: String): Cmd =
+        git(repoDir, "grep", "-I", "--fixed-strings", "--files-with-matches", string)
+
+      def gitLatestSha1(repoDir: File): Cmd =
+        git(repoDir, "rev-parse", "--verify", "HEAD")
+
+      def gitStatus(repoDir: File): Cmd =
+        git(repoDir, "status", "--porcelain", "--untracked-files=no", "--ignore-submodules")
     }
 
     final case class Log(log: (Option[Throwable], String)) extends TraceEntry