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, cacheConfig.isPreservePermissions());
boolean hasFile = CacheUtils.zip(dir, temp, glob, cacheConfig.isPreservePermissions(), 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, cacheConfig.isPreservePermissions());
CacheUtils.unzip(artifactFilePath, outputDir, cacheConfig.isPreservePermissions(), cacheConfig.isPreserveTimestamps());
}

// TODO: move to config
Expand Down
114 changes: 100 additions & 14 deletions src/main/java/org/apache/maven/buildcache/CacheUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,11 @@
import java.nio.file.attribute.PosixFilePermission;
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;
Expand Down Expand Up @@ -163,10 +166,11 @@ 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.</p>
* @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
Expand All @@ -177,16 +181,46 @@ public static boolean zip(final Path dir, final Path zip, final String glob, boo

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 ZipArchiveEntry zipEntry =
new ZipArchiveEntry(dir.relativize(path).toString());

// Preserve timestamp if requested
if (preserveTimestamps) {
zipEntry.setTime(basicFileAttributes.lastModifiedTime().toMillis());
}

// Preserve Unix permissions if requested and filesystem supports it
if (supportsPosix) {
Set<PosixFilePermission> permissions = Files.getPosixFilePermissions(path);
Expand All @@ -200,16 +234,42 @@ public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttribu
}
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() + "/";
ZipArchiveEntry zipEntry = new ZipArchiveEntry(relativePath);
zipEntry.setTime(attrs.lastModifiedTime().toMillis());
zipOutputStream.putArchiveEntry(zipEntry);
zipOutputStream.closeArchiveEntry();
}
}
return FileVisitResult.CONTINUE;
}
});
}
return hasFiles.booleanValue();
}

public static void unzip(Path zip, Path out, boolean preservePermissions) throws IOException {
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");

Map<Path, Long> directoryTimestamps = preserveTimestamps ? new HashMap<>() : Collections.emptyMap();
try (ZipArchiveInputStream zis = new ZipArchiveInputStream(Files.newInputStream(zip))) {
ZipArchiveEntry entry = zis.getNextEntry();
while (entry != null) {
Expand All @@ -218,26 +278,52 @@ 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);
}
Files.copy(zis, file, StandardCopyOption.REPLACE_EXISTING);
}
Files.setLastModifiedTime(file, FileTime.fromMillis(entry.getTime()));

// Restore Unix permissions if requested and filesystem supports it
if (supportsPosix) {
int unixMode = entry.getUnixMode();
if (unixMode != 0) {
Set<PosixFilePermission> permissions = modeToPermissions(unixMode);
Files.setPosixFilePermissions(file, permissions);

if (preserveTimestamps) {
// Set file timestamp with error handling
try {
Files.setLastModifiedTime(file, FileTime.fromMillis(entry.getTime()));
} catch (IOException e) {
// Timestamp setting is best-effort; silently ignore failures
// This can happen on filesystems that don't support modification times
}
}
}

// Restore Unix permissions if requested and filesystem supports it
if (supportsPosix) {
int unixMode = entry.getUnixMode();
if (unixMode != 0) {
Set<PosixFilePermission> permissions = modeToPermissions(unixMode);
Files.setPosixFilePermissions(file, permissions);
}
}
}
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()) {
try {
Files.setLastModifiedTime(dirEntry.getKey(), FileTime.fromMillis(dirEntry.getValue()));
} catch (IOException e) {
// Timestamp setting is best-effort; silently ignore failures
// 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 @@ -110,6 +110,8 @@ public interface CacheConfig {

boolean isPreservePermissions();

boolean isPreserveTimestamps();

boolean adjustMetaInfVersion();

boolean calculateProjectVersionChecksum();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,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()) {
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 @@ -382,6 +382,12 @@ under the License.
<defaultValue>true</defaultValue>
<description>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.</description>
</field>
<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