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

Issue:
Creating `.git-blame-ignore-revs` can error the Scala Steward process if that file is covered by `gitignore`
The Guardian's [Scala Steward GitHub Action](https://github.com/guardian/scala-steward-public-repos/actions/workflows/public-repos-scala-steward.yml) (covering all the public repos in our GitHub org) job [started erroring](https://github.com/guardian/support-service-lambdas/pull/1834), and the culprit turned out to be the `git add .git-blame-ignore-revs` added with https://github.com/scala-steward-org/scala-steward/pull/2652 :

```
2023-01-19 10:02:07,438 ERROR Steward guardian/support-service-lambdas failed
  java.io.IOException: 'GIT_ASKPASS=/home/runner/scala-steward/askpass.sh "SBT_OPTS=-Xmx2048m -Xss8m -XX:MaxMetaspaceSize=512m" git -c core.hooksPath=/dev/null add /home/runner/scala-steward/workspace/repos/guardian/support-service-lambdas/.git-blame-ignore-revs' exited with code 1
  The following paths are ignored by one of your .gitignore files:
  .git-blame-ignore-revs
  hint: Use -f if you really want to add them.
  hint: Turn this message off by running
  hint: "git config advice.addIgnoredFile false"
```

It turns out we had [quite broad](https://github.com/guardian/support-service-lambdas/blob/4952dab4c35793b620e82b6f338244628e071962/.gitignore#L1) gitignore configuration in the repo (ignoring all files starting with `.` by default). The file `.git-blame-ignore-revs` starts with a `.`, and `git add` doesn't like adding files that are gitignored.

The gitignore configuration in the troublesome repo is quite easily fixed once the problem's identified, but it took a bit of digging to understand what the issue was, and so I was wondering if there was a possible improvement to Scala Stewards behaviour here? Some possible options:

* **Force `git add`** : Add the `-f` flag to the `git add` command, so Scala Steward just overrides the gitignore configuration, goes ahead and creates the file. https://github.com/scala-steward-org/scala-steward/blob/02acd65ff3243b80254eb44c690eb07ea93ef238/modules/core/src/main/scala/org/scalasteward/core/edit/hooks/HookExecutor.scala#L89
* **Informative warning** : Somehow test if `.git-blame-ignore-revs` can be added, and if not, just provide a friendly warning, rather than having the entire job (which, for a GitHub action, can cover a very large number of repos) being marked as a 'Failure'. ![image](https://user-images.githubusercontent.com/52038/214324817-9f108c2b-408b-4de8-bf7d-fe6effc4aae7.png)




I'm not sure which approach would be considered best, but I could have a go at a PR for **Force `git add`**, if that would be helpful.

Pull request description:
use git check-ignore before adding a file to the stage in the HookExecutor.

Closes #2942

Patch:
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 b7af5f90c0..f34c581c67 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
@@ -95,9 +95,21 @@ final class HookExecutor[F[_]](implicit
         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)
+        pathAsString = file.pathAsString
+
+        addAndCommit = gitAlg.add(repo, pathAsString).flatMap { _ =>
+          val blameIgnoreCommitMsg =
+            CommitMsg(s"Add '${commitMsg.title}' to $gitBlameIgnoreRevsName")
+          gitAlg.commitAllIfDirty(repo, blameIgnoreCommitMsg)
+        }
+        maybeBlameIgnoreCommit <- gitAlg
+          .checkIgnore(repo, pathAsString)
+          .ifM(
+            logger
+              .warn(s"Impossible to add '$pathAsString' because it is git ignored.")
+              .as(Option.empty[Commit]),
+            addAndCommit
+          )
       } yield maybeBlameIgnoreCommit
     } else F.pure(None)
 }
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 e2d8f50239..5e2e8ff98b 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
@@ -22,7 +22,7 @@ import cats.syntax.all._
 import org.http4s.Uri
 import org.scalasteward.core.application.Config.GitCfg
 import org.scalasteward.core.git.FileGitAlg.{dotdot, gitCmd}
-import org.scalasteward.core.io.process.SlurpOptions
+import org.scalasteward.core.io.process.{ProcessFailedException, SlurpOptions}
 import org.scalasteward.core.io.{FileAlg, ProcessAlg, WorkspaceAlg}
 import org.scalasteward.core.util.Nel
 
@@ -47,6 +47,11 @@ final class FileGitAlg[F[_]](config: GitCfg)(implicit
   override def checkoutBranch(repo: File, branch: Branch): F[Unit] =
     git_("checkout", branch.name)(repo).void
 
+  override def checkIgnore(repo: File, file: String): F[Boolean] =
+    git_("check-ignore", file)(repo)
+      .as(true)
+      .recover { case ex: ProcessFailedException if ex.exitValue === 1 => false }
+
   override def clone(repo: File, url: Uri): F[Unit] =
     for {
       rootDir <- workspaceAlg.rootDir
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 c457df1e13..22616c7140 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
@@ -34,6 +34,8 @@ trait GenGitAlg[F[_], Repo] {
 
   def checkoutBranch(repo: Repo, branch: Branch): F[Unit]
 
+  def checkIgnore(repo: Repo, file: String): F[Boolean]
+
   def clone(repo: Repo, url: Uri): F[Unit]
 
   def cloneExists(repo: Repo): F[Boolean]
@@ -105,6 +107,9 @@ trait GenGitAlg[F[_], Repo] {
       override def checkoutBranch(repo: A, branch: Branch): F[Unit] =
         f(repo).flatMap(self.checkoutBranch(_, branch))
 
+      override def checkIgnore(repo: A, file: String): F[Boolean] =
+        f(repo).flatMap(self.checkIgnore(_, file))
+
       override def clone(repo: A, url: Uri): F[Unit] =
         f(repo).flatMap(self.clone(_, url))
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/io/process.scala b/modules/core/src/main/scala/org/scalasteward/core/io/process.scala
index 2c9b8c106e..7c108fad8e 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/io/process.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/io/process.scala
@@ -148,7 +148,7 @@ object process {
   final class ProcessFailedException(
       args: Args,
       buffer: ListBuffer[String],
-      exitValue: Int
+      val exitValue: Int
   ) extends IOException(makeMessage(args, buffer)(s"exited with code $exitValue."))
 
   final class ProcessTimedOutException(


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillAlgTest.scala
index aa08dcef0c..6cd4e83b35 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/buildtool/mill/MillAlgTest.scala
@@ -28,7 +28,8 @@ class MillAlgTest extends FunSuite {
       "show",
       extractDeps
     )
-    val initial = MockState.empty.copy(commandOutputs = Map(millCmd -> List("""{"modules":[]}""")))
+    val initial =
+      MockState.empty.copy(commandOutputs = Map(millCmd -> Right(List("""{"modules":[]}"""))))
     val state = millAlg.getDependencies(buildRoot).runS(initial).unsafeRunSync()
     val expected = initial.copy(
       trace = Vector(
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 100172a92b..dccde4a2f9 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
@@ -1,5 +1,6 @@
 package org.scalasteward.core.edit.hooks
 
+import cats.data.NonEmptyList
 import cats.syntax.all._
 import munit.CatsEffectSuite
 import org.scalasteward.core.TestInstances.{dummyRepoCache, dummySha1}
@@ -15,6 +16,9 @@ import org.scalasteward.core.repoconfig.{PostUpdateHookConfig, RepoConfig, Scala
 import org.scalasteward.core.scalafmt.ScalafmtAlg.opts
 import org.scalasteward.core.scalafmt.{scalafmtArtifactId, scalafmtBinary, scalafmtGroupId}
 import org.scalasteward.core.util.Nel
+import org.scalasteward.core.io.process.ProcessFailedException
+import org.scalasteward.core.io.process
+import scala.collection.mutable.ListBuffer
 
 class HookExecutorTest extends CatsEffectSuite {
   private val repo = Repo("scala-steward-org", "scala-steward")
@@ -27,23 +31,51 @@ class HookExecutorTest extends CatsEffectSuite {
     state.map(assertEquals(_, MockState.empty))
   }
 
-  test("scalafmt: enabled by config") {
-    val update = (scalafmtGroupId % scalafmtArtifactId % "2.7.4" %> "2.7.5").single
-    val initial = MockState.empty.copy(commandOutputs =
-      Map(
-        FileGitAlg.gitCmd.toList ++
-          List("status", "--porcelain", "--untracked-files=no", "--ignore-submodules") ->
-          List("build.sbt"),
-        FileGitAlg.gitCmd.toList ++ List("rev-parse", "--verify", "HEAD") ->
-          List(dummySha1.value.value)
+  Seq(true, false).foreach { case blameRevIgnored =>
+    val isIgnored = if (blameRevIgnored) "ignored" else "not ignored"
+
+    test(s"scalafmt: enabled by config / $gitBlameIgnoreRevsName $isIgnored") {
+      val gitBlameIgnoreRevs = repoDir / gitBlameIgnoreRevsName
+      val update = (scalafmtGroupId % scalafmtArtifactId % "2.7.4" %> "2.7.5").single
+      val initial = MockState.empty.copy(commandOutputs =
+        Map(
+          FileGitAlg.gitCmd.toList ++
+            List("status", "--porcelain", "--untracked-files=no", "--ignore-submodules") ->
+            Right(List("build.sbt")),
+          FileGitAlg.gitCmd.toList ++ List("rev-parse", "--verify", "HEAD") ->
+            Right(List(dummySha1.value.value)),
+          FileGitAlg.gitCmd.toList ++ List("check-ignore", gitBlameIgnoreRevs.pathAsString) ->
+            (if (blameRevIgnored) Right(List.empty) else Left(dummyProcessError))
+        )
       )
-    )
-    val gitBlameIgnoreRevs = repoDir / gitBlameIgnoreRevsName
-    val state = FileAlgTest.ioFileAlg.deleteForce(gitBlameIgnoreRevs) >>
-      hookExecutor.execPostUpdateHooks(data, update).runS(initial)
+      val state = FileAlgTest.ioFileAlg.deleteForce(gitBlameIgnoreRevs) >>
+        hookExecutor.execPostUpdateHooks(data, update).runS(initial)
 
-    val expected = initial.copy(
-      trace = Vector(
+      val logIfIgnored =
+        if (blameRevIgnored)
+          Vector(Log(s"Impossible to add '$gitBlameIgnoreRevs' because it is git ignored."))
+        else
+          Vector(
+            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")
+          )
+
+      val traces = Vector(
         Log(
           "Executing post-update hook for org.scalameta:scalafmt-core with command 'scalafmt --non-interactive'"
         ),
@@ -70,30 +102,19 @@ class HookExecutorTest extends CatsEffectSuite {
         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"
+        Cmd(gitCmd(repoDir), "check-ignore", gitBlameIgnoreRevs.pathAsString)
+      ) ++
+        logIfIgnored ++ Vector(
+        )
+      val expected = initial.copy(
+        trace = traces,
+        files = Map(
+          gitBlameIgnoreRevs -> s"# Scala Steward: Reformat with scalafmt 2.7.5\n${dummySha1.value.value}\n"
+        )
       )
-    )
 
-    state.map(assertEquals(_, expected))
+      state.map(assertEquals(_, expected))
+    }
   }
 
   test("scalafmt: disabled by config") {
@@ -166,4 +187,7 @@ class HookExecutorTest extends CatsEffectSuite {
 
     state.map(assertEquals(_, expected))
   }
+
+  private val dummyProcessError =
+    new ProcessFailedException(process.Args(NonEmptyList.of("cmd")), ListBuffer.empty, 1)
 }
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
index 068b5f90ae..07537bb03b 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala
@@ -16,6 +16,32 @@ import org.scalasteward.core.util.Nel
 class FileGitAlgTest extends CatsEffectSuite {
   private val rootDir = mockRoot / "git-tests"
 
+  test("add with .gitignore") {
+    val repo = rootDir / "branchgitignpreAuthors"
+    for {
+      _ <- ioAuxGitAlg.createRepo(repo)
+
+      gitIgnoreFile = repo / ".gitignore"
+      _ <- ioFileAlg.writeFile(gitIgnoreFile, "ignored.txt")
+      _ <- ioAuxGitAlg.addFiles(repo, gitIgnoreFile)
+
+      ignoredFile = repo / "ignored.txt"
+      _ <- ioFileAlg.writeFile(ignoredFile, "irrelevant")
+      ignoredFileCheck <- ioGitAlg.checkIgnore(repo, ignoredFile.pathAsString)
+
+      notIgnoredFile = repo / "not-ignored.txt"
+      _ <- ioFileAlg.writeFile(notIgnoredFile, "irrelevant")
+      notIgnoredFileCheck <- ioGitAlg.checkIgnore(repo, notIgnoredFile.pathAsString)
+
+    } yield {
+      assert(ignoredFileCheck, "The file is in .gitignore, checkIgnore should return true.")
+      assert(
+        !notIgnoredFileCheck,
+        "The file is not in .gitignore, checkIgnore should return false."
+      )
+    }
+  }
+
   test("branchAuthors") {
     val repo = rootDir / "branchAuthors"
     for {
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 13af653396..388db23707 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,19 +1,26 @@
 package org.scalasteward.core.io
 
 import cats.data.Kleisli
+import cats.effect.IO
 import org.scalasteward.core.application.Config.ProcessCfg
 import org.scalasteward.core.mock.MockEff
 
 object MockProcessAlg {
   def create(config: ProcessCfg): ProcessAlg[MockEff] =
     new ProcessAlg(config)({ args =>
-      Kleisli {
-        _.modify { s =>
-          val cmd = args.workingDirectory.map(_.toString).toList ++ args.command.toList
-          val s1 = s.exec(cmd, args.extraEnv: _*)
-          val a = s.commandOutputs.getOrElse(args.command.toList, List.empty)
-          (s1, a)
-        }
+      Kleisli { x =>
+        for {
+          state <- x.get
+          cmd = args.workingDirectory.map(_.toString).toList ++ args.command.toList
+          newState = state.exec(cmd, args.extraEnv: _*)
+          res <- x
+            .set(newState)
+            .flatMap { _ =>
+              state.commandOutputs
+                .getOrElse(args.command.toList, Right(List.empty))
+                .fold(err => IO.raiseError(err), a => IO.pure(a))
+            }
+        } yield res
       }
     })
 }
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 d3bcf60f62..61f2c89db5 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
@@ -10,7 +10,7 @@ import org.scalasteward.core.mock.MockState.TraceEntry.{Cmd, Log}
 
 final case class MockState(
     trace: Vector[TraceEntry],
-    commandOutputs: Map[List[String], List[String]],
+    commandOutputs: Map[List[String], Either[Throwable, List[String]]],
     files: Map[File, String],
     uris: Map[Uri, String],
     clientResponses: HttpApp[MockEff]