Repository: GradleUp/shadow
Pull request #1277 (2025-02-25 08:03:18)

Fix the last modified time of shadowed directories
---

- [x] [CHANGELOG](https://github.com/GradleUp/shadow/blob/main/src/docs/changes/README.md)'s "Unreleased" section has been updated, if applicable.



Patch:
diff --git a/lint-baseline.xml b/lint-baseline.xml
index ec96ac13..eca3ea72 100644
--- a/lint-baseline.xml
+++ b/lint-baseline.xml
@@ -41,7 +41,7 @@
         errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
             file="src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt"
-            line="17"
+            line="18"
             column="1"/>
     </issue>
 
@@ -52,7 +52,7 @@
         errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
             file="src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt"
-            line="18"
+            line="19"
             column="1"/>
     </issue>
 
@@ -63,7 +63,7 @@
         errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
             file="src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt"
-            line="19"
+            line="20"
             column="1"/>
     </issue>
 
@@ -74,7 +74,7 @@
         errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
             file="src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt"
-            line="20"
+            line="21"
             column="1"/>
     </issue>
 
@@ -85,7 +85,7 @@
         errorLine2="~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~">
         <location
             file="src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt"
-            line="21"
+            line="22"
             column="1"/>
     </issue>
 
diff --git a/src/docs/changes/README.md b/src/docs/changes/README.md
index cde8a512..68ee373d 100644
--- a/src/docs/changes/README.md
+++ b/src/docs/changes/README.md
@@ -3,6 +3,10 @@
 
 ## [Unreleased]
 
+**Fixed**
+
+- Fix the last modified time of shadowed directories. ([#1277](https://github.com/GradleUp/shadow/pull/1277))
+
 
 ## [v9.0.0-beta9] (2025-02-24)
 
diff --git a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt
index e4f22324..75c649e9 100644
--- a/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt
+++ b/src/main/kotlin/com/github/jengelman/gradle/plugins/shadow/tasks/ShadowCopyAction.kt
@@ -87,14 +87,15 @@ public open class ShadowCopyAction(
     val entries = zos::class.java.getDeclaredField("entries").apply { isAccessible = true }
       .get(zos).cast<List<ZipEntry>>().map { it.name }
     val added = entries.toMutableSet()
+    val currentTimeMillis = System.currentTimeMillis()
 
     fun addParent(name: String) {
       val parent = name.substringBeforeLast('/', "")
       val entryName = "$parent/"
       if (parent.isNotEmpty() && added.add(entryName)) {
-        val details = visitedDirs[entryName]
+        val details = visitedDirs[parent]
         val (lastModified, flag) = if (details == null) {
-          CONSTANT_TIME_FOR_ZIP_ENTRIES to UnixStat.DEFAULT_DIR_PERM
+          currentTimeMillis to UnixStat.DEFAULT_DIR_PERM
         } else {
           details.lastModified to details.permissions.toUnixNumeric()
         }
@@ -226,6 +227,12 @@ public open class ShadowCopyAction(
 
   public companion object {
     private val logger = Logging.getLogger(ShadowCopyAction::class.java)
+
+    /**
+     * A copy of [org.gradle.api.internal.file.archive.ZipEntryConstants.CONSTANT_TIME_FOR_ZIP_ENTRIES].
+     *
+     * 1980-02-01 00:00:00 (318182400000).
+     */
     public val CONSTANT_TIME_FOR_ZIP_ENTRIES: Long = GregorianCalendar(1980, 1, 1, 0, 0, 0).timeInMillis
   }
 }


Test patch:
diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/BasePluginTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/BasePluginTest.kt
index 23b5c667..a2cadc5a 100644
--- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/BasePluginTest.kt
+++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/BasePluginTest.kt
@@ -16,6 +16,7 @@ import com.github.jengelman.gradle.plugins.shadow.util.JarPath
 import java.io.Closeable
 import java.nio.file.Path
 import java.util.Properties
+import java.util.jar.JarEntry
 import kotlin.io.path.ExperimentalPathApi
 import kotlin.io.path.Path
 import kotlin.io.path.absolutePathString
@@ -348,11 +349,10 @@ abstract class BasePluginTest {
     }
 
     val junitJar: Path = requireResourceAsPath("junit-3.8.2.jar")
-    val junitEntries: Array<String> = JarPath(junitJar)
+    val junitRawEntries: List<JarEntry> = JarPath(junitJar)
       .use { it.entries().toList() }
-      .map { entry -> entry.name }
-      .filterNot { it == "junit3.8.2/" || it.startsWith("META-INF/") }
-      .toTypedArray()
+      .filterNot { it.name == "junit3.8.2/" || it.name.startsWith("META-INF/") }
+    val junitEntries: Array<String> = junitRawEntries.map { it.name }.toTypedArray()
 
     val shadowJar: String = """
       tasks.named('$SHADOW_JAR_TASK_NAME', ${ShadowJar::class.java.name})
diff --git a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt
index af1f6d2d..b56da600 100644
--- a/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt
+++ b/src/functionalTest/kotlin/com/github/jengelman/gradle/plugins/shadow/RelocationTest.kt
@@ -8,12 +8,14 @@ import assertk.assertions.isInstanceOf
 import assertk.assertions.isNotEmpty
 import assertk.fail
 import com.github.jengelman.gradle.plugins.shadow.ShadowJavaPlugin.Companion.SHADOW_JAR_TASK_NAME
+import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowCopyAction.Companion.CONSTANT_TIME_FOR_ZIP_ENTRIES
 import com.github.jengelman.gradle.plugins.shadow.util.Issue
 import com.github.jengelman.gradle.plugins.shadow.util.containsEntries
 import com.github.jengelman.gradle.plugins.shadow.util.doesNotContainEntries
 import java.net.URLClassLoader
 import kotlin.io.path.appendText
 import kotlin.io.path.writeText
+import kotlin.time.Duration.Companion.seconds
 import org.junit.jupiter.api.Test
 import org.junit.jupiter.params.ParameterizedTest
 import org.junit.jupiter.params.provider.Arguments
@@ -347,6 +349,89 @@ class RelocationTest : BasePluginTest() {
     assertThat(excluded).isEmpty()
   }
 
+  @ParameterizedTest
+  @MethodSource("preserveLastModifiedProvider")
+  fun preserveLastModifiedCorrectly(enableRelocation: Boolean, preserveFileTimestamps: Boolean) {
+    // Minus 3 sec to avoid the time difference between the file system and the JVM.
+    val currentTimeMillis = System.currentTimeMillis() - 3.seconds.inWholeMilliseconds
+    val junitEntryTimeRange = junitRawEntries.map { it.time }.let { it.min()..it.max() }
+    writeMainClass(withImports = true)
+    projectScriptPath.appendText(
+      """
+        dependencies {
+          implementation 'junit:junit:3.8.2'
+        }
+        $shadowJar {
+          enableRelocation = $enableRelocation
+          preserveFileTimestamps = $preserveFileTimestamps
+        }
+      """.trimIndent(),
+    )
+
+    run(shadowJarTask)
+
+    if (enableRelocation) {
+      val (relocatedEntries, otherEntries) = outputShadowJar.use {
+        it.entries().toList().partition { entry -> entry.name.startsWith("shadow/") }
+      }
+      assertThat(relocatedEntries).isNotEmpty()
+      assertThat(otherEntries).isNotEmpty()
+      val (relocatedDirs, relocatedClasses) = relocatedEntries.partition { it.isDirectory }
+      assertThat(relocatedDirs).isNotEmpty()
+      assertThat(relocatedClasses).isNotEmpty()
+
+      if (preserveFileTimestamps) {
+        relocatedClasses.forEach { entry ->
+          // Relocated files should preserve the last modified time of the original files.
+          if (entry.time !in junitEntryTimeRange) {
+            fail("Relocated file ${entry.name} has an invalid last modified time: ${entry.time}")
+          }
+        }
+        (relocatedDirs + otherEntries).forEach { entry ->
+          // Relocated directories and other entries are newly created, so they should be in now time.
+          if (entry.time < currentTimeMillis) {
+            fail("Relocated directory ${entry.name} has an invalid last modified time: ${entry.time}")
+          }
+        }
+      } else {
+        (relocatedEntries + otherEntries).forEach { entry ->
+          // All entries should be newly modified, that default to CONSTANT_TIME_FOR_ZIP_ENTRIES.
+          if (entry.time != CONSTANT_TIME_FOR_ZIP_ENTRIES) {
+            fail("Entry ${entry.name} has an invalid last modified time: ${entry.time}")
+          }
+        }
+      }
+    } else {
+      val (shadowedEntries, otherEntries) = outputShadowJar.use {
+        it.entries().toList().partition { entry -> entry.name.startsWith("junit/") }
+      }
+      assertThat(shadowedEntries).isNotEmpty()
+      assertThat(otherEntries).isNotEmpty()
+
+      if (preserveFileTimestamps) {
+        shadowedEntries.forEach { entry ->
+          // Shadowed entries should preserve the last modified time of the original entries.
+          if (entry.time !in junitEntryTimeRange) {
+            fail("Shadowed entry ${entry.name} has an invalid last modified time: ${entry.time}")
+          }
+        }
+        otherEntries.forEach { entry ->
+          // Other entries are newly created, so they should be in now time.
+          if (entry.time < currentTimeMillis) {
+            fail("Entry ${entry.name} has an invalid last modified time: ${entry.time}")
+          }
+        }
+      } else {
+        (shadowedEntries + otherEntries).forEach { entry ->
+          // All entries should be newly modified, defaults to CONSTANT_TIME_FOR_ZIP_ENTRIES.
+          if (entry.time != CONSTANT_TIME_FOR_ZIP_ENTRIES) {
+            fail("Entry ${entry.name} has an invalid last modified time: ${entry.time}")
+          }
+        }
+      }
+    }
+  }
+
   private companion object {
     @JvmStatic
     fun prefixProvider() = listOf(
@@ -355,5 +440,13 @@ class RelocationTest : BasePluginTest() {
       Arguments.of("new.pkg"),
       Arguments.of("new/path"),
     )
+
+    @JvmStatic
+    fun preserveLastModifiedProvider() = listOf(
+      Arguments.of(false, false),
+      Arguments.of(true, false),
+      Arguments.of(false, true),
+      Arguments.of(true, true),
+    )
   }
 }