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

Issue:
Log a warning at start-up if the workspace is empty
See https://github.com/scala-steward-org/scala-steward/pull/2886#issuecomment-1372712263.

Pull request:
See https://github.com/scala-steward-org/scala-steward/pull/2886#issuecomment-1372712263.

Fixes #2889

Patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/SelfCheckAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/application/SelfCheckAlg.scala
index 11e2050b97..7d7b11a755 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/SelfCheckAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/SelfCheckAlg.scala
@@ -21,12 +21,14 @@ import cats.syntax.all._
 import org.scalasteward.core.edit.scalafix.ScalafixCli
 import org.scalasteward.core.edit.scalafix.ScalafixCli.scalafixBinary
 import org.scalasteward.core.git.GitAlg
+import org.scalasteward.core.io.FileAlg
 import org.scalasteward.core.scalafmt.{scalafmtBinary, ScalafmtAlg}
 import org.scalasteward.core.util.UrlChecker
 import org.scalasteward.core.util.logger.LoggerOps
 import org.typelevel.log4cats.Logger
 
 final class SelfCheckAlg[F[_]](config: Config)(implicit
+    fileAlg: FileAlg[F],
     gitAlg: GitAlg[F],
     logger: Logger[F],
     scalafixCli: ScalafixCli[F],
@@ -37,12 +39,28 @@ final class SelfCheckAlg[F[_]](config: Config)(implicit
   def checkAll: F[Unit] =
     for {
       _ <- logger.info("Run self checks")
+      _ <- checkWorkspaceDirectory
       _ <- checkGitBinary
       _ <- checkScalafixBinary
       _ <- checkScalafmtBinary
       _ <- checkUrlChecker
     } yield ()
 
+  private def checkWorkspaceDirectory: F[Unit] =
+    fileAlg
+      .isNonEmptyDirectory(config.workspace)
+      .ifM(
+        F.unit,
+        logger.warn(
+          s"""
+             |The workspace directory is empty: '${config.workspace}'
+             |This is expected if this is your first Scala Steward run.
+             |Make sure to preserve the workspace between runs for all features to work as expected.
+             |https://github.com/scala-steward-org/scala-steward/blob/main/docs/faq.md#why-doesnt-self-hosted-scala-steward-close-obsolete-prs
+             |""".stripMargin
+        )
+      )
+
   private def checkGitBinary: F[Unit] =
     logger.attemptWarn.log_(execFailedMessage("git")) {
       gitAlg.version.flatMap(output => logger.info(s"Using $output"))
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 fe47f030dd..b07da626cc 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
@@ -34,6 +34,8 @@ trait FileAlg[F[_]] {
 
   def isDirectory(file: File): F[Boolean]
 
+  def isNonEmptyDirectory(dir: File): F[Boolean]
+
   def isRegularFile(file: File): F[Boolean]
 
   def removeTemporarily(file: File): Resource[F, Unit]
@@ -101,6 +103,9 @@ object FileAlg {
       override def isDirectory(file: File): F[Boolean] =
         F.blocking(file.isDirectory(File.LinkOptions.noFollow))
 
+      override def isNonEmptyDirectory(dir: File): F[Boolean] =
+        F.blocking(dir.isDirectory(File.LinkOptions.noFollow) && dir.nonEmpty)
+
       override def isRegularFile(file: File): F[Boolean] =
         F.blocking(file.isRegularFile(File.LinkOptions.noFollow))
 


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/application/StewardAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/application/StewardAlgTest.scala
index 9062175a6f..a1ab6a43da 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/application/StewardAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/application/StewardAlgTest.scala
@@ -8,6 +8,6 @@ import org.scalasteward.core.mock.MockState
 class StewardAlgTest extends CatsEffectSuite {
   test("runF") {
     val exitCode = stewardAlg.runF.runA(MockState.empty)
-    exitCode.map(assertEquals(_, ExitCode.Success))
+    assertIO(exitCode, ExitCode.Success)
   }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/io/FileAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/io/FileAlgTest.scala
index cbfe1998b2..497869f427 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/io/FileAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/io/FileAlgTest.scala
@@ -2,8 +2,7 @@ package org.scalasteward.core.io
 
 import better.files.File
 import cats.effect.IO
-import cats.effect.unsafe.implicits.global
-import munit.FunSuite
+import munit.CatsEffectSuite
 import org.http4s.Uri
 import org.scalacheck.Arbitrary
 import org.scalasteward.core.TestInstances.ioLogger
@@ -13,65 +12,65 @@ import org.scalasteward.core.mock.MockContext.context.fileAlg
 import org.scalasteward.core.mock.MockState
 import org.scalasteward.core.mock.MockState.TraceEntry.Cmd
 
-class FileAlgTest extends FunSuite {
+class FileAlgTest extends CatsEffectSuite {
   test("createTemporarily") {
     val file = mockRoot / "test-scala-steward3.tmp"
     val content = Arbitrary.arbitrary[String].sample.getOrElse("")
 
-    val p = for {
+    val obtained = for {
       before <- ioFileAlg.readFile(file)
       during <- ioFileAlg.createTemporarily(file, content).surround(ioFileAlg.readFile(file))
       after <- ioFileAlg.readFile(file)
     } yield (before, during, after)
 
-    assertEquals(p.unsafeRunSync(), (None, Some(content), None))
+    assertIO(obtained, (None, Some(content), None))
   }
 
   test("writeFile *> readFile <* deleteForce") {
     val file = mockRoot / "test-scala-steward1.tmp"
     val content = Arbitrary.arbitrary[String].sample.getOrElse("")
-    val read = ioFileAlg.writeFile(file, content) *>
+    val obtained = ioFileAlg.writeFile(file, content) *>
       ioFileAlg.readFile(file).map(_.getOrElse("")) <*
       ioFileAlg.deleteForce(file)
-    assertEquals(read.unsafeRunSync(), content)
+    assertIO(obtained, content)
   }
 
   test("removeTemporarily") {
     val file = mockRoot / "test-scala-steward2.tmp"
     val content = Arbitrary.arbitrary[String].sample.getOrElse("")
 
-    val p = for {
+    val obtained = for {
       _ <- ioFileAlg.writeFile(file, content)
       before <- ioFileAlg.readFile(file)
       during <- ioFileAlg.removeTemporarily(file).surround(ioFileAlg.readFile(file))
       after <- ioFileAlg.readFile(file)
     } yield (before, during, after)
 
-    assertEquals(p.unsafeRunSync(), (Some(content), None, Some(content)))
+    assertIO(obtained, (Some(content), None, Some(content)))
   }
 
   test("removeTemporarily: nonexistent file") {
     val file = mockRoot / "does-not-exist.txt"
-    assertEquals(ioFileAlg.removeTemporarily(file).surround(IO.pure(42)).unsafeRunSync(), 42)
+    val obtained = ioFileAlg.removeTemporarily(file).surround(IO.pure(42))
+    assertIO(obtained, 42)
   }
 
   test("editFile: nonexistent file") {
-    val state = fileAlg
+    val obtained = fileAlg
       .editFile(mockRoot / "does-not-exist.txt", identity)
       .runS(MockState.empty)
-      .unsafeRunSync()
 
     val expected =
       MockState.empty.copy(trace = Vector(Cmd("read", s"$mockRoot/does-not-exist.txt")))
-    assertEquals(state, expected)
+    assertIO(obtained, expected)
   }
 
   test("editFile: existent file") {
     val file = mockRoot / "steward" / "test1.sbt"
-    val state = (for {
+    val obtained = (for {
       _ <- fileAlg.writeFile(file, "123")
       _ <- fileAlg.editFile(file, _.replace("2", "4"))
-    } yield ()).runS(MockState.empty).unsafeRunSync()
+    } yield ()).runS(MockState.empty)
 
     val expected = MockState.empty.copy(
       trace = Vector(
@@ -81,7 +80,7 @@ class FileAlgTest extends FunSuite {
       ),
       files = Map(file -> "143")
     )
-    assertEquals(state, expected)
+    assertIO(obtained, expected)
   }
 
   test("deleteForce removes dangling symlink in subdirectory") {
@@ -89,7 +88,7 @@ class FileAlgTest extends FunSuite {
     val sub = dir / "sub"
     val regular = dir / "regular"
     val symlink = sub / "symlink"
-    val p = for {
+    val obtained = for {
       _ <- IO(dir.delete(swallowIOExceptions = true))
       _ <- ioFileAlg.writeFile(regular, "I'm a regular file")
       _ <- IO(sub.createDirectory())
@@ -98,29 +97,45 @@ class FileAlgTest extends FunSuite {
       _ <- ioFileAlg.deleteForce(dir)
       symlinkExists <- IO(symlink.exists(File.LinkOptions.noFollow))
     } yield symlinkExists
-    assertEquals(p.unsafeRunSync(), false)
+    assertIO(obtained, false)
   }
 
   test("readUri: local file without scheme") {
     val file = mockRoot / "steward" / "readUri.txt"
     val content = "42"
-    val p = for {
+    val obtained = for {
       _ <- ioFileAlg.writeFile(file, content)
       read <- ioFileAlg.readUri(Uri.unsafeFromString(file.toString))
     } yield read
-    assertEquals(p.unsafeRunSync(), content)
+    assertIO(obtained, content)
   }
 
   test("isRegularFile") {
     val dir = mockRoot / "steward" / "regular"
     val file = dir / "file.txt"
-    val p = for {
+    val obtained = for {
       _ <- ioFileAlg.deleteForce(dir)
       r1 <- ioFileAlg.isRegularFile(file)
       _ <- ioFileAlg.writeFile(file, "content")
       r2 <- ioFileAlg.isRegularFile(file)
     } yield (r1, r2)
-    assertEquals(p.unsafeRunSync(), (false, true))
+    assertIO(obtained, (false, true))
+  }
+
+  test("isNonEmptyDirectory: empty") {
+    val dir = mockRoot / "workspace2"
+    val obtained = ioFileAlg.isNonEmptyDirectory(dir)
+    assertIO(obtained, false)
+  }
+
+  test("isNonEmptyDirectory: non empty") {
+    val dir = mockRoot / "workspace1"
+    val file = dir / "file.txt"
+    val obtained = for {
+      _ <- ioFileAlg.writeFile(file, "42")
+      read <- ioFileAlg.isNonEmptyDirectory(dir)
+    } yield read
+    assertIO(obtained, true)
   }
 }
 
diff --git a/modules/core/src/test/scala/org/scalasteward/core/io/MockFileAlg.scala b/modules/core/src/test/scala/org/scalasteward/core/io/MockFileAlg.scala
index 091a6cc6f5..f4428c8935 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/io/MockFileAlg.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/io/MockFileAlg.scala
@@ -18,6 +18,12 @@ class MockFileAlg extends FileAlg[MockEff] {
   override def isDirectory(file: File): MockEff[Boolean] =
     Kleisli(_.update(_.exec(List("test", "-d", file.pathAsString))) >> ioFileAlg.isDirectory(file))
 
+  override def isNonEmptyDirectory(dir: File): MockEff[Boolean] =
+    Kleisli(
+      _.update(_.exec(List("find", dir.pathAsString, "-type", "d", "-not", "-empty"))) >> ioFileAlg
+        .isNonEmptyDirectory(dir)
+    )
+
   override def isRegularFile(file: File): MockEff[Boolean] =
     Kleisli {
       _.update(_.exec(List("test", "-f", file.pathAsString))) >> ioFileAlg.isRegularFile(file)