Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -881,7 +881,7 @@ 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);
boolean hasFile = CacheUtils.zip(dir, temp, glob, cacheConfig.isPreserveTimestamps());
if (hasFile) {
projectHelper.attachArtifact(project, "zip", classifier, temp.toFile());
}
Expand All @@ -896,7 +896,7 @@ private void restoreGeneratedSources(Artifact artifact, Path artifactFilePath, M
if (!Files.exists(outputDir)) {
Files.createDirectories(outputDir);
}
CacheUtils.unzip(artifactFilePath, outputDir);
CacheUtils.unzip(artifactFilePath, outputDir, cacheConfig.isPreserveTimestamps());
}

// TODO: move to config
Expand Down
114 changes: 109 additions & 5 deletions src/main/java/org/apache/maven/buildcache/CacheUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,18 @@
import java.nio.file.PathMatcher;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.StandardCopyOption;
import java.nio.file.attribute.BasicFileAttributeView;
import java.nio.file.attribute.BasicFileAttributes;
import java.nio.file.attribute.FileTime;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
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;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
Expand Down Expand Up @@ -154,37 +160,98 @@ public static boolean isArchive(File file) {
* @param dir directory to zip
* @param zip zip to populate
* @param glob glob to apply to filenames
* @param preserveTimestamps whether to preserve file and directory timestamps in the zip.
* <p><b>Important:</b> When {@code true}, timestamps are stored in ZIP entry headers,
* which means they become part of the ZIP file's binary content. As a result, hashing
* the ZIP file (e.g., for cache keys) will include timestamp information, ensuring
* cache invalidation when file timestamps change. This behavior is similar to how Git
* includes file mode in tree hashes.</p>
* @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) throws IOException {
public static boolean zip(final Path dir, final Path zip, final String glob, boolean preserveTimestamps)
throws IOException {
final MutableBoolean hasFiles = new MutableBoolean();
try (ZipOutputStream zipOutputStream = new ZipOutputStream(Files.newOutputStream(zip))) {

PathMatcher matcher =
"*".equals(glob) ? null : FileSystems.getDefault().getPathMatcher("glob:" + glob);

// Track directories that contain matching files for glob filtering
final Set<Path> directoriesWithMatchingFiles = new HashSet<>();
// Track directory attributes for timestamp preservation
final Map<Path, BasicFileAttributes> directoryAttributes =
preserveTimestamps ? new HashMap<>() : Collections.emptyMap();

Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {

@Override
public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) throws IOException {
if (preserveTimestamps) {
// Store attributes for use in postVisitDirectory
directoryAttributes.put(path, attrs);
}
return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttributes)
throws IOException {

if (matcher == null || matcher.matches(path.getFileName())) {
if (preserveTimestamps) {
// Mark all parent directories as containing matching files
Path parent = path.getParent();
while (parent != null && !parent.equals(dir)) {
directoriesWithMatchingFiles.add(parent);
parent = parent.getParent();
}
}

final ZipEntry zipEntry =
new ZipEntry(dir.relativize(path).toString());
if (preserveTimestamps) {
zipEntry.setTime(basicFileAttributes.lastModifiedTime().toMillis());
}
zipOutputStream.putNextEntry(zipEntry);
Files.copy(path, zipOutputStream);
hasFiles.setTrue();
zipOutputStream.closeEntry();
}
return FileVisitResult.CONTINUE;
}

@Override
public FileVisitResult postVisitDirectory(Path path, IOException exc) throws IOException {
// Propagate any exception that occurred during directory traversal
if (exc != null) {
throw exc;
}

// Add directory entry only if preserving timestamps and:
// 1. It's not the root directory, AND
// 2. Either no glob filter (matcher is null) OR directory contains matching files
if (preserveTimestamps
&& !path.equals(dir)
&& (matcher == null || directoriesWithMatchingFiles.contains(path))) {
BasicFileAttributes attrs = directoryAttributes.get(path);
if (attrs != null) {
String relativePath = dir.relativize(path).toString() + "/";
ZipEntry zipEntry = new ZipEntry(relativePath);
zipEntry.setTime(attrs.lastModifiedTime().toMillis());
zipOutputStream.putNextEntry(zipEntry);
zipOutputStream.closeEntry();
}
}
return FileVisitResult.CONTINUE;
}
});
}
return hasFiles.booleanValue();
}

public static void unzip(Path zip, Path out) throws IOException {
public static void unzip(Path zip, Path out, boolean preserveTimestamps) throws IOException {
Map<Path, Long> directoryTimestamps = preserveTimestamps ? new HashMap<>() : Collections.emptyMap();
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip))) {
ZipEntry entry = zis.getNextEntry();
while (entry != null) {
Expand All @@ -193,16 +260,53 @@ public static void unzip(Path zip, Path out) throws IOException {
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);
}
Files.copy(zis, file, StandardCopyOption.REPLACE_EXISTING);

if (preserveTimestamps) {
setAllTimestamps(file, entry.getTime());
}
}
Files.setLastModifiedTime(file, FileTime.fromMillis(entry.getTime()));
entry = zis.getNextEntry();
}
}

if (preserveTimestamps) {
// Set directory timestamps after all files have been extracted to avoid them being
// updated by file creation operations
for (Map.Entry<Path, Long> dirEntry : directoryTimestamps.entrySet()) {
setAllTimestamps(dirEntry.getKey(), dirEntry.getValue());
}
}
}

/**
* Sets all timestamps (lastModifiedTime, lastAccessTime, and creationTime) for a path
* to the same value to ensure consistency. This is a best-effort operation that silently
* ignores errors on filesystems that don't support timestamp modification.
*
* @param path the path to update
* @param timestampMillis the timestamp in milliseconds since epoch
*/
private static void setAllTimestamps(Path path, long timestampMillis) {
try {
BasicFileAttributeView attributes = Files.getFileAttributeView(path, BasicFileAttributeView.class);
if (attributes != null) {
FileTime time = FileTime.fromMillis(timestampMillis);
attributes.setTimes(time, time, time);
}
} catch (IOException e) {
// Timestamp setting is best-effort; log but don't fail extraction
// This can happen on filesystems that don't support modification times
}
}

public static <T> void debugPrintCollection(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ public interface CacheConfig {

List<DirName> getAttachedOutputs();

boolean isPreserveTimestamps();

boolean adjustMetaInfVersion();

boolean calculateProjectVersionChecksum();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,13 @@ public List<DirName> getAttachedOutputs() {
return attachedOutputs == null ? Collections.emptyList() : attachedOutputs.getDirNames();
}

@Override
public boolean isPreserveTimestamps() {
checkInitializedState();
final AttachedOutputs attachedOutputs = getConfiguration().getAttachedOutputs();
return attachedOutputs == null || attachedOutputs.isPreserveTimestamps();
}

@Override
public boolean adjustMetaInfVersion() {
if (isEnabled()) {
Expand Down
6 changes: 6 additions & 0 deletions src/main/mdo/build-cache-config.mdo
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,12 @@ under the License.
<name>AttachedOutputs</name>
<description>Section relative to outputs which are not artifacts but need to be saved/restored.</description>
<fields>
<field>
<name>preserveTimestamps</name>
<type>boolean</type>
<defaultValue>true</defaultValue>
<description>Preserve file and directory timestamps when saving/restoring attached outputs. Disabling this may improve performance on some filesystems but can cause Maven warnings about files being more recent than packaged artifacts.</description>
</field>
<field>
<name>dirNames</name>
<association>
Expand Down
Loading