diff --git a/internal/util-core/src/main/scala/sbt/internal/util/Util.scala b/internal/util-core/src/main/scala/sbt/internal/util/Util.scala index e5024f215..7e6d6d5a7 100644 --- a/internal/util-core/src/main/scala/sbt/internal/util/Util.scala +++ b/internal/util-core/src/main/scala/sbt/internal/util/Util.scala @@ -8,12 +8,13 @@ package sbt.internal.util -import java.nio.file.{ Path, Paths } +import java.nio.file.{ Files, Path, Paths } import java.util.Locale import scala.collection.concurrent.TrieMap import scala.reflect.Selectable.reflectiveSelectable import scala.util.Properties +import scala.util.control.NonFatal object Util: def makeList[T](size: Int, value: T): List[T] = List.fill(size)(value) @@ -77,6 +78,10 @@ object Util: lazy val isEmacs: Boolean = sys.env.contains("INSIDE_EMACS") + def isApfs(path: Path): Boolean = + try Files.getFileStore(path).`type`().equalsIgnoreCase("apfs") + catch case NonFatal(_) => false + def nil[A]: List[A] = List.empty[A] def nilSeq[A]: Seq[A] = Seq.empty[A] def none[A]: Option[A] = (None: Option[A]) diff --git a/util-cache/src/main/scala/sbt/util/ActionCacheStore.scala b/util-cache/src/main/scala/sbt/util/ActionCacheStore.scala index 0f7975646..ff0ab8783 100644 --- a/util-cache/src/main/scala/sbt/util/ActionCacheStore.scala +++ b/util-cache/src/main/scala/sbt/util/ActionCacheStore.scala @@ -196,7 +196,9 @@ case class DiskActionCacheStore(base: Path, converter: FileConverter) dir } - private val symlinkSupported: AtomicBoolean = AtomicBoolean(true) + // Files.copy clones on APFS, so a real file costs no extra disk while sparing every later + // directory walk the CAS inode lookup that resolving a symlink pays. + private lazy val symlinkSupported: AtomicBoolean = AtomicBoolean(!Util.isApfs(casBase)) override def storeName: String = "disk" diff --git a/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala b/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala index c0c6fe9e2..8e9165356 100644 --- a/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala +++ b/util-cache/src/test/scala/sbt/util/ActionCacheTest.scala @@ -8,6 +8,7 @@ import java.util.concurrent.{ CyclicBarrier, ExecutorService, Executors, TimeUni import sbt.internal.util.CacheEventLog import sbt.internal.util.StringVirtualFile1 +import sbt.internal.util.Util import sbt.io.IO import sbt.io.syntax.* import verify.BasicTestSuite @@ -140,6 +141,7 @@ object ActionCacheTest extends BasicTestSuite: cache.syncBlobs(refs, outputDirectory) assert((dir / "a.txt").exists, "a.txt not re-extracted after the directory was deleted") assert((dir / "b.txt").exists, "b.txt not re-extracted after the directory was deleted") + assertMaterialized(cache, (dir / "a.txt").toPath) test("Disk cache does not re-extract a dirzip whose archive digest already matches"): withDiskCache: cache => @@ -160,7 +162,7 @@ object ActionCacheTest extends BasicTestSuite: cache.syncBlobs(refs, outputDirectory) assert(IO.read(dir / "a.txt") == "diverged") - test("Disk cache relinks a digest-matching dirzip to the CAS"): + test("Disk cache materializes a digest-matching dirzip from the CAS"): withDiskCache: cache => IO.withTemporaryDirectory: tempDir => val outputDirectory = tempDir.toPath() @@ -176,7 +178,7 @@ object ActionCacheTest extends BasicTestSuite: assert(!Files.isSymbolicLink(zipPath), "packageDirectory should leave a regular file") cache.syncBlobs(refs, outputDirectory) - assert(Files.isSymbolicLink(zipPath), "digest-matching archive was not relinked to the CAS") + assertMaterialized(cache, zipPath) test("Disk cache re-extracts a dirzip whose archive digest differs"): withDiskCache: cache => @@ -874,6 +876,11 @@ object ActionCacheTest extends BasicTestSuite: keepDirectory = false ) + def assertMaterialized(cache: DiskActionCacheStore, p: Path): Unit = + if Util.isApfs(cache.casBase) then + assert(!Files.isSymbolicLink(p), s"$p was symlinked instead of copied") + else assert(Files.isSymbolicLink(p), s"$p was not symlinked into the CAS") + def getCacheConfig( cache: ActionCacheStore, outputDir: File,