diff --git a/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java b/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java
index 08dabc0b..65f15668 100644
--- a/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java
+++ b/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java
@@ -373,7 +373,11 @@ private void restoreDirectoryArtifact(File cachedZip, Artifact artifact, Path re
if (!Files.exists(restorationPath)) {
Files.createDirectories(restorationPath);
}
- CacheUtils.unzip(cachedZip.toPath(), restorationPath, cacheConfig.isPreservePermissions());
+ CacheUtils.unzip(
+ cachedZip.toPath(),
+ restorationPath,
+ cacheConfig.isPreservePermissions(),
+ cacheConfig.isPreserveTimestamps());
LOGGER.debug("Restored directory artifact by unzipping: {} -> {}", artifact.getFileName(), restorationPath);
}
@@ -708,7 +712,12 @@ private void saveDirectoryArtifact(
File originalFile)
throws IOException {
Path tempZip = Files.createTempFile("maven-cache-", "-" + project.getArtifactId() + ".zip");
- boolean hasFiles = CacheUtils.zip(originalFile.toPath(), tempZip, "*", cacheConfig.isPreservePermissions());
+ boolean hasFiles = CacheUtils.zip(
+ originalFile.toPath(),
+ tempZip,
+ "*",
+ cacheConfig.isPreservePermissions(),
+ cacheConfig.isPreserveTimestamps());
if (hasFiles) {
// Temporarily replace artifact file with zip for saving
projectArtifact.setFile(tempZip.toFile());
@@ -1059,7 +1068,8 @@ private boolean zipAndAttachArtifact(MavenProject project, Path dir, String clas
throws IOException {
Path temp = Files.createTempFile("maven-incremental-", project.getArtifactId());
temp.toFile().deleteOnExit();
- boolean hasFile = CacheUtils.zip(dir, temp, glob, cacheConfig.isPreservePermissions());
+ boolean hasFile = CacheUtils.zip(
+ dir, temp, glob, cacheConfig.isPreservePermissions(), cacheConfig.isPreserveTimestamps());
if (hasFile) {
projectHelper.attachArtifact(project, "zip", classifier, temp.toFile());
}
@@ -1074,7 +1084,8 @@ private void restoreGeneratedSources(Artifact artifact, Path artifactFilePath, M
if (!Files.exists(outputDir)) {
Files.createDirectories(outputDir);
}
- CacheUtils.unzip(artifactFilePath, outputDir, cacheConfig.isPreservePermissions());
+ CacheUtils.unzip(
+ artifactFilePath, outputDir, cacheConfig.isPreservePermissions(), cacheConfig.isPreserveTimestamps());
}
// TODO: move to config
diff --git a/src/main/java/org/apache/maven/buildcache/CacheUtils.java b/src/main/java/org/apache/maven/buildcache/CacheUtils.java
index e7bb836a..109d7395 100644
--- a/src/main/java/org/apache/maven/buildcache/CacheUtils.java
+++ b/src/main/java/org/apache/maven/buildcache/CacheUtils.java
@@ -34,8 +34,10 @@
import java.util.Arrays;
import java.util.Collection;
import java.util.Enumeration;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
+import java.util.Map;
import java.util.NoSuchElementException;
import java.util.Set;
import java.util.stream.Stream;
@@ -161,6 +163,11 @@ public static boolean isArchive(File file) {
return Strings.CS.endsWithAny(fileName, ".jar", ".zip", ".war", ".ear");
}
+ public static boolean zip(final Path dir, final Path zip, final String glob, boolean preservePermissions)
+ throws IOException {
+ return zip(dir, zip, glob, preservePermissions, false);
+ }
+
/**
* Put every matching files of a directory in a zip.
* @param dir directory to zip
@@ -172,10 +179,12 @@ public static boolean isArchive(File file) {
* the ZIP file (e.g., for cache keys) will include permission information, ensuring
* cache invalidation when file permissions change. This behavior is similar to how Git
* includes file mode in tree hashes.
+ * @param preserveTimestamps whether to preserve file and directory timestamps in the zip
* @return true if at least one file has been included in the zip.
* @throws IOException
*/
- public static boolean zip(final Path dir, final Path zip, final String glob, boolean preservePermissions)
+ public static boolean zip(
+ final Path dir, final Path zip, final String glob, boolean preservePermissions, boolean preserveTimestamps)
throws IOException {
final MutableBoolean hasFiles = new MutableBoolean();
// Check once if filesystem supports POSIX permissions instead of catching exceptions for every file
@@ -186,16 +195,45 @@ public static boolean zip(final Path dir, final Path zip, final String glob, boo
PathMatcher matcher =
"*".equals(glob) ? null : FileSystems.getDefault().getPathMatcher("glob:" + glob);
+ final Map directoryTimestamps = new HashMap<>();
+ final Set directoriesWithMatchingFiles = new HashSet<>();
+ if (preserveTimestamps) {
+ ZipArchiveEntry zipEntry = new ZipArchiveEntry("./");
+ zipEntry.setTime(Files.getLastModifiedTime(dir).toMillis());
+ zipOutputStream.putArchiveEntry(zipEntry);
+ zipOutputStream.closeArchiveEntry();
+ }
Files.walkFileTree(dir, new SimpleFileVisitor() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) {
+ if (preserveTimestamps) {
+ directoryTimestamps.put(path, attrs.lastModifiedTime());
+ }
+ return FileVisitResult.CONTINUE;
+ }
+
@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttributes)
throws IOException {
if (matcher == null || matcher.matches(path.getFileName())) {
+ if (preserveTimestamps) {
+ Path parent = path.getParent();
+ while (parent != null && !parent.equals(dir)) {
+ directoriesWithMatchingFiles.add(parent);
+ parent = parent.getParent();
+ }
+ }
+
final ZipArchiveEntry zipEntry =
new ZipArchiveEntry(dir.relativize(path).toString());
+ if (preserveTimestamps) {
+ zipEntry.setTime(
+ basicFileAttributes.lastModifiedTime().toMillis());
+ }
+
// Preserve Unix permissions if requested and filesystem supports it
if (supportsPosix) {
Set permissions = Files.getPosixFilePermissions(path);
@@ -209,16 +247,39 @@ public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttribu
}
return FileVisitResult.CONTINUE;
}
+
+ @Override
+ public FileVisitResult postVisitDirectory(Path path, IOException exc) throws IOException {
+ if (exc != null) {
+ throw exc;
+ }
+ if (preserveTimestamps
+ && !path.equals(dir)
+ && (matcher == null || directoriesWithMatchingFiles.contains(path))) {
+ ZipArchiveEntry zipEntry =
+ new ZipArchiveEntry(dir.relativize(path).toString() + "/");
+ zipEntry.setTime(directoryTimestamps.get(path).toMillis());
+ zipOutputStream.putArchiveEntry(zipEntry);
+ zipOutputStream.closeArchiveEntry();
+ }
+ return FileVisitResult.CONTINUE;
+ }
});
}
return hasFiles.booleanValue();
}
public static void unzip(Path zip, Path out, boolean preservePermissions) throws IOException {
+ unzip(zip, out, preservePermissions, true);
+ }
+
+ public static void unzip(Path zip, Path out, boolean preservePermissions, boolean preserveTimestamps)
+ throws IOException {
// Check once if filesystem supports POSIX permissions instead of catching exceptions for every file
final boolean supportsPosix = preservePermissions
&& out.getFileSystem().supportedFileAttributeViews().contains("posix");
+ final Map directoryTimestamps = new HashMap<>();
try (ZipFile zipFile = ZipFile.builder().setFile(zip.toFile()).get()) {
Enumeration entries = zipFile.getEntries();
while (entries.hasMoreElements()) {
@@ -228,15 +289,26 @@ public static void unzip(Path zip, Path out, boolean preservePermissions) throws
throw new RuntimeException("Bad zip entry");
}
if (entry.isDirectory()) {
- Files.createDirectory(file);
+ Files.createDirectories(file);
+ if (preserveTimestamps) {
+ directoryTimestamps.put(file, entry.getTime());
+ }
} else {
Path parent = file.getParent();
- Files.createDirectories(parent);
+ if (parent != null) {
+ Files.createDirectories(parent);
+ }
try (InputStream is = zipFile.getInputStream(entry)) {
Files.copy(is, file, StandardCopyOption.REPLACE_EXISTING);
}
+ if (preserveTimestamps) {
+ try {
+ Files.setLastModifiedTime(file, FileTime.fromMillis(entry.getTime()));
+ } catch (IOException e) {
+ LOGGER.debug("Could not preserve timestamp for {}", file, e);
+ }
+ }
}
- Files.setLastModifiedTime(file, FileTime.fromMillis(entry.getTime()));
// Restore Unix permissions if requested and filesystem supports it
if (supportsPosix) {
@@ -248,6 +320,15 @@ public static void unzip(Path zip, Path out, boolean preservePermissions) throws
}
}
}
+ if (preserveTimestamps) {
+ for (Map.Entry entry : directoryTimestamps.entrySet()) {
+ try {
+ Files.setLastModifiedTime(entry.getKey(), FileTime.fromMillis(entry.getValue()));
+ } catch (IOException e) {
+ LOGGER.debug("Could not preserve timestamp for {}", entry.getKey(), e);
+ }
+ }
+ }
}
public static void debugPrintCollection(
diff --git a/src/main/java/org/apache/maven/buildcache/checksum/MavenProjectInput.java b/src/main/java/org/apache/maven/buildcache/checksum/MavenProjectInput.java
index e7a7a8d2..76be0cad 100644
--- a/src/main/java/org/apache/maven/buildcache/checksum/MavenProjectInput.java
+++ b/src/main/java/org/apache/maven/buildcache/checksum/MavenProjectInput.java
@@ -110,7 +110,7 @@ public class MavenProjectInput {
/**
* Version of cache implementation. It is recommended to change to simplify remote cache maintenance
*/
- public static final String CACHE_IMPLEMENTATION_VERSION = "v1.1";
+ public static final String CACHE_IMPLEMENTATION_VERSION = "v1.2";
/**
* property name to pass glob value. The glob to be used to list directory files in plugins scanning
diff --git a/src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java b/src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java
index 452dcfb7..53871dc0 100644
--- a/src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java
+++ b/src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java
@@ -110,6 +110,10 @@ public interface CacheConfig {
boolean isPreservePermissions();
+ default boolean isPreserveTimestamps() {
+ return true;
+ }
+
boolean adjustMetaInfVersion();
boolean calculateProjectVersionChecksum();
diff --git a/src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java b/src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java
index f5c1dc95..d8de4e84 100644
--- a/src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java
+++ b/src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java
@@ -597,6 +597,13 @@ public boolean isPreservePermissions() {
return attachedOutputs == null || attachedOutputs.isPreservePermissions();
}
+ @Override
+ public boolean isPreserveTimestamps() {
+ checkInitializedState();
+ final AttachedOutputs attachedOutputs = getConfiguration().getAttachedOutputs();
+ return attachedOutputs == null || attachedOutputs.isPreserveTimestamps();
+ }
+
@Override
public boolean adjustMetaInfVersion() {
if (isEnabled()) {
diff --git a/src/main/mdo/build-cache-config.mdo b/src/main/mdo/build-cache-config.mdo
index 617dcb9c..8aaa0818 100644
--- a/src/main/mdo/build-cache-config.mdo
+++ b/src/main/mdo/build-cache-config.mdo
@@ -382,6 +382,12 @@ under the License.
true
Preserve Unix file permissions when saving/restoring attached outputs. When enabled, permissions are stored in ZIP entry headers and become part of the cache key, ensuring cache invalidation when permissions change. This is similar to how Git includes file mode in tree hashes. Disabling this may improve portability across different systems but will not preserve executable bits.
+
+ preserveTimestamps
+ boolean
+ true
+ Preserve file and directory timestamps when saving/restoring attached outputs.
+
dirNames
diff --git a/src/site/markdown/parameters.md b/src/site/markdown/parameters.md
index c0c62e56..04b6ee0d 100644
--- a/src/site/markdown/parameters.md
+++ b/src/site/markdown/parameters.md
@@ -111,7 +111,9 @@ The following elements are supported in `maven-build-cache-config.xml` but have
```xml
-
+
+ true
+ true
generated-sources/apt
@@ -119,9 +121,10 @@ The following elements are supported in `maven-build-cache-config.xml` but have
```
-| Element/Attribute | Default | Description |
+| Element | Default | Description |
|-----------------------|---------|----------------------------------------------------------------------------------------------------------|
| `preservePermissions` | `true` | Preserve Unix file-system permissions when restoring artifacts from cache. Requires a POSIX file system. |
+| `preserveTimestamps` | `true` | Preserve file and directory timestamps when saving and restoring cached outputs. |
| `dirNames/dirName` | — | Additional output directories to include in cached artifacts beyond standard Maven output directories. |
#### `` — version handling in cached artifacts
diff --git a/src/test/java/org/apache/maven/buildcache/CacheUtilsTimestampTest.java b/src/test/java/org/apache/maven/buildcache/CacheUtilsTimestampTest.java
new file mode 100644
index 00000000..e573c909
--- /dev/null
+++ b/src/test/java/org/apache/maven/buildcache/CacheUtilsTimestampTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.maven.buildcache;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CacheUtilsTimestampTest {
+ private static final long TIMESTAMP_TOLERANCE_MILLIS = 2_000;
+
+ @Test
+ void preservesFileAndDirectoryTimestamps(@TempDir Path tempDir) throws IOException {
+ Path source = tempDir.resolve("source");
+ Path nested = source.resolve("a/b/c");
+ Files.createDirectories(nested);
+ Path file = nested.resolve("output.txt");
+ Files.write(file, "cached output".getBytes(StandardCharsets.UTF_8));
+
+ FileTime timestamp = FileTime.fromMillis(System.currentTimeMillis() - 3_600_000);
+ Files.setLastModifiedTime(file, timestamp);
+ Files.setLastModifiedTime(nested, timestamp);
+ Files.setLastModifiedTime(nested.getParent(), timestamp);
+ Files.setLastModifiedTime(nested.getParent().getParent(), timestamp);
+ Files.setLastModifiedTime(source, timestamp);
+
+ Path archive = tempDir.resolve("output.zip");
+ CacheUtils.zip(source, archive, "*", true, true);
+
+ Path restored = tempDir.resolve("restored");
+ Files.createDirectories(restored);
+ CacheUtils.unzip(archive, restored, true, true);
+
+ assertTimestamp(timestamp, Files.getLastModifiedTime(restored));
+ assertTimestamp(timestamp, Files.getLastModifiedTime(restored.resolve("a/b/c/output.txt")));
+ assertTimestamp(timestamp, Files.getLastModifiedTime(restored.resolve("a/b/c")));
+ assertTimestamp(timestamp, Files.getLastModifiedTime(restored.resolve("a/b")));
+ assertTimestamp(timestamp, Files.getLastModifiedTime(restored.resolve("a")));
+ }
+
+ @Test
+ void doesNotPreserveTimestampsWhenDisabled(@TempDir Path tempDir) throws IOException {
+ Path source = tempDir.resolve("source");
+ Files.createDirectories(source);
+ Path file = source.resolve("output.txt");
+ Files.write(file, "cached output".getBytes(StandardCharsets.UTF_8));
+ FileTime timestamp = FileTime.fromMillis(System.currentTimeMillis() - 3_600_000);
+ Files.setLastModifiedTime(file, timestamp);
+
+ Path archive = tempDir.resolve("output.zip");
+ CacheUtils.zip(source, archive, "*", false, false);
+ Path restored = tempDir.resolve("restored");
+ CacheUtils.unzip(archive, restored, false, false);
+
+ long difference = Math.abs(
+ Files.getLastModifiedTime(restored.resolve("output.txt")).toMillis() - timestamp.toMillis());
+ assertTrue(difference > TIMESTAMP_TOLERANCE_MILLIS, "Timestamp was unexpectedly preserved");
+ }
+
+ private static void assertTimestamp(FileTime expected, FileTime actual) {
+ long difference = Math.abs(actual.toMillis() - expected.toMillis());
+ assertTrue(difference <= TIMESTAMP_TOLERANCE_MILLIS, "Timestamp difference: " + difference);
+ }
+}