Repository: scala-steward-org/scala-steward (scala)

Issue:
Add reformatting commits to .git-blame-ignore-revs
When Scala Steward creates reformatting commits with scalafmt it could add the corresponding commit hashes to `.git-blame-ignore-revs` if it exists. See https://www.moxio.com/blog/43/ignoring-bulk-change-commits-with-git-blame for more context.

Pull request description:
This adds a new field `addToGitBlameIgnoreRevs` to `PostUpdateHook`
which controls if the commit hash of that hook should be appended to
`.git-blame-ignore-revs`. If that file does not exists in the repo,
it will be added by Scala Steward.

Example PRs:
* https://github.com/scala-steward-org/test-repo-1/pull/15
* https://github.com/scala-steward-org/test-repo-1/pull/16

Closes: #2567

Patch:
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 8fccc11cdd..58e6e8f571 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
@@ -22,23 +22,25 @@ import org.scalasteward.core.edit.scalafix.ScalafixMigration
 import org.scalasteward.core.git.Commit
 
 sealed trait EditAttempt extends Product with Serializable {
-  def maybeCommit: Option[Commit]
+  def commits: List[Commit]
 }
 
 object EditAttempt {
   final case class UpdateEdit(update: Update, commit: Commit) extends EditAttempt {
-    override def maybeCommit: Some[Commit] = Some(commit)
+    override def commits: List[Commit] = List(commit)
   }
 
   final case class ScalafixEdit(
       migration: ScalafixMigration,
       result: Either[Throwable, Unit],
       maybeCommit: Option[Commit]
-  ) extends EditAttempt
+  ) extends EditAttempt {
+    override def commits: List[Commit] = maybeCommit.toList
+  }
 
   final case class HookEdit(
       hook: PostUpdateHook,
       result: Either[Throwable, Unit],
-      maybeCommit: Option[Commit]
+      commits: List[Commit]
   ) extends EditAttempt
 }
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 09e3046aac..ba1c1132d5 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
@@ -16,6 +16,7 @@
 
 package org.scalasteward.core.edit.hooks
 
+import better.files.File
 import cats.MonadThrow
 import cats.syntax.all._
 import org.scalasteward.core.buildtool.sbt.{
@@ -27,8 +28,8 @@ import org.scalasteward.core.buildtool.sbt.{
 import org.scalasteward.core.data._
 import org.scalasteward.core.edit.EditAttempt
 import org.scalasteward.core.edit.EditAttempt.HookEdit
-import org.scalasteward.core.git.{CommitMsg, GitAlg}
-import org.scalasteward.core.io.{ProcessAlg, WorkspaceAlg}
+import org.scalasteward.core.git.{gitBlameIgnoreRevsName, Commit, CommitMsg, GitAlg}
+import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.repocache.RepoCache
 import org.scalasteward.core.scalafmt.{scalafmtArtifactId, scalafmtGroupId, ScalafmtAlg}
 import org.scalasteward.core.util.Nel
@@ -37,6 +38,7 @@ import org.scalasteward.core.vcs.data.Repo
 import org.typelevel.log4cats.Logger
 
 final class HookExecutor[F[_]](implicit
+    fileAlg: FileAlg[F],
     gitAlg: GitAlg[F],
     logger: Logger[F],
     processAlg: ProcessAlg[F],
@@ -57,15 +59,38 @@ final class HookExecutor[F[_]](implicit
   private def execPostUpdateHook(repo: Repo, update: Update, hook: PostUpdateHook): F[EditAttempt] =
     for {
       _ <- logger.info(
-        s"Executing post-update hook for ${update.groupId}:${update.mainArtifactId} with command " +
-          s"${hook.command.mkString_("'", " ", "'")}"
+        s"Executing post-update hook for ${update.groupId}:${update.mainArtifactId} with command ${hook.showCommand}"
       )
       repoDir <- workspaceAlg.repoDir(repo)
       result <- logger.attemptWarn.log("Post-update hook failed") {
         processAlg.execMaybeSandboxed(hook.useSandbox)(hook.command, repoDir)
       }
-      maybeCommit <- gitAlg.commitAllIfDirty(repo, hook.commitMessage(update))
-    } yield HookEdit(hook, result.void, maybeCommit)
+      commitMessage = hook.commitMessage(update)
+      maybeHookCommit <- gitAlg.commitAllIfDirty(repo, commitMessage)
+      maybeBlameIgnoreCommit <-
+        maybeHookCommit.flatTraverse(addToGitBlameIgnoreRevs(repo, repoDir, hook, _, commitMessage))
+    } yield HookEdit(hook, result.void, maybeHookCommit.toList ++ maybeBlameIgnoreCommit.toList)
+
+  private def addToGitBlameIgnoreRevs(
+      repo: Repo,
+      repoDir: File,
+      hook: PostUpdateHook,
+      commit: Commit,
+      commitMsg: CommitMsg
+  ): F[Option[Commit]] =
+    if (hook.addToGitBlameIgnoreRevs) {
+      for {
+        _ <- F.unit
+        file = repoDir / gitBlameIgnoreRevsName
+        newLines = s"# Scala Steward: ${commitMsg.title}\n${commit.sha1.value.value}\n"
+        oldContent <- fileAlg.readFile(file)
+        newContent = oldContent.fold(newLines)(_ + "\n" + newLines)
+        _ <- fileAlg.writeFile(file, newContent)
+        _ <- gitAlg.add(repo, file.pathAsString)
+        blameIgnoreCommitMsg = CommitMsg(s"Add '${commitMsg.title}' to $gitBlameIgnoreRevsName")
+        maybeBlameIgnoreCommit <- gitAlg.commitAllIfDirty(repo, blameIgnoreCommitMsg)
+      } yield maybeBlameIgnoreCommit
+    } else F.pure(None)
 }
 
 object HookExecutor {
@@ -105,7 +130,8 @@ object HookExecutor {
       useSandbox = true,
       commitMessage = _ => CommitMsg("Regenerate workflow with sbt-github-actions"),
       enabledByCache = enabledByCache,
-      enabledByConfig = _ => true
+      enabledByConfig = _ => true,
+      addToGitBlameIgnoreRevs = false
     )
 
   private val scalafmtHook =
@@ -116,7 +142,8 @@ object HookExecutor {
       useSandbox = false,
       commitMessage = update => CommitMsg(s"Reformat with scalafmt ${update.nextVersion}"),
       enabledByCache = _ => true,
-      enabledByConfig = _.scalafmt.runAfterUpgradingOrDefault
+      enabledByConfig = _.scalafmt.runAfterUpgradingOrDefault,
+      addToGitBlameIgnoreRevs = true
     )
 
   private val sbtJavaFormatterHook =
@@ -128,7 +155,8 @@ object HookExecutor {
       commitMessage =
         update => CommitMsg(s"Reformat with sbt-java-formatter ${update.nextVersion}"),
       enabledByCache = _ => true,
-      enabledByConfig = _ => true
+      enabledByConfig = _ => true,
+      addToGitBlameIgnoreRevs = true
     )
 
   private def sbtTypelevelHook(
@@ -142,7 +170,8 @@ object HookExecutor {
       useSandbox = true,
       commitMessage = _ => CommitMsg("Run prePR with sbt-typelevel"),
       enabledByCache = _ => true,
-      enabledByConfig = _ => true
+      enabledByConfig = _ => true,
+      addToGitBlameIgnoreRevs = false
     )
 
   private val postUpdateHooks: List[PostUpdateHook] =
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 ce1c81fc64..439ce8d489 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
@@ -16,6 +16,7 @@
 
 package org.scalasteward.core.edit.hooks
 
+import cats.implicits._
 import org.scalasteward.core.data.{ArtifactId, GroupId, Update}
 import org.scalasteward.core.git.CommitMsg
 import org.scalasteward.core.repocache.RepoCache
@@ -29,5 +30,8 @@ final case class PostUpdateHook(
     useSandbox: Boolean,
     commitMessage: Update => CommitMsg,
     enabledByCache: RepoCache => Boolean,
-    enabledByConfig: RepoConfig => Boolean
-)
+    enabledByConfig: RepoConfig => Boolean,
+    addToGitBlameIgnoreRevs: Boolean
+) {
+  def showCommand: String = command.mkString_("'", " ", "'")
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala
index 3411d8285e..99770d906b 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/FileGitAlg.scala
@@ -31,6 +31,9 @@ final class FileGitAlg[F[_]](config: GitCfg)(implicit
     workspaceAlg: WorkspaceAlg[F],
     F: MonadCancelThrow[F]
 ) extends GenGitAlg[F, File] {
+  override def add(repo: File, file: String): F[Unit] =
+    git("add", file)(repo).void
+
   override def branchAuthors(repo: File, branch: Branch, base: Branch): F[List[String]] =
     git("log", "--pretty=format:'%an'", dotdot(base, branch))(repo).map(_.distinct)
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala
index 1f530d2fe6..b71d57fb9c 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/git/GenGitAlg.scala
@@ -24,6 +24,8 @@ import org.scalasteward.core.application.Config.GitCfg
 import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
 
 trait GenGitAlg[F[_], Repo] {
+  def add(repo: Repo, file: String): F[Unit]
+
   def branchAuthors(repo: Repo, branch: Branch, base: Branch): F[List[String]]
 
   def branchExists(repo: Repo, branch: Branch): F[Boolean]
@@ -88,6 +90,9 @@ trait GenGitAlg[F[_], 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 add(repo: A, file: String): F[Unit] =
+        f(repo).flatMap(self.add(_, file))
+
       override def branchAuthors(repo: A, branch: Branch, base: Branch): F[List[String]] =
         f(repo).flatMap(self.branchAuthors(_, branch, base))
 
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 1c9876e355..229b57ad16 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
@@ -25,6 +25,8 @@ import org.scalasteward.core.vcs.data.Repo
 package object git {
   type GitAlg[F[_]] = GenGitAlg[F, Repo]
 
+  val gitBlameIgnoreRevsName = ".git-blame-ignore-revs"
+
   val updateBranchPrefix = "update"
 
   def branchFor(update: Update, baseBranch: Option[Branch]): Branch = {
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 b1abd57ca8..5307c74209 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
@@ -153,7 +153,7 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
       val createBranch = logger.info(s"Create branch ${data.updateBranch.name}") >>
         gitAlg.createBranch(data.repo, data.updateBranch)
       editAlg.applyUpdate(data.repoData, data.update, createBranch).flatMap { edits =>
-        val editCommits = edits.flatMap(_.maybeCommit)
+        val editCommits = edits.flatMap(_.commits)
         if (editCommits.isEmpty) logger.warn("No commits created").as(Ignored)
         else
           gitAlg.branchesDiffer(data.repo, data.baseBranch, data.updateBranch).flatMap {
@@ -250,7 +250,7 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
       maybeRevertCommit <- gitAlg.revertChanges(data.repo, data.baseBranch)
       maybeMergeCommit <- gitAlg.mergeTheirs(data.repo, data.baseBranch)
       edits <- editAlg.applyUpdate(data.repoData, data.update)
-      editCommits = edits.flatMap(_.maybeCommit)
+      editCommits = edits.flatMap(_.commits)
       commits = maybeRevertCommit.toList ++ maybeMergeCommit.toList ++ editCommits
       result <- pushCommits(data, commits)
     } yield result
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PostUpdateHookConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PostUpdateHookConfig.scala
index 0bc582f79f..5b42280cca 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PostUpdateHookConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PostUpdateHookConfig.scala
@@ -28,7 +28,8 @@ final case class PostUpdateHookConfig(
     groupId: Option[GroupId],
     artifactId: Option[String],
     command: Nel[String],
-    commitMessage: String
+    commitMessage: String,
+    addToGitBlameIgnoreRevs: Option[Boolean] = None
 ) {
   def toHook: PostUpdateHook =
     PostUpdateHook(
@@ -38,7 +39,8 @@ final case class PostUpdateHookConfig(
       useSandbox = true,
       commitMessage = _ => CommitMsg(commitMessage),
       enabledByCache = _ => true,
-      enabledByConfig = _ => true
+      enabledByConfig = _ => true,
+      addToGitBlameIgnoreRevs = addToGitBlameIgnoreRevs.getOrElse(false)
     )
 }
 
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 1b67a31347..bc5693a6ae 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
@@ -221,7 +221,7 @@ object NewPullRequestData {
       edits: List[EditAttempt],
       filesWithOldVersion: List[String]
   ): List[String] = {
-    val commitCount = edits.flatMap(_.maybeCommit).size
+    val commitCount = edits.flatMap(_.commits).size
     val commitCountLabel = "commit-count:" + (commitCount match {
       case n if n <= 1 => s"$n"
       case n           => s"n:$n"


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 9404bd87f3..30218ce9e6 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
@@ -5,7 +5,8 @@ import munit.CatsEffectSuite
 import org.scalasteward.core.TestInstances.{dummyRepoCache, dummySha1}
 import org.scalasteward.core.TestSyntax._
 import org.scalasteward.core.data.RepoData
-import org.scalasteward.core.git.FileGitAlg
+import org.scalasteward.core.git.{gitBlameIgnoreRevsName, FileGitAlg}
+import org.scalasteward.core.io.FileAlgTest
 import org.scalasteward.core.mock.MockConfig.gitCmd
 import org.scalasteward.core.mock.MockContext.context.{hookExecutor, workspaceAlg}
 import org.scalasteward.core.mock.MockState
@@ -38,7 +39,9 @@ class HookExecutorTest extends CatsEffectSuite {
           List(dummySha1.value.value)
       )
     )
-    val state = hookExecutor.execPostUpdateHooks(data, update).runS(initial)
+    val gitBlameIgnoreRevs = repoDir / gitBlameIgnoreRevsName
+    val state = FileAlgTest.ioFileAlg.deleteForce(gitBlameIgnoreRevs) >>
+      hookExecutor.execPostUpdateHooks(data, update).runS(initial)
 
     val expected = initial.copy(
       trace = Vector(
@@ -63,7 +66,29 @@ class HookExecutorTest extends CatsEffectSuite {
           "-m",
           "Reformat with scalafmt 2.7.5"
         ),
+        Cmd(gitCmd(repoDir), "rev-parse", "--verify", "HEAD"),
+        Cmd("read", gitBlameIgnoreRevs.pathAsString),
+        Cmd("write", gitBlameIgnoreRevs.pathAsString),
+        Cmd(gitCmd(repoDir), "add", gitBlameIgnoreRevs.pathAsString),
+        Cmd(
+          gitCmd(repoDir),
+          "status",
+          "--porcelain",
+          "--untracked-files=no",
+          "--ignore-submodules"
+        ),
+        Cmd(
+          gitCmd(repoDir),
+          "commit",
+          "--all",
+          "--no-gpg-sign",
+          "-m",
+          s"Add 'Reformat with scalafmt 2.7.5' to $gitBlameIgnoreRevsName"
+        ),
         Cmd(gitCmd(repoDir), "rev-parse", "--verify", "HEAD")
+      ),
+      files = Map(
+        gitBlameIgnoreRevs -> s"# Scala Steward: Reformat with scalafmt 2.7.5\n${dummySha1.value.value}\n"
       )
     )