This is an automated email from the ASF dual-hosted git repository.
olamy pushed a commit to branch master
in repository
https://gitbox.apache.org/repos/asf/maven-build-cache-extension.git
The following commit(s) were added to refs/heads/master by this push:
new e739cfa Save attached outputs for compile-only cache entries (#394)
e739cfa is described below
commit e739cfafb6837a484a9f3bd3b92023c500ef5bb8
Author: Gili Tzabari <[email protected]>
AuthorDate: Thu Dec 25 04:00:51 2025 -0500
Save attached outputs for compile-only cache entries (#394)
* Save attached outputs for compile-only cache entries
---
.../BuildCacheMojosExecutionStrategy.java | 62 ++-
.../apache/maven/buildcache/CacheController.java | 20 +
.../maven/buildcache/CacheControllerImpl.java | 609 +++++++++++++++++++--
.../apache/maven/buildcache/xml/CacheConfig.java | 11 +
.../maven/buildcache/xml/CacheConfigImpl.java | 6 +
src/main/mdo/build-cache-build.mdo | 5 +
src/site/markdown/how-to.md | 15 +
src/site/markdown/parameters.md | 1 +
.../maven/buildcache/its/BuildExtensionTest.java | 1 +
.../buildcache/its/CacheCompileDisabledTest.java | 139 +++++
.../buildcache/its/Issue393CompileRestoreTest.java | 53 ++
.../maven/buildcache/its/MandatoryCleanTest.java | 4 +
.../maven/buildcache/its/StaleArtifactTest.java | 83 +++
.../its/StaleMultimoduleArtifactTest.java | 105 ++++
.../issue-393-compile-restore/.mvn/extensions.xml | 28 +
.../.mvn/maven-build-cache-config.xml | 34 ++
.../projects/issue-393-compile-restore/app/pom.xml | 37 ++
.../app/src/main/java/module-info.java | 21 +
.../maven/caching/test/jpms/app/Greeting.java | 30 +
.../issue-393-compile-restore/consumer/pom.xml | 64 +++
.../consumer/src/main/java/module-info.java | 22 +
.../maven/caching/test/jpms/consumer/Consumer.java | 32 ++
.../caching/test/jpms/consumer/ConsumerTest.java | 31 ++
.../projects/issue-393-compile-restore/pom.xml | 42 ++
.../.mvn/maven-build-cache-config.xml | 32 ++
src/test/projects/stale-artifact/pom.xml | 46 ++
.../src/main/java/org/example/App.java | 25 +
.../.mvn/maven-build-cache-config.xml | 32 ++
.../stale-multimodule-artifact/module1/pom.xml | 31 ++
.../module1/src/main/java/org/example/Module1.java | 25 +
.../projects/stale-multimodule-artifact/pom.xml | 47 ++
31 files changed, 1616 insertions(+), 77 deletions(-)
diff --git
a/src/main/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategy.java
b/src/main/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategy.java
index 863d3e4..d4715ed 100644
---
a/src/main/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategy.java
+++
b/src/main/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategy.java
@@ -23,6 +23,7 @@
import javax.inject.Named;
import java.io.File;
+import java.io.IOException;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
@@ -142,33 +143,58 @@ public void execute(
boolean restorable = result.isSuccess() ||
result.isPartialSuccess();
boolean restored = false; // if partially restored need to save
increment
+
if (restorable) {
CacheRestorationStatus cacheRestorationStatus =
restoreProject(result, mojoExecutions,
mojoExecutionRunner, cacheConfig);
restored = CacheRestorationStatus.SUCCESS ==
cacheRestorationStatus;
executeExtraCleanPhaseIfNeeded(cacheRestorationStatus,
cleanPhase, mojoExecutionRunner);
}
- if (!restored) {
- for (MojoExecution mojoExecution : mojoExecutions) {
- if (source == Source.CLI
- || mojoExecution.getLifecyclePhase() == null
- ||
lifecyclePhasesHelper.isLaterPhaseThanClean(mojoExecution.getLifecyclePhase()))
{
- mojoExecutionRunner.run(mojoExecution);
+
+ try {
+ if (!restored && !forkedExecution) {
+ // Move pre-existing artifacts to staging directory to
prevent caching stale files
+ // from previous builds (e.g., after source changes or
from cache restored
+ // with clock skew). This ensures save() only sees fresh
files built during this session.
+ // Skip for forked executions since they don't cache and
shouldn't modify artifacts.
+ try {
+ cacheController.stagePreExistingArtifacts(session,
project);
+ } catch (IOException e) {
+ LOGGER.debug("Failed to stage pre-existing artifacts:
{}", e.getMessage());
+ // Continue build - if staging fails, we'll just cache
what exists
}
}
- }
- if (cacheState == INITIALIZED && (!result.isSuccess() ||
!restored)) {
- if (cacheConfig.isSkipSave()) {
- LOGGER.info("Cache saving is disabled.");
- } else if (cacheConfig.isMandatoryClean()
- && lifecyclePhasesHelper
- .getCleanSegment(project, mojoExecutions)
- .isEmpty()) {
- LOGGER.info("Cache storing is skipped since there was no
\"clean\" phase.");
- } else {
- final Map<String, MojoExecutionEvent> executionEvents =
mojoListener.getProjectExecutions(project);
- cacheController.save(result, mojoExecutions,
executionEvents);
+ if (!restored) {
+ for (MojoExecution mojoExecution : mojoExecutions) {
+ if (source == Source.CLI
+ || mojoExecution.getLifecyclePhase() == null
+ ||
lifecyclePhasesHelper.isLaterPhaseThanClean(mojoExecution.getLifecyclePhase()))
{
+ mojoExecutionRunner.run(mojoExecution);
+ }
+ }
+ }
+
+ if (cacheState == INITIALIZED && (!result.isSuccess() ||
!restored)) {
+ if (cacheConfig.isSkipSave()) {
+ LOGGER.debug("Cache saving is disabled.");
+ } else if (cacheConfig.isMandatoryClean()
+ && lifecyclePhasesHelper
+ .getCleanSegment(project, mojoExecutions)
+ .isEmpty()) {
+ LOGGER.debug("Cache storing is skipped since there was
no \"clean\" phase.");
+ } else {
+ final Map<String, MojoExecutionEvent> executionEvents =
+ mojoListener.getProjectExecutions(project);
+ cacheController.save(result, mojoExecutions,
executionEvents);
+ }
+ }
+ } finally {
+ // Always restore staged files after build completes (whether
save ran or not).
+ // Files that were rebuilt are discarded; files that weren't
rebuilt are restored.
+ // Skip for forked executions since they don't stage artifacts.
+ if (!restored && !forkedExecution) {
+ cacheController.restoreStagedArtifacts(session, project);
}
}
diff --git a/src/main/java/org/apache/maven/buildcache/CacheController.java
b/src/main/java/org/apache/maven/buildcache/CacheController.java
index 7acf785..7d8f578 100644
--- a/src/main/java/org/apache/maven/buildcache/CacheController.java
+++ b/src/main/java/org/apache/maven/buildcache/CacheController.java
@@ -18,6 +18,7 @@
*/
package org.apache.maven.buildcache;
+import java.io.IOException;
import java.util.List;
import java.util.Map;
@@ -45,4 +46,23 @@ void save(
boolean isForcedExecution(MavenProject project, MojoExecution execution);
void saveCacheReport(MavenSession session);
+
+ /**
+ * Move pre-existing artifacts to staging directory to prevent caching
stale files.
+ * Called before mojos run to ensure save() only sees fresh files.
+ *
+ * @param session the Maven session
+ * @param project the Maven project
+ * @throws IOException if file operations fail
+ */
+ void stagePreExistingArtifacts(MavenSession session, MavenProject project)
throws IOException;
+
+ /**
+ * Restore staged artifacts after save() completes.
+ * Files that were rebuilt are discarded; files that weren't rebuilt are
restored.
+ *
+ * @param session the Maven session
+ * @param project the Maven project
+ */
+ void restoreStagedArtifacts(MavenSession session, MavenProject project);
}
diff --git a/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java
b/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java
index 48cb5a0..4c57328 100644
--- a/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java
+++ b/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java
@@ -30,6 +30,7 @@
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
+import java.nio.file.FileAlreadyExistsException;
import java.nio.file.FileVisitResult;
import java.nio.file.Files;
import java.nio.file.Path;
@@ -40,6 +41,7 @@
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
+import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -134,13 +136,30 @@ public class CacheControllerImpl implements
CacheController {
private volatile Scm scm;
/**
- * A map dedicated to store the base path of resources stored to the cache
which are not original artifacts
- * (ex : generated source basedir).
- * Used to link the resource to its path on disk
+ * Per-project cache state to ensure thread safety in multi-threaded
builds.
+ * Each project gets isolated state for resource tracking, counters, and
restored output tracking.
*/
- private final Map<String, Path> attachedResourcesPathsById = new
HashMap<>();
+ private static class ProjectCacheState {
+ final Map<String, Path> attachedResourcesPathsById = new HashMap<>();
+ int attachedResourceCounter = 0;
+ final Set<String> restoredOutputClassifiers = new HashSet<>();
+
+ /**
+ * Tracks the staging directory path where pre-existing artifacts are
moved.
+ * Artifacts are moved here before mojos run and restored after save()
completes.
+ */
+ Path stagingDirectory;
+ }
+
+ private final ConcurrentMap<String, ProjectCacheState> projectStates = new
ConcurrentHashMap<>();
- private int attachedResourceCounter = 0;
+ /**
+ * Get or create cache state for the given project (thread-safe).
+ */
+ private ProjectCacheState getProjectState(MavenProject project) {
+ String key = getVersionlessProjectKey(project);
+ return projectStates.computeIfAbsent(key, k -> new
ProjectCacheState());
+ }
// CHECKSTYLE_OFF: ParameterNumber
@Inject
public CacheControllerImpl(
@@ -261,6 +280,7 @@ private CacheResult analyzeResult(CacheContext context,
List<MojoExecution> mojo
List<MojoExecution> cachedSegment =
lifecyclePhasesHelper.getCachedSegment(context.getProject(), mojoExecutions,
build);
List<MojoExecution> missingMojos =
build.getMissingExecutions(cachedSegment);
+
if (!missingMojos.isEmpty()) {
LOGGER.warn(
"Cached build doesn't contains all requested plugin
executions "
@@ -312,7 +332,6 @@ private boolean canIgnoreMissingSegment(MavenProject
project, Build info, List<M
private UnaryOperator<File> createRestorationToDiskConsumer(final
MavenProject project, final Artifact artifact) {
if (cacheConfig.isRestoreOnDiskArtifacts() &&
MavenProjectInput.isRestoreOnDiskArtifacts(project)) {
-
Path restorationPath =
project.getBasedir().toPath().resolve(artifact.getFilePath());
final AtomicBoolean restored = new AtomicBoolean(false);
return file -> {
@@ -320,13 +339,11 @@ private UnaryOperator<File>
createRestorationToDiskConsumer(final MavenProject p
if (restored.compareAndSet(false, true)) {
verifyRestorationInsideProject(project, restorationPath);
try {
- Files.createDirectories(restorationPath.getParent());
- Files.copy(file.toPath(), restorationPath,
StandardCopyOption.REPLACE_EXISTING);
+ restoreArtifactToDisk(file, artifact, restorationPath);
} catch (IOException e) {
LOGGER.error("Cannot restore file " +
artifact.getFileName(), e);
throw new RuntimeException(e);
}
- LOGGER.debug("Restored file on disk ({} to {})",
artifact.getFileName(), restorationPath);
}
return restorationPath.toFile();
};
@@ -335,6 +352,41 @@ private UnaryOperator<File>
createRestorationToDiskConsumer(final MavenProject p
return file -> file;
}
+ /**
+ * Restores an artifact from cache to disk, handling both regular files
and directory artifacts.
+ * Directory artifacts (cached as zips) are unzipped back to their
original directory structure.
+ */
+ private void restoreArtifactToDisk(File cachedFile, Artifact artifact,
Path restorationPath) throws IOException {
+ // Check the explicit isDirectory flag set during save.
+ // Directory artifacts (e.g., target/classes) are saved as zips and
need to be unzipped on restore.
+ if (artifact.isIsDirectory()) {
+ restoreDirectoryArtifact(cachedFile, artifact, restorationPath);
+ } else {
+ restoreRegularFileArtifact(cachedFile, artifact, restorationPath);
+ }
+ }
+
+ /**
+ * Restores a directory artifact by unzipping the cached zip file.
+ */
+ private void restoreDirectoryArtifact(File cachedZip, Artifact artifact,
Path restorationPath) throws IOException {
+ if (!Files.exists(restorationPath)) {
+ Files.createDirectories(restorationPath);
+ }
+ CacheUtils.unzip(cachedZip.toPath(), restorationPath,
cacheConfig.isPreservePermissions());
+ LOGGER.debug("Restored directory artifact by unzipping: {} -> {}",
artifact.getFileName(), restorationPath);
+ }
+
+ /**
+ * Restores a regular file artifact by copying it from cache.
+ */
+ private void restoreRegularFileArtifact(File cachedFile, Artifact
artifact, Path restorationPath)
+ throws IOException {
+ Files.createDirectories(restorationPath.getParent());
+ Files.copy(cachedFile.toPath(), restorationPath,
StandardCopyOption.REPLACE_EXISTING);
+ LOGGER.debug("Restored file on disk ({} to {})",
artifact.getFileName(), restorationPath);
+ }
+
private boolean isPathInsideProject(final MavenProject project, Path path)
{
Path restorationPath = path.toAbsolutePath().normalize();
return restorationPath.startsWith(project.getBasedir().toPath());
@@ -355,6 +407,7 @@ public ArtifactRestorationReport
restoreProjectArtifacts(CacheResult cacheResult
final Build build = cacheResult.getBuildInfo();
final CacheContext context = cacheResult.getContext();
final MavenProject project = context.getProject();
+ final ProjectCacheState state = getProjectState(project);
ArtifactRestorationReport restorationReport = new
ArtifactRestorationReport();
try {
@@ -396,6 +449,8 @@ public ArtifactRestorationReport
restoreProjectArtifacts(CacheResult cacheResult
final Path attachedArtifactFile =
localCache.getArtifactFile(context,
cacheResult.getSource(), attachedArtifactInfo);
restoreGeneratedSources(attachedArtifactInfo,
attachedArtifactFile, project);
+ // Track this classifier as restored so save()
includes it even with old timestamp
+
state.restoredOutputClassifiers.add(attachedArtifactInfo.getClassifier());
}
} else {
Future<File> downloadTask = createDownloadTask(
@@ -496,29 +551,71 @@ public void save(
final MavenProject project = context.getProject();
final MavenSession session = context.getSession();
+ final ProjectCacheState state = getProjectState(project);
try {
+ state.attachedResourcesPathsById.clear();
+ state.attachedResourceCounter = 0;
+
+ // Get build start time to filter out stale artifacts from
previous builds
+ final long buildStartTime =
session.getRequest().getStartTime().getTime();
+
final HashFactory hashFactory = cacheConfig.getHashFactory();
+ final HashAlgorithm algorithm = hashFactory.createAlgorithm();
final org.apache.maven.artifact.Artifact projectArtifact =
project.getArtifact();
- final List<org.apache.maven.artifact.Artifact> attachedArtifacts;
- final List<Artifact> attachedArtifactDtos;
- final Artifact projectArtifactDto;
- if (project.hasLifecyclePhase("package")) {
- final HashAlgorithm algorithm = hashFactory.createAlgorithm();
- attachGeneratedSources(project);
- attachOutputs(project);
- attachedArtifacts = project.getAttachedArtifacts() != null
- ? project.getAttachedArtifacts()
- : Collections.emptyList();
- attachedArtifactDtos = artifactDtos(attachedArtifacts,
algorithm, project);
- projectArtifactDto = artifactDto(project.getArtifact(),
algorithm, project);
- } else {
- attachedArtifacts = Collections.emptyList();
- attachedArtifactDtos = new ArrayList<>();
- projectArtifactDto = null;
+
+ // Cache compile outputs (classes, test-classes, generated
sources) if enabled
+ // This allows compile-only builds to create restorable cache
entries
+ // Can be disabled with -Dmaven.build.cache.cacheCompile=false to
reduce IO overhead
+ final boolean cacheCompile = cacheConfig.isCacheCompile();
+ if (cacheCompile) {
+ attachGeneratedSources(project, state, buildStartTime);
+ attachOutputs(project, state, buildStartTime);
}
+ final List<org.apache.maven.artifact.Artifact> attachedArtifacts =
+ project.getAttachedArtifacts() != null ?
project.getAttachedArtifacts() : Collections.emptyList();
+ final List<Artifact> attachedArtifactDtos =
artifactDtos(attachedArtifacts, algorithm, project, state);
+ // Always create artifact DTO - if package phase hasn't run, the
file will be null
+ // and restoration will safely skip it. This ensures all builds
have an artifact DTO.
+ final Artifact projectArtifactDto =
artifactDto(project.getArtifact(), algorithm, project, state);
+
List<CompletedExecution> completedExecution =
buildExecutionInfo(mojoExecutions, executionEvents);
+ // CRITICAL: Don't create incomplete cache entries!
+ // Only save cache entry if we have SOMETHING useful to restore.
+ // Exclude consumer POMs (Maven metadata) from the "useful
artifacts" check.
+ // This prevents the bug where:
+ // 1. mvn compile (cacheCompile=false) creates cache entry with
only metadata
+ // 2. mvn compile (cacheCompile=true) tries to restore
incomplete cache and fails
+ //
+ // Save cache entry if ANY of these conditions are met:
+ // 1. Project artifact file exists:
+ // a) Regular file (JAR/WAR/etc from package phase)
+ // b) Directory (target/classes from compile-only builds) -
only if cacheCompile=true
+ // 2. Has attached artifacts (classes/test-classes from
cacheCompile=true)
+ // 3. POM project with plugin executions (worth caching to skip
plugin execution on cache hit)
+ //
+ // NOTE: No timestamp checking needed -
stagePreExistingArtifacts() ensures only fresh files
+ // are visible (stale files are moved to staging directory).
+
+ // Check if project artifact is valid (exists and is correct type)
+ boolean hasArtifactFile = projectArtifact.getFile() != null
+ && projectArtifact.getFile().exists()
+ && (projectArtifact.getFile().isFile()
+ || (cacheCompile &&
projectArtifact.getFile().isDirectory()));
+ boolean hasAttachedArtifacts = !attachedArtifactDtos.isEmpty()
+ && attachedArtifactDtos.stream()
+ .anyMatch(a ->
!"consumer".equals(a.getClassifier()) || !"pom".equals(a.getType()));
+ // Only save POM projects if they executed plugins (not just
aggregator POMs with no work)
+ boolean isPomProjectWithWork =
"pom".equals(project.getPackaging()) && !completedExecution.isEmpty();
+
+ if (!hasArtifactFile && !hasAttachedArtifacts &&
!isPomProjectWithWork) {
+ LOGGER.info(
+ "Skipping cache save: no artifacts to save ({}only
metadata present)",
+ cacheCompile ? "" : "cacheCompile=false, ");
+ return;
+ }
+
final Build build = new Build(
session.getGoals(),
projectArtifactDto,
@@ -532,23 +629,21 @@ public void save(
localCache.beforeSave(context);
- // if package phase presence means new artifacts were packaged
- if (project.hasLifecyclePhase("package")) {
- if (projectArtifact.getFile() != null) {
- localCache.saveArtifactFile(cacheResult, projectArtifact);
- }
- for (org.apache.maven.artifact.Artifact attachedArtifact :
attachedArtifacts) {
- if (attachedArtifact.getFile() != null) {
- boolean storeArtifact =
-
isOutputArtifact(attachedArtifact.getFile().getName());
- if (storeArtifact) {
- localCache.saveArtifactFile(cacheResult,
attachedArtifact);
- } else {
- LOGGER.debug(
- "Skipping attached project artifact '{}' =
"
- + " it is marked for exclusion
from caching",
- attachedArtifact.getFile().getName());
- }
+ // Save project artifact file if it exists (created by package or
compile phase)
+ if (projectArtifact.getFile() != null) {
+ saveProjectArtifact(cacheResult, projectArtifact, project);
+ }
+ for (org.apache.maven.artifact.Artifact attachedArtifact :
attachedArtifacts) {
+ if (attachedArtifact.getFile() != null) {
+ boolean storeArtifact =
+
isOutputArtifact(attachedArtifact.getFile().getName());
+ if (storeArtifact) {
+ localCache.saveArtifactFile(cacheResult,
attachedArtifact);
+ } else {
+ LOGGER.debug(
+ "Skipping attached project artifact '{}' = "
+ + " it is marked for exclusion from
caching",
+ attachedArtifact.getFile().getName());
}
}
}
@@ -566,6 +661,58 @@ public void save(
} catch (Exception ex) {
LOGGER.error("Failed to clean cache due to unexpected error:",
ex);
}
+ } finally {
+ // Cleanup project state to free memory, but preserve
stagingDirectory for restore
+ // Note: stagingDirectory must persist until
restoreStagedArtifacts() is called
+ state.attachedResourcesPathsById.clear();
+ state.attachedResourceCounter = 0;
+ state.restoredOutputClassifiers.clear();
+ // stagingDirectory is NOT cleared here - it's cleared in
restoreStagedArtifacts()
+ }
+ }
+
+ /**
+ * Saves a project artifact to cache, handling both regular files and
directory artifacts.
+ * Directory artifacts (e.g., target/classes from compile-only builds) are
zipped before saving
+ * since Files.copy() cannot handle directories.
+ */
+ private void saveProjectArtifact(
+ CacheResult cacheResult, org.apache.maven.artifact.Artifact
projectArtifact, MavenProject project)
+ throws IOException {
+ File originalFile = projectArtifact.getFile();
+ try {
+ if (originalFile.isDirectory()) {
+ saveDirectoryArtifact(cacheResult, projectArtifact, project,
originalFile);
+ } else {
+ // Regular file (JAR/WAR) - save directly
+ localCache.saveArtifactFile(cacheResult, projectArtifact);
+ }
+ } finally {
+ // Restore original file reference in case it was temporarily
changed
+ projectArtifact.setFile(originalFile);
+ }
+ }
+
+ /**
+ * Saves a directory artifact by zipping it first, then saving the zip to
cache.
+ */
+ private void saveDirectoryArtifact(
+ CacheResult cacheResult,
+ org.apache.maven.artifact.Artifact projectArtifact,
+ MavenProject project,
+ File originalFile)
+ throws IOException {
+ Path tempZip = Files.createTempFile("maven-cache-", "-" +
project.getArtifactId() + ".zip");
+ boolean hasFiles = CacheUtils.zip(originalFile.toPath(), tempZip, "*",
cacheConfig.isPreservePermissions());
+ if (hasFiles) {
+ // Temporarily replace artifact file with zip for saving
+ projectArtifact.setFile(tempZip.toFile());
+ localCache.saveArtifactFile(cacheResult, projectArtifact);
+ LOGGER.debug("Saved directory artifact as zip: {} -> {}",
originalFile, tempZip);
+ // Clean up temp file after it's been saved to cache
+ Files.deleteIfExists(tempZip);
+ } else {
+ LOGGER.info("Skipping empty directory artifact: {}", originalFile);
}
}
@@ -623,29 +770,43 @@ public void produceDiffReport(CacheResult cacheResult,
Build build) {
}
private List<Artifact> artifactDtos(
- List<org.apache.maven.artifact.Artifact> attachedArtifacts,
HashAlgorithm digest, MavenProject project)
+ List<org.apache.maven.artifact.Artifact> attachedArtifacts,
+ HashAlgorithm digest,
+ MavenProject project,
+ ProjectCacheState state)
throws IOException {
List<Artifact> result = new ArrayList<>();
for (org.apache.maven.artifact.Artifact attachedArtifact :
attachedArtifacts) {
if (attachedArtifact.getFile() != null
&& isOutputArtifact(attachedArtifact.getFile().getName()))
{
- result.add(artifactDto(attachedArtifact, digest, project));
+ result.add(artifactDto(attachedArtifact, digest, project,
state));
}
}
return result;
}
private Artifact artifactDto(
- org.apache.maven.artifact.Artifact projectArtifact, HashAlgorithm
algorithm, MavenProject project)
+ org.apache.maven.artifact.Artifact projectArtifact,
+ HashAlgorithm algorithm,
+ MavenProject project,
+ ProjectCacheState state)
throws IOException {
final Artifact dto = DtoUtils.createDto(projectArtifact);
- if (projectArtifact.getFile() != null &&
projectArtifact.getFile().isFile()) {
+ if (projectArtifact.getFile() != null) {
final Path file = projectArtifact.getFile().toPath();
- dto.setFileHash(algorithm.hash(file));
- dto.setFileSize(Files.size(file));
+ // Only set hash and size for regular files (not directories like
target/classes for JPMS projects)
+ if (Files.isRegularFile(file)) {
+ dto.setFileHash(algorithm.hash(file));
+ dto.setFileSize(Files.size(file));
+ } else if (Files.isDirectory(file)) {
+ // Mark directory artifacts explicitly so we can unzip them on
restore
+ dto.setIsDirectory(true);
+ }
+
+ // Always set filePath (needed for artifact restoration)
// Get the relative path of any extra zip directory added to the
cache
- Path relativePath =
attachedResourcesPathsById.get(projectArtifact.getClassifier());
+ Path relativePath =
state.attachedResourcesPathsById.get(projectArtifact.getClassifier());
if (relativePath == null) {
// If the path was not a member of this map, we are in
presence of an original artifact.
// we get its location on the disk
@@ -899,15 +1060,29 @@ private void restoreGeneratedSources(Artifact artifact,
Path artifactFilePath, M
}
// TODO: move to config
- public void attachGeneratedSources(MavenProject project) throws
IOException {
+ public void attachGeneratedSources(MavenProject project, ProjectCacheState
state, long buildStartTime)
+ throws IOException {
final Path targetDir = Paths.get(project.getBuild().getDirectory());
final Path generatedSourcesDir =
targetDir.resolve("generated-sources");
- attachDirIfNotEmpty(generatedSourcesDir, targetDir, project,
OutputType.GENERATED_SOURCE, DEFAULT_FILE_GLOB);
+ attachDirIfNotEmpty(
+ generatedSourcesDir,
+ targetDir,
+ project,
+ state,
+ OutputType.GENERATED_SOURCE,
+ DEFAULT_FILE_GLOB,
+ buildStartTime);
final Path generatedTestSourcesDir =
targetDir.resolve("generated-test-sources");
attachDirIfNotEmpty(
- generatedTestSourcesDir, targetDir, project,
OutputType.GENERATED_SOURCE, DEFAULT_FILE_GLOB);
+ generatedTestSourcesDir,
+ targetDir,
+ project,
+ state,
+ OutputType.GENERATED_SOURCE,
+ DEFAULT_FILE_GLOB,
+ buildStartTime);
Set<String> sourceRoots = new TreeSet<>();
if (project.getCompileSourceRoots() != null) {
@@ -923,18 +1098,26 @@ public void attachGeneratedSources(MavenProject project)
throws IOException {
&& sourceRootPath.startsWith(targetDir)
&& !(sourceRootPath.startsWith(generatedSourcesDir)
||
sourceRootPath.startsWith(generatedTestSourcesDir))) { // dir within target
- attachDirIfNotEmpty(sourceRootPath, targetDir, project,
OutputType.GENERATED_SOURCE, DEFAULT_FILE_GLOB);
+ attachDirIfNotEmpty(
+ sourceRootPath,
+ targetDir,
+ project,
+ state,
+ OutputType.GENERATED_SOURCE,
+ DEFAULT_FILE_GLOB,
+ buildStartTime);
}
}
}
- private void attachOutputs(MavenProject project) throws IOException {
+ private void attachOutputs(MavenProject project, ProjectCacheState state,
long buildStartTime) throws IOException {
final List<DirName> attachedDirs = cacheConfig.getAttachedOutputs();
for (DirName dir : attachedDirs) {
final Path targetDir =
Paths.get(project.getBuild().getDirectory());
final Path outputDir = targetDir.resolve(dir.getValue());
if (isPathInsideProject(project, outputDir)) {
- attachDirIfNotEmpty(outputDir, targetDir, project,
OutputType.EXTRA_OUTPUT, dir.getGlob());
+ attachDirIfNotEmpty(
+ outputDir, targetDir, project, state,
OutputType.EXTRA_OUTPUT, dir.getGlob(), buildStartTime);
} else {
LOGGER.warn("Outside project output candidate directory
discarded ({})", outputDir.normalize());
}
@@ -945,16 +1128,25 @@ private void attachDirIfNotEmpty(
Path candidateSubDir,
Path parentDir,
MavenProject project,
+ ProjectCacheState state,
final OutputType attachedOutputType,
- final String glob)
+ final String glob,
+ final long buildStartTime)
throws IOException {
if (Files.isDirectory(candidateSubDir) && hasFiles(candidateSubDir)) {
final Path relativePath =
project.getBasedir().toPath().relativize(candidateSubDir);
- attachedResourceCounter++;
- final String classifier = attachedOutputType.getClassifierPrefix()
+ attachedResourceCounter;
+ state.attachedResourceCounter++;
+ final String classifier = attachedOutputType.getClassifierPrefix()
+ state.attachedResourceCounter;
+
+ // NOTE: No timestamp checking needed -
stagePreExistingArtifacts() ensures stale files
+ // are moved to staging. If files exist here, they're either:
+ // 1. Fresh files built during this session, or
+ // 2. Files restored from cache during this session
+ // Both cases are valid and should be cached.
+
boolean success = zipAndAttachArtifact(project, candidateSubDir,
classifier, glob);
if (success) {
- attachedResourcesPathsById.put(classifier, relativePath);
+ state.attachedResourcesPathsById.put(classifier, relativePath);
LOGGER.debug("Attached directory: {}", candidateSubDir);
}
}
@@ -973,6 +1165,305 @@ public FileVisitResult visitFile(Path path,
BasicFileAttributes basicFileAttribu
return hasFiles.booleanValue();
}
+ /**
+ * Move pre-existing build artifacts to staging directory to prevent
caching stale files.
+ *
+ * <p><b>Artifacts Staged:</b>
+ * <ul>
+ * <li>{@code target/classes} - Compiled main classes directory</li>
+ * <li>{@code target/test-classes} - Compiled test classes directory</li>
+ * <li>{@code target/*.jar} - Main project artifact (JAR/WAR files)</li>
+ * <li>Other directories configured via {@code attachedOutputs} in cache
configuration</li>
+ * </ul>
+ *
+ * <p><b>DESIGN RATIONALE - Staleness Detection via Staging Directory:</b>
+ *
+ * <p>This approach solves three critical problems that timestamp-based
checking cannot handle:
+ *
+ * <p><b>Problem 1: Future Timestamps from Clock Skew</b>
+ * <ul>
+ * <li>Machine A (clock ahead at 11:00 AM) builds and caches artifacts
+ * <li>Machine B (correct clock at 10:00 AM) restores cache
+ * <li>Restored files have timestamps from the future (11:00 AM)
+ * <li>User switches branches or updates sources (sources timestamped
10:02 AM)
+ * <li>Maven incremental compiler sees: sources (10:02 AM) < classes
(11:00 AM)
+ * <li>Maven skips compilation (thinks sources older than classes)
+ * <li>Wrong classes from old source version get cached!
+ * </ul>
+ *
+ * <p><b>Problem 2: Orphaned Class Files from Deleted Sources</b>
+ * <ul>
+ * <li>Version A has Foo.java → compiles Foo.class
+ * <li>Switch to Version B (no Foo.java)
+ * <li>Foo.class remains in target/classes (orphaned)
+ * <li>Cache miss on new version triggers mojos
+ * <li>Without protection, orphaned Foo.class gets cached
+ * <li>Future cache hits restore Foo.class (which shouldn't exist!)
+ * </ul>
+ *
+ * <p><b>Problem 3: Stale JARs/WARs from Previous Builds</b>
+ * <ul>
+ * <li>Yesterday: built myapp.jar on old version
+ * <li>Today: switched to new version, sources changed
+ * <li>mvn package runs (cache miss)
+ * <li>If JAR wasn't rebuilt, stale JAR could be cached
+ * </ul>
+ *
+ * <p><b>Solution: Staging Directory Physical Separation</b>
+ * <ul>
+ * <li>Before mojos run: Move pre-existing artifacts to
target/.maven-build-cache-stash/
+ * <li>Maven sees clean target/ with no pre-existing artifacts
+ * <li>Maven compiler MUST compile (can't skip based on timestamps)
+ * <li>Fresh correct files created in target/
+ * <li>save() only sees fresh files (stale ones are in staging directory)
+ * <li>After save(): Restore artifacts from staging (delete if fresh
version exists)
+ * </ul>
+ *
+ * <p><b>Why Better Than Timestamp Checking:</b>
+ * <ul>
+ * <li>No clock skew calculations needed
+ * <li>Physical file separation (not heuristics)
+ * <li>Forces correct incremental compilation
+ * <li>Handles interrupted builds gracefully (just delete staging
directory)
+ * <li>Simpler and more robust
+ * <li>Easier cleanup - delete one directory instead of filtering files
+ * </ul>
+ *
+ * <p><b>Interrupted Build Handling:</b>
+ * If staging directory exists from interrupted previous run, it's deleted
and recreated.
+ *
+ * @param session The Maven session
+ * @param project The Maven project being built
+ * @throws IOException if file move operations fail
+ */
+ public void stagePreExistingArtifacts(MavenSession session, MavenProject
project) throws IOException {
+ final ProjectCacheState state = getProjectState(project);
+ final Path multimoduleRoot = CacheUtils.getMultimoduleRoot(session);
+ final Path stagingDir =
multimoduleRoot.resolve("target").resolve("maven-build-cache-extension");
+
+ // Create or reuse staging directory from interrupted previous run
+ Files.createDirectories(stagingDir);
+ state.stagingDirectory = stagingDir;
+
+ // Collect all paths that will be cached
+ Set<Path> pathsToProcess = collectCachedArtifactPaths(project);
+
+ int movedCount = 0;
+ for (Path path : pathsToProcess) {
+ // Calculate path relative to multimodule root (preserves full
path including submodule)
+ Path relativePath = multimoduleRoot.relativize(path);
+ Path stagedPath = stagingDir.resolve(relativePath);
+
+ if (Files.isDirectory(path)) {
+ // If directory already exists in staging (from interrupted
run), remove it first
+ if (Files.exists(stagedPath)) {
+ deleteDirectory(stagedPath);
+ LOGGER.debug("Removed existing staged directory: {}",
stagedPath);
+ }
+ // Move entire directory to staging
+ Files.createDirectories(stagedPath.getParent());
+ Files.move(path, stagedPath);
+ movedCount++;
+ LOGGER.debug("Moved directory to staging: {} → {}",
relativePath, stagedPath);
+ } else if (Files.isRegularFile(path)) {
+ // If file already exists in staging (from interrupted run),
remove it first
+ if (Files.exists(stagedPath)) {
+ Files.delete(stagedPath);
+ LOGGER.debug("Removed existing staged file: {}",
stagedPath);
+ }
+ // Move individual file (e.g., JAR) to staging
+ Files.createDirectories(stagedPath.getParent());
+ Files.move(path, stagedPath);
+ movedCount++;
+ LOGGER.debug("Moved file to staging: {} → {}", relativePath,
stagedPath);
+ }
+ }
+
+ if (movedCount > 0) {
+ LOGGER.info(
+ "Moved {} pre-existing artifacts to staging directory to
prevent caching stale files", movedCount);
+ }
+ }
+
+ /**
+ * Collects paths to all artifacts that will be considered for caching for
the given project.
+ *
+ * <p>This includes:
+ * <ul>
+ * <li>the main project artifact file (for example, the built JAR), if
it has been produced, and</li>
+ * <li>any attached output directories configured via {@code
cacheConfig.getAttachedOutputs()} under the
+ * project's target directory, when {@code
cacheConfig.isCacheCompile()} is enabled.</li>
+ * </ul>
+ * Only paths that currently exist on disk are included in the returned
set; non-existent files or directories
+ * are ignored.
+ *
+ * @param project the Maven project whose artifact and attached output
paths should be collected
+ * @return a set of existing filesystem paths for the project's main
artifact and configured attached outputs
+ */
+ private Set<Path> collectCachedArtifactPaths(MavenProject project) {
+ Set<Path> paths = new HashSet<>();
+ final org.apache.maven.artifact.Artifact projectArtifact =
project.getArtifact();
+ final Path targetDir = Paths.get(project.getBuild().getDirectory());
+
+ // 1. Main project artifact (JAR file or target/classes directory)
+ if (projectArtifact.getFile() != null &&
projectArtifact.getFile().exists()) {
+ paths.add(projectArtifact.getFile().toPath());
+ }
+
+ // 2. Attached outputs from configuration (if cacheCompile enabled)
+ if (cacheConfig.isCacheCompile()) {
+ List<DirName> attachedDirs = cacheConfig.getAttachedOutputs();
+ for (DirName dir : attachedDirs) {
+ Path outputDir = targetDir.resolve(dir.getValue());
+ if (Files.exists(outputDir)) {
+ paths.add(outputDir);
+ }
+ }
+ }
+
+ return paths;
+ }
+
+ /**
+ * Restore artifacts from staging directory after save() completes.
+ *
+ * <p>For each artifact in staging:
+ * <ul>
+ * <li>If fresh version exists in target/: Delete staged version (was
rebuilt correctly)
+ * <li>If fresh version missing: Move staged version back to target/
(wasn't rebuilt, still valid)
+ * </ul>
+ *
+ * <p>This ensures:
+ * <ul>
+ * <li>save() only cached fresh files (stale ones were in staging
directory)
+ * <li>Developers see complete target/ directory after build
+ * <li>Incremental builds work correctly (unchanged files restored)
+ * </ul>
+ *
+ * <p>Finally, deletes the staging directory.
+ *
+ * @param session The Maven session
+ * @param project The Maven project being built
+ */
+ public void restoreStagedArtifacts(MavenSession session, MavenProject
project) {
+ final ProjectCacheState state = getProjectState(project);
+ final Path stagingDir = state.stagingDirectory;
+
+ if (stagingDir == null || !Files.exists(stagingDir)) {
+ return; // Nothing to restore
+ }
+
+ try {
+ final Path multimoduleRoot =
CacheUtils.getMultimoduleRoot(session);
+
+ // Collect directories to delete (where fresh versions exist)
+ final List<Path> dirsToDelete = new ArrayList<>();
+
+ // Walk staging directory and process files
+ Files.walkFileTree(stagingDir, new SimpleFileVisitor<Path>() {
+ @Override
+ public FileVisitResult preVisitDirectory(Path dir,
BasicFileAttributes attrs) throws IOException {
+ if (dir.equals(stagingDir)) {
+ return FileVisitResult.CONTINUE; // Skip root
+ }
+
+ Path relativePath = stagingDir.relativize(dir);
+ Path targetPath = multimoduleRoot.resolve(relativePath);
+
+ if (Files.exists(targetPath)) {
+ // Fresh directory exists - mark entire tree for
deletion
+ dirsToDelete.add(dir);
+ LOGGER.debug("Fresh directory exists, marking for
recursive deletion: {}", relativePath);
+ return FileVisitResult.SKIP_SUBTREE;
+ }
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult visitFile(Path file,
BasicFileAttributes attrs) throws IOException {
+ Path relativePath = stagingDir.relativize(file);
+ Path targetPath = multimoduleRoot.resolve(relativePath);
+
+ try {
+ // Atomically move file back if destination doesn't
exist
+ Files.createDirectories(targetPath.getParent());
+ Files.move(file, targetPath);
+ LOGGER.debug("Restored unchanged file from staging:
{}", relativePath);
+ } catch (FileAlreadyExistsException e) {
+ // Fresh file exists (was rebuilt) - delete stale
version
+ Files.delete(file);
+ LOGGER.debug("Fresh file exists, deleted stale file:
{}", relativePath);
+ }
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult postVisitDirectory(Path dir,
IOException exc) throws IOException {
+ if (exc != null) {
+ throw exc;
+ }
+ // Try to delete empty directories bottom-up
+ if (!dir.equals(stagingDir)) {
+ try {
+ Files.delete(dir);
+ LOGGER.debug("Deleted empty directory: {}",
stagingDir.relativize(dir));
+ } catch (IOException e) {
+ // Not empty yet - other modules may still have
files here
+ }
+ }
+ return FileVisitResult.CONTINUE;
+ }
+ });
+
+ // Recursively delete directories where fresh versions exist
+ for (Path dirToDelete : dirsToDelete) {
+ LOGGER.debug("Recursively deleting stale directory: {}",
stagingDir.relativize(dirToDelete));
+ deleteDirectory(dirToDelete);
+ }
+
+ // Try to delete staging directory itself if now empty
+ try {
+ Files.delete(stagingDir);
+ LOGGER.debug("Deleted empty staging directory: {}",
stagingDir);
+ } catch (IOException e) {
+ LOGGER.debug("Staging directory not empty, preserving for
other modules");
+ }
+
+ } catch (IOException e) {
+ LOGGER.warn("Failed to restore artifacts from staging directory:
{}", e.getMessage());
+ }
+
+ // Clear the staging directory reference
+ state.stagingDirectory = null;
+
+ // Remove the project state from map to free memory (called after
save() cleanup)
+ String key = getVersionlessProjectKey(project);
+ projectStates.remove(key);
+ }
+
+ /**
+ * Recursively delete a directory and all its contents.
+ */
+ private void deleteDirectory(Path dir) throws IOException {
+ if (!Files.exists(dir)) {
+ return;
+ }
+
+ Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
+ @Override
+ public FileVisitResult visitFile(Path file, BasicFileAttributes
attrs) throws IOException {
+ Files.delete(file);
+ return FileVisitResult.CONTINUE;
+ }
+
+ @Override
+ public FileVisitResult postVisitDirectory(Path dir, IOException
exc) throws IOException {
+ Files.delete(dir);
+ return FileVisitResult.CONTINUE;
+ }
+ });
+ }
+
private boolean isOutputArtifact(String name) {
List<Pattern> excludePatterns = cacheConfig.getExcludePatterns();
for (Pattern pattern : excludePatterns) {
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 27d6d94..452dcfb 100644
--- a/src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java
+++ b/src/main/java/org/apache/maven/buildcache/xml/CacheConfig.java
@@ -154,4 +154,15 @@ public interface CacheConfig {
* Flag to save in cache only if a build went through the clean lifecycle
*/
boolean isMandatoryClean();
+
+ /**
+ * Flag to cache compile phase outputs (classes, test-classes, generated
sources).
+ * When enabled (default), compile-only builds create cache entries that
can be restored
+ * by subsequent builds. When disabled, caching only occurs during package
phase or later.
+ * <p>
+ * Use: -Dmaven.build.cache.cacheCompile=(true|false)
+ * <p>
+ * Default: true
+ */
+ boolean isCacheCompile();
}
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 70ab047..7d7b91e 100644
--- a/src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java
+++ b/src/main/java/org/apache/maven/buildcache/xml/CacheConfigImpl.java
@@ -97,6 +97,7 @@ public class CacheConfigImpl implements
org.apache.maven.buildcache.xml.CacheCon
public static final String RESTORE_GENERATED_SOURCES_PROPERTY_NAME =
"maven.build.cache.restoreGeneratedSources";
public static final String ALWAYS_RUN_PLUGINS =
"maven.build.cache.alwaysRunPlugins";
public static final String MANDATORY_CLEAN =
"maven.build.cache.mandatoryClean";
+ public static final String CACHE_COMPILE =
"maven.build.cache.cacheCompile";
/**
* Flag to control if we should skip lookup for cached artifacts globally
or for a particular project even if
@@ -541,6 +542,11 @@ public boolean isMandatoryClean() {
return getProperty(MANDATORY_CLEAN,
getConfiguration().isMandatoryClean());
}
+ @Override
+ public boolean isCacheCompile() {
+ return getProperty(CACHE_COMPILE, true);
+ }
+
@Override
public String getId() {
checkInitializedState();
diff --git a/src/main/mdo/build-cache-build.mdo
b/src/main/mdo/build-cache-build.mdo
index 0e63935..2d80d85 100644
--- a/src/main/mdo/build-cache-build.mdo
+++ b/src/main/mdo/build-cache-build.mdo
@@ -244,6 +244,11 @@ under the License.
<name>filePath</name>
<type>String</type>
</field>
+ <field>
+ <name>isDirectory</name>
+ <type>boolean</type>
+ <description>Indicates if this artifact represents a directory
(e.g., target/classes) that was zipped for caching</description>
+ </field>
<!--/xs:sequence-->
</fields>
<!--/xs:complexType-->
diff --git a/src/site/markdown/how-to.md b/src/site/markdown/how-to.md
index a4ebbb7..ce7fa6d 100644
--- a/src/site/markdown/how-to.md
+++ b/src/site/markdown/how-to.md
@@ -227,3 +227,18 @@ Set attribute `excludeDependencies` to `true` in
`input/plugins/plugin` section:
</plugins>
</input>
```
+
+### I want to disable caching of compile-only builds
+
+By default, the cache extension saves build outputs when running compile-only
phases (like `mvn compile` or `mvn test-compile`).
+This allows subsequent builds to restore compiled classes without
recompilation. To disable this behavior and only cache
+builds that reach the package phase or later:
+
+```shell
+mvn compile -Dmaven.build.cache.cacheCompile=false
+```
+
+This is useful when:
+* You want to ensure cache entries always contain packaged artifacts (JARs,
WARs, etc.)
+* Your workflow relies on artifacts being available in the local repository
+* You prefer the traditional behavior where only complete builds are cached
diff --git a/src/site/markdown/parameters.md b/src/site/markdown/parameters.md
index e248b00..c03886a 100644
--- a/src/site/markdown/parameters.md
+++ b/src/site/markdown/parameters.md
@@ -39,6 +39,7 @@ This document contains various configuration parameters
supported by the cache e
| `-Dmaven.build.cache.skipCache=(true/false)` | Skip looking up
artifacts in caches. Does not affect writing artifacts to caches, disables only
reading when set to `true` | May be used to trigger a forced
rebuild when matching artifacts do exist in caches |
| `-Dmaven.build.cache.skipSave=(true/false)` | Skip writing build
result in caches. Does not affect reading from the cache. |
Configuring MR builds to benefits from the cache, but restricting writes to the
`master` branch |
| `-Dmaven.build.cache.mandatoryClean=(true/false)` | Enable or
disable the necessity to execute the `clean` phase in order to store the build
result in cache | Reducing the risk to save
"wrong" files in cache in a local dev environnement |
+| `-Dmaven.build.cache.cacheCompile=(true/false)` | Cache compile
phase outputs (classes, test-classes, generated sources). When enabled
(default), compile-only builds create cache entries that can be restored by
subsequent builds. When disabled, caching only occurs during package phase or
later. | Performance optimization for incremental builds
|
### Project-level properties
diff --git
a/src/test/java/org/apache/maven/buildcache/its/BuildExtensionTest.java
b/src/test/java/org/apache/maven/buildcache/its/BuildExtensionTest.java
index 382ba76..bf324db 100644
--- a/src/test/java/org/apache/maven/buildcache/its/BuildExtensionTest.java
+++ b/src/test/java/org/apache/maven/buildcache/its/BuildExtensionTest.java
@@ -58,6 +58,7 @@ void skipSaving(Verifier verifier) throws
VerificationException, IOException {
verifier.getCliOptions().clear();
verifier.addCliOption("-D" + CACHE_LOCATION_PROPERTY_NAME + "=" +
tempDirectory.toAbsolutePath());
verifier.addCliOption("-D" + SKIP_SAVE + "=true");
+ verifier.addCliOption("--debug");
verifier.setLogFileName("../log-1.txt");
verifier.executeGoal("verify");
diff --git
a/src/test/java/org/apache/maven/buildcache/its/CacheCompileDisabledTest.java
b/src/test/java/org/apache/maven/buildcache/its/CacheCompileDisabledTest.java
new file mode 100644
index 0000000..d4241d4
--- /dev/null
+++
b/src/test/java/org/apache/maven/buildcache/its/CacheCompileDisabledTest.java
@@ -0,0 +1,139 @@
+/*
+ * 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.its;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.stream.Stream;
+
+import org.apache.maven.buildcache.its.junit.IntegrationTest;
+import org.apache.maven.it.VerificationException;
+import org.apache.maven.it.Verifier;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests that the maven.build.cache.cacheCompile property correctly disables
+ * caching of compile-phase outputs.
+ */
+@IntegrationTest("src/test/projects/issue-393-compile-restore")
+class CacheCompileDisabledTest {
+
+ @Test
+ void compileDoesNotCacheWhenDisabled(Verifier verifier) throws
VerificationException, IOException {
+ verifier.setAutoclean(false);
+
+ // The actual cache is stored in target/build-cache (relative to the
extension root, not test project)
+ Path localCache =
Paths.get(System.getProperty("maven.multiModuleProjectDirectory"))
+ .resolve("target/build-cache");
+
+ // Clean cache before test
+ if (Files.exists(localCache)) {
+ deleteDirectory(localCache);
+ }
+
+ // First compile with cacheCompile disabled - compile only the app
module to avoid dependency issues
+ verifier.setLogFileName("../log-compile-disabled.txt");
+ verifier.addCliOption("-Dmaven.build.cache.cacheCompile=false");
+ verifier.addCliOption("-pl");
+ verifier.addCliOption("app");
+ verifier.executeGoals(Arrays.asList("clean", "compile"));
+ verifier.verifyErrorFreeLog();
+
+ // Verify NO cache entry was created (no buildinfo.xml in local cache)
+ boolean hasCacheEntry;
+ try (Stream<Path> walk = Files.walk(localCache)) {
+ hasCacheEntry = walk.anyMatch(p ->
p.getFileName().toString().equals("buildinfo.xml"));
+ }
+ assertFalse(hasCacheEntry, "Cache entry should NOT be created when
maven.build.cache.cacheCompile=false");
+
+ // Clean project and run compile again
+ verifier.setLogFileName("../log-compile-disabled-2.txt");
+ verifier.addCliOption("-Dmaven.build.cache.cacheCompile=false");
+ verifier.addCliOption("-pl");
+ verifier.addCliOption("app");
+ verifier.executeGoals(Arrays.asList("clean", "compile"));
+ verifier.verifyErrorFreeLog();
+
+ // Verify cache miss (should NOT restore from cache)
+ Path logFile =
Paths.get(verifier.getBasedir()).getParent().resolve("log-compile-disabled-2.txt");
+ String logContent = new String(Files.readAllBytes(logFile));
+ assertFalse(
+ logContent.contains("Found cached build, restoring"),
+ "Should NOT restore from cache when cacheCompile was
disabled");
+ }
+
+ @Test
+ void compileCreatesCacheEntryWhenEnabled(Verifier verifier) throws
VerificationException, IOException {
+ verifier.setAutoclean(false);
+
+ // The actual cache is stored in target/build-cache (relative to the
extension root, not test project)
+ Path localCache =
Paths.get(System.getProperty("maven.multiModuleProjectDirectory"))
+ .resolve("target/build-cache");
+
+ // Clean cache before test
+ if (Files.exists(localCache)) {
+ deleteDirectory(localCache);
+ }
+
+ // First compile with cacheCompile enabled (default) - compile only
the app module
+ verifier.setLogFileName("../log-compile-enabled.txt");
+ verifier.addCliOption("-pl");
+ verifier.addCliOption("app");
+ verifier.executeGoals(Arrays.asList("clean", "compile"));
+ verifier.verifyErrorFreeLog();
+
+ // Verify cache entry WAS created
+ boolean hasCacheEntry;
+ try (Stream<Path> walk = Files.walk(localCache)) {
+ hasCacheEntry = walk.anyMatch(p ->
p.getFileName().toString().equals("buildinfo.xml"));
+ }
+ assertTrue(hasCacheEntry, "Cache entry should be created when
maven.build.cache.cacheCompile=true (default)");
+
+ // Clean project and run compile again
+ verifier.setLogFileName("../log-compile-enabled-2.txt");
+ verifier.addCliOption("-pl");
+ verifier.addCliOption("app");
+ verifier.executeGoals(Arrays.asList("clean", "compile"));
+ verifier.verifyErrorFreeLog();
+
+ // Verify cache hit (should restore from cache)
+ verifier.verifyTextInLog("Found cached build, restoring");
+ verifier.verifyTextInLog("Skipping plugin execution (cached):
compiler:compile");
+ }
+
+ private void deleteDirectory(Path directory) throws IOException {
+ if (Files.exists(directory)) {
+ try (Stream<Path> walk = Files.walk(directory)) {
+ walk.sorted((a, b) -> b.compareTo(a)).forEach(path -> {
+ try {
+ Files.delete(path);
+ } catch (IOException e) {
+ // Ignore
+ }
+ });
+ }
+ }
+ }
+}
diff --git
a/src/test/java/org/apache/maven/buildcache/its/Issue393CompileRestoreTest.java
b/src/test/java/org/apache/maven/buildcache/its/Issue393CompileRestoreTest.java
new file mode 100644
index 0000000..7c9714b
--- /dev/null
+++
b/src/test/java/org/apache/maven/buildcache/its/Issue393CompileRestoreTest.java
@@ -0,0 +1,53 @@
+/*
+ * 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.its;
+
+import java.util.Arrays;
+
+import org.apache.maven.buildcache.its.junit.IntegrationTest;
+import org.apache.maven.it.VerificationException;
+import org.apache.maven.it.Verifier;
+import org.junit.jupiter.api.Test;
+
+@IntegrationTest("src/test/projects/issue-393-compile-restore")
+class Issue393CompileRestoreTest {
+
+ @Test
+ void restoresAttachedOutputsAfterCompileOnlyBuild(Verifier verifier)
throws VerificationException {
+ verifier.setAutoclean(false);
+
+ verifier.setLogFileName("../log-compile.txt");
+ verifier.executeGoals(Arrays.asList("clean", "compile"));
+ verifier.verifyErrorFreeLog();
+ verifier.verifyFilePresent("app/target/classes/module-info.class");
+
verifier.verifyFilePresent("consumer/target/classes/module-info.class");
+
+ verifier.setLogFileName("../log-verify.txt");
+ verifier.executeGoals(Arrays.asList("clean", "verify"));
+ verifier.verifyErrorFreeLog();
+ verifier.verifyTextInLog(
+ "Found cached build, restoring
org.apache.maven.caching.test.jpms:issue-393-app from cache");
+ verifier.verifyTextInLog("Skipping plugin execution (cached):
compiler:compile");
+
+ verifier.verifyFilePresent("app/target/classes/module-info.class");
+
verifier.verifyFilePresent("consumer/target/classes/module-info.class");
+ verifier.verifyFilePresent(
+
"consumer/target/test-classes/org/apache/maven/caching/test/jpms/consumer/ConsumerTest.class");
+ }
+}
diff --git
a/src/test/java/org/apache/maven/buildcache/its/MandatoryCleanTest.java
b/src/test/java/org/apache/maven/buildcache/its/MandatoryCleanTest.java
index 5b25af9..d1253d8 100644
--- a/src/test/java/org/apache/maven/buildcache/its/MandatoryCleanTest.java
+++ b/src/test/java/org/apache/maven/buildcache/its/MandatoryCleanTest.java
@@ -52,6 +52,7 @@ void simple(Verifier verifier) throws VerificationException,
IOException {
Path tempDirectory =
Files.createTempDirectory("simple-mandatory-clean");
verifier.getCliOptions().clear();
verifier.addCliOption("-D" + CACHE_LOCATION_PROPERTY_NAME + "=" +
tempDirectory.toAbsolutePath());
+ verifier.addCliOption("--debug");
verifier.setLogFileName("../log-1.txt");
verifier.executeGoal("verify");
@@ -99,6 +100,7 @@ void simple(Verifier verifier) throws VerificationException,
IOException {
void disabledViaProperty(Verifier verifier) throws VerificationException {
verifier.setAutoclean(false);
+ verifier.addCliOption("--debug");
verifier.setLogFileName("../log-1.txt");
verifier.executeGoal("verify");
@@ -118,6 +120,7 @@ void disabledViaProperty(Verifier verifier) throws
VerificationException {
verifier.setLogFileName("../log-2.txt");
verifier.getCliOptions().clear();
+ verifier.addCliOption("--debug");
// With "true", we do not change the initially expected behaviour
verifier.addCliOption("-D" + CacheConfigImpl.MANDATORY_CLEAN +
"=true");
verifier.executeGoal("verify");
@@ -137,6 +140,7 @@ void disabledViaProperty(Verifier verifier) throws
VerificationException {
// With "false", we remove the need for the clean phase
verifier.getCliOptions().clear();
+ verifier.addCliOption("--debug");
verifier.addCliOption("-D" + CacheConfigImpl.MANDATORY_CLEAN +
"=false");
verifier.setLogFileName("../log-3.txt");
verifier.executeGoal("verify");
diff --git
a/src/test/java/org/apache/maven/buildcache/its/StaleArtifactTest.java
b/src/test/java/org/apache/maven/buildcache/its/StaleArtifactTest.java
new file mode 100644
index 0000000..52ba081
--- /dev/null
+++ b/src/test/java/org/apache/maven/buildcache/its/StaleArtifactTest.java
@@ -0,0 +1,83 @@
+/*
+ * 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.its;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.util.Arrays;
+
+import org.apache.maven.buildcache.its.junit.IntegrationTest;
+import org.apache.maven.it.VerificationException;
+import org.apache.maven.it.Verifier;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests that stale artifacts from source changes are not cached.
+ * Simulates the scenario:
+ * 1. Build version A (creates target/classes with old content)
+ * 2. Source changes (e.g., branch switch, external update), but
target/classes remains
+ * 3. Build without 'mvn clean' - should NOT cache stale target/classes
+ */
+@IntegrationTest("src/test/projects/stale-artifact")
+class StaleArtifactTest {
+
+ @Test
+ void staleDirectoryNotCached(Verifier verifier) throws
VerificationException, IOException {
+ verifier.setAutoclean(false);
+
+ // Build version A: compile project
+ verifier.setLogFileName("../log-version-a.txt");
+ verifier.executeGoals(Arrays.asList("clean", "compile"));
+ verifier.verifyErrorFreeLog();
+
+ Path classesDir = Paths.get(verifier.getBasedir(), "target",
"classes");
+ Path appClass = classesDir.resolve("org/example/App.class");
+ assertTrue(Files.exists(appClass), "App.class should exist after
compile");
+
+ // Simulate source change (e.g., branch switch, external update) by:
+ // 1. Modifying source file (simulates different source version)
+ // 2. Making class file appear OLDER than build start time (stale)
+ Path sourceFile = Paths.get(verifier.getBasedir(),
"src/main/java/org/example/App.java");
+ String content = new String(Files.readAllBytes(sourceFile), "UTF-8");
+ Files.write(sourceFile, content.replace("Version A", "Version
B").getBytes("UTF-8"));
+
+ // Backdate the class file to simulate stale artifact from previous
build
+ FileTime oldTime = FileTime.from(Instant.now().minusSeconds(3600)); //
1 hour ago
+ Files.setLastModifiedTime(appClass, oldTime);
+
+ // Try to build without clean (simulates developer workflow)
+ verifier.setLogFileName("../log-version-b.txt");
+ verifier.executeGoals(Arrays.asList("compile"));
+ verifier.verifyErrorFreeLog();
+
+ // Verify that compiler detected source change and recompiled
+ // (class file should have new timestamp after recompile)
+ FileTime newTime = Files.getLastModifiedTime(appClass);
+ assertTrue(
+ newTime.toMillis() > oldTime.toMillis(),
+ "Compiler should have recompiled stale class (new timestamp: "
+ newTime + ", old timestamp: " + oldTime
+ + ")");
+ }
+}
diff --git
a/src/test/java/org/apache/maven/buildcache/its/StaleMultimoduleArtifactTest.java
b/src/test/java/org/apache/maven/buildcache/its/StaleMultimoduleArtifactTest.java
new file mode 100644
index 0000000..70c5bbc
--- /dev/null
+++
b/src/test/java/org/apache/maven/buildcache/its/StaleMultimoduleArtifactTest.java
@@ -0,0 +1,105 @@
+/*
+ * 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.its;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.util.Arrays;
+
+import org.apache.maven.buildcache.its.junit.IntegrationTest;
+import org.apache.maven.it.VerificationException;
+import org.apache.maven.it.Verifier;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests that stale artifacts from source changes in multimodule projects are
not cached.
+ * Verifies that the staging directory correctly preserves the full path
structure including
+ * submodule paths relative to the multimodule root.
+ *
+ * <p>Scenario:
+ * <ol>
+ * <li>Build multimodule project version A (creates
module1/target/classes)</li>
+ * <li>Simulate source change (source changes, target/classes remains
stale)</li>
+ * <li>Build without 'mvn clean' - should stage stale files with full path
preservation</li>
+ * <li>Verify staging directory structure:
target/.maven-build-cache-stash/module1/target/classes</li>
+ * </ol>
+ */
+@IntegrationTest("src/test/projects/stale-multimodule-artifact")
+class StaleMultimoduleArtifactTest {
+
+ @Test
+ void staleMultimoduleDirectoriesCorrectlyStaged(Verifier verifier) throws
VerificationException, IOException {
+ verifier.setAutoclean(false);
+
+ // Build version A: compile multimodule project
+ verifier.setLogFileName("../log-multimodule-version-a.txt");
+ verifier.executeGoals(Arrays.asList("clean", "compile"));
+ verifier.verifyErrorFreeLog();
+
+ // Verify module1 class file was created
+ Path basedir = Paths.get(verifier.getBasedir());
+ Path module1ClassesDir = basedir.resolve("module1/target/classes");
+ Path module1Class =
module1ClassesDir.resolve("org/example/Module1.class");
+ assertTrue(Files.exists(module1Class), "Module1.class should exist
after compile");
+
+ // Simulate source change (e.g., branch switch, external update) by:
+ // 1. Modifying source file (simulates different source version)
+ // 2. Making class file appear OLDER than build start time (stale)
+ Path sourceFile =
basedir.resolve("module1/src/main/java/org/example/Module1.java");
+ String content = new String(Files.readAllBytes(sourceFile), "UTF-8");
+ Files.write(sourceFile, content.replace("Version A", "Version
B").getBytes("UTF-8"));
+
+ // Backdate the class file to simulate stale artifact from previous
build
+ FileTime oldTime = FileTime.from(Instant.now().minusSeconds(3600)); //
1 hour ago
+ Files.setLastModifiedTime(module1Class, oldTime);
+
+ // Build without clean (simulates developer workflow)
+ // The staleness detection should:
+ // 1. Move module1/target/classes to
target/.maven-build-cache-stash/module1/target/classes
+ // 2. Force recompilation (Maven sees clean module1/target/)
+ // 3. After save(), restore or discard based on whether files were
rebuilt
+ verifier.setLogFileName("../log-multimodule-version-b.txt");
+ verifier.executeGoals(Arrays.asList("compile"));
+ verifier.verifyErrorFreeLog();
+
+ // Verify that compiler detected source change and recompiled
+ // (class file should have new timestamp after recompile)
+ FileTime newTime = Files.getLastModifiedTime(module1Class);
+ assertTrue(
+ newTime.toMillis() > oldTime.toMillis(),
+ "Compiler should have recompiled stale class (new timestamp: "
+ newTime + ", old timestamp: " + oldTime
+ + ")");
+
+ // Verify that staging directory was cleaned up after restore
+ // After a successful build, all files should be either:
+ // 1. Restored (moved back to original location) - for unchanged files
+ // 2. Discarded (deleted from staging) - for rebuilt files
+ // So the staging directory should be empty or deleted
+ Path stagingDir =
basedir.resolve("target/maven-build-cache-extension");
+ assertTrue(
+ !Files.exists(stagingDir),
+ "Staging directory should be deleted after all files are
restored or discarded");
+ }
+}
diff --git a/src/test/projects/issue-393-compile-restore/.mvn/extensions.xml
b/src/test/projects/issue-393-compile-restore/.mvn/extensions.xml
new file mode 100644
index 0000000..8568c4d
--- /dev/null
+++ b/src/test/projects/issue-393-compile-restore/.mvn/extensions.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ 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.
+
+-->
+<extensions>
+ <extension>
+ <groupId>org.apache.maven.extensions</groupId>
+ <artifactId>maven-build-cache-extension</artifactId>
+ <version>${projectVersion}</version>
+ </extension>
+</extensions>
diff --git
a/src/test/projects/issue-393-compile-restore/.mvn/maven-build-cache-config.xml
b/src/test/projects/issue-393-compile-restore/.mvn/maven-build-cache-config.xml
new file mode 100644
index 0000000..6a91d57
--- /dev/null
+++
b/src/test/projects/issue-393-compile-restore/.mvn/maven-build-cache-config.xml
@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!--
+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.
+-->
+
+<cache xmlns="http://maven.apache.org/BUILD-CACHE-CONFIG/1.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/BUILD-CACHE-CONFIG/1.2.0
https://maven.apache.org/xsd/build-cache-config-1.2.0.xsd">
+ <configuration>
+ <attachedOutputs>
+ <dirNames>
+ <dirName>classes</dirName>
+ <dirName>test-classes</dirName>
+ <dirName>maven-status</dirName>
+ </dirNames>
+ </attachedOutputs>
+ </configuration>
+</cache>
diff --git a/src/test/projects/issue-393-compile-restore/app/pom.xml
b/src/test/projects/issue-393-compile-restore/app/pom.xml
new file mode 100644
index 0000000..f732e70
--- /dev/null
+++ b/src/test/projects/issue-393-compile-restore/app/pom.xml
@@ -0,0 +1,37 @@
+<!--
+
+ 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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.maven.caching.test.jpms</groupId>
+ <artifactId>issue-393-compile-restore</artifactId>
+ <version>0.0.1-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>issue-393-app</artifactId>
+ <name>Issue 393 Compile Restore - App Module</name>
+ <packaging>jar</packaging>
+
+</project>
diff --git
a/src/test/projects/issue-393-compile-restore/app/src/main/java/module-info.java
b/src/test/projects/issue-393-compile-restore/app/src/main/java/module-info.java
new file mode 100644
index 0000000..757ca82
--- /dev/null
+++
b/src/test/projects/issue-393-compile-restore/app/src/main/java/module-info.java
@@ -0,0 +1,21 @@
+/*
+ * 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.
+ */
+module org.apache.maven.caching.test.jpms.app {
+ exports org.apache.maven.caching.test.jpms.app;
+}
diff --git
a/src/test/projects/issue-393-compile-restore/app/src/main/java/org/apache/maven/caching/test/jpms/app/Greeting.java
b/src/test/projects/issue-393-compile-restore/app/src/main/java/org/apache/maven/caching/test/jpms/app/Greeting.java
new file mode 100644
index 0000000..cbc1ace
--- /dev/null
+++
b/src/test/projects/issue-393-compile-restore/app/src/main/java/org/apache/maven/caching/test/jpms/app/Greeting.java
@@ -0,0 +1,30 @@
+/*
+ * 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.caching.test.jpms.app;
+
+public final class Greeting {
+
+ private Greeting() {
+ // utility
+ }
+
+ public static String message() {
+ return "hello from module";
+ }
+}
diff --git a/src/test/projects/issue-393-compile-restore/consumer/pom.xml
b/src/test/projects/issue-393-compile-restore/consumer/pom.xml
new file mode 100644
index 0000000..ab7a00e
--- /dev/null
+++ b/src/test/projects/issue-393-compile-restore/consumer/pom.xml
@@ -0,0 +1,64 @@
+<!--
+
+ 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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.apache.maven.caching.test.jpms</groupId>
+ <artifactId>issue-393-compile-restore</artifactId>
+ <version>0.0.1-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>issue-393-consumer</artifactId>
+ <name>Issue 393 Compile Restore - Consumer Module</name>
+ <packaging>jar</packaging>
+
+ <dependencies>
+ <dependency>
+ <groupId>${project.groupId}</groupId>
+ <artifactId>issue-393-app</artifactId>
+ <version>${project.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <version>5.10.2</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-surefire-plugin</artifactId>
+ <version>3.2.5</version>
+ <configuration>
+ <useModulePath>true</useModulePath>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
+</project>
diff --git
a/src/test/projects/issue-393-compile-restore/consumer/src/main/java/module-info.java
b/src/test/projects/issue-393-compile-restore/consumer/src/main/java/module-info.java
new file mode 100644
index 0000000..76abdde
--- /dev/null
+++
b/src/test/projects/issue-393-compile-restore/consumer/src/main/java/module-info.java
@@ -0,0 +1,22 @@
+/*
+ * 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.
+ */
+module org.apache.maven.caching.test.jpms.consumer {
+ requires org.apache.maven.caching.test.jpms.app;
+ exports org.apache.maven.caching.test.jpms.consumer;
+}
diff --git
a/src/test/projects/issue-393-compile-restore/consumer/src/main/java/org/apache/maven/caching/test/jpms/consumer/Consumer.java
b/src/test/projects/issue-393-compile-restore/consumer/src/main/java/org/apache/maven/caching/test/jpms/consumer/Consumer.java
new file mode 100644
index 0000000..d983de1
--- /dev/null
+++
b/src/test/projects/issue-393-compile-restore/consumer/src/main/java/org/apache/maven/caching/test/jpms/consumer/Consumer.java
@@ -0,0 +1,32 @@
+/*
+ * 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.caching.test.jpms.consumer;
+
+import org.apache.maven.caching.test.jpms.app.Greeting;
+
+public final class Consumer {
+
+ private Consumer() {
+ // utility
+ }
+
+ public static String message() {
+ return Greeting.message();
+ }
+}
diff --git
a/src/test/projects/issue-393-compile-restore/consumer/src/test/java/org/apache/maven/caching/test/jpms/consumer/ConsumerTest.java
b/src/test/projects/issue-393-compile-restore/consumer/src/test/java/org/apache/maven/caching/test/jpms/consumer/ConsumerTest.java
new file mode 100644
index 0000000..813a973
--- /dev/null
+++
b/src/test/projects/issue-393-compile-restore/consumer/src/test/java/org/apache/maven/caching/test/jpms/consumer/ConsumerTest.java
@@ -0,0 +1,31 @@
+/*
+ * 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.caching.test.jpms.consumer;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+class ConsumerTest {
+
+ @Test
+ void messageIsProvidedByUpstreamModule() {
+ assertEquals("hello from module", Consumer.message());
+ }
+}
diff --git a/src/test/projects/issue-393-compile-restore/pom.xml
b/src/test/projects/issue-393-compile-restore/pom.xml
new file mode 100644
index 0000000..f504867
--- /dev/null
+++ b/src/test/projects/issue-393-compile-restore/pom.xml
@@ -0,0 +1,42 @@
+<!--
+
+ 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.
+
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+ <groupId>org.apache.maven.caching.test.jpms</groupId>
+ <artifactId>issue-393-compile-restore</artifactId>
+ <version>0.0.1-SNAPSHOT</version>
+ <packaging>pom</packaging>
+ <name>Issue 393 Compile Restore Aggregator</name>
+
+ <modules>
+ <module>app</module>
+ <module>consumer</module>
+ </modules>
+
+ <properties>
+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+ <maven.compiler.release>17</maven.compiler.release>
+ </properties>
+
+</project>
diff --git a/src/test/projects/stale-artifact/.mvn/maven-build-cache-config.xml
b/src/test/projects/stale-artifact/.mvn/maven-build-cache-config.xml
new file mode 100644
index 0000000..c3d63ea
--- /dev/null
+++ b/src/test/projects/stale-artifact/.mvn/maven-build-cache-config.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ 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.
+
+-->
+<cache xmlns="http://maven.apache.org/BUILD-CACHE-CONFIG/1.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/BUILD-CACHE-CONFIG/1.2.0
https://maven.apache.org/xsd/build-cache-config-1.2.0.xsd">
+ <configuration>
+ <attachedOutputs>
+ <dirNames>
+ <dirName>classes</dirName>
+ </dirNames>
+ </attachedOutputs>
+ </configuration>
+</cache>
diff --git a/src/test/projects/stale-artifact/pom.xml
b/src/test/projects/stale-artifact/pom.xml
new file mode 100644
index 0000000..087372f
--- /dev/null
+++ b/src/test/projects/stale-artifact/pom.xml
@@ -0,0 +1,46 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ 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.
+
+-->
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns="http://maven.apache.org/POM/4.0.0"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+ <groupId>org.apache.maven.caching.test</groupId>
+ <artifactId>stale-artifact</artifactId>
+ <version>0.0.1-SNAPSHOT</version>
+ <packaging>jar</packaging>
+
+ <properties>
+ <maven.compiler.source>1.8</maven.compiler.source>
+ <maven.compiler.target>1.8</maven.compiler.target>
+ </properties>
+
+ <build>
+ <extensions>
+ <extension>
+ <groupId>org.apache.maven.extensions</groupId>
+ <artifactId>maven-build-cache-extension</artifactId>
+ <version>${projectVersion}</version>
+ </extension>
+ </extensions>
+ </build>
+
+</project>
diff --git
a/src/test/projects/stale-artifact/src/main/java/org/example/App.java
b/src/test/projects/stale-artifact/src/main/java/org/example/App.java
new file mode 100644
index 0000000..6ebdd9e
--- /dev/null
+++ b/src/test/projects/stale-artifact/src/main/java/org/example/App.java
@@ -0,0 +1,25 @@
+/*
+ * 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.example;
+
+public class App {
+ public static void main(String[] args) {
+ System.out.println("Version A");
+ }
+}
diff --git
a/src/test/projects/stale-multimodule-artifact/.mvn/maven-build-cache-config.xml
b/src/test/projects/stale-multimodule-artifact/.mvn/maven-build-cache-config.xml
new file mode 100644
index 0000000..c3d63ea
--- /dev/null
+++
b/src/test/projects/stale-multimodule-artifact/.mvn/maven-build-cache-config.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+
+ 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.
+
+-->
+<cache xmlns="http://maven.apache.org/BUILD-CACHE-CONFIG/1.2.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/BUILD-CACHE-CONFIG/1.2.0
https://maven.apache.org/xsd/build-cache-config-1.2.0.xsd">
+ <configuration>
+ <attachedOutputs>
+ <dirNames>
+ <dirName>classes</dirName>
+ </dirNames>
+ </attachedOutputs>
+ </configuration>
+</cache>
diff --git a/src/test/projects/stale-multimodule-artifact/module1/pom.xml
b/src/test/projects/stale-multimodule-artifact/module1/pom.xml
new file mode 100644
index 0000000..329dd9a
--- /dev/null
+++ b/src/test/projects/stale-multimodule-artifact/module1/pom.xml
@@ -0,0 +1,31 @@
+<!--
+ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.example</groupId>
+ <artifactId>stale-multimodule-parent</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>module1</artifactId>
+</project>
diff --git
a/src/test/projects/stale-multimodule-artifact/module1/src/main/java/org/example/Module1.java
b/src/test/projects/stale-multimodule-artifact/module1/src/main/java/org/example/Module1.java
new file mode 100644
index 0000000..2622957
--- /dev/null
+++
b/src/test/projects/stale-multimodule-artifact/module1/src/main/java/org/example/Module1.java
@@ -0,0 +1,25 @@
+/*
+ * 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.example;
+
+public class Module1 {
+ public static void main(String[] args) {
+ System.out.println("Module1 Version A");
+ }
+}
diff --git a/src/test/projects/stale-multimodule-artifact/pom.xml
b/src/test/projects/stale-multimodule-artifact/pom.xml
new file mode 100644
index 0000000..4dd27ac
--- /dev/null
+++ b/src/test/projects/stale-multimodule-artifact/pom.xml
@@ -0,0 +1,47 @@
+<!--
+ 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.
+-->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/maven-v4_0_0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <groupId>org.example</groupId>
+ <artifactId>stale-multimodule-parent</artifactId>
+ <version>1.0-SNAPSHOT</version>
+ <packaging>pom</packaging>
+
+ <properties>
+ <maven.compiler.source>1.8</maven.compiler.source>
+ <maven.compiler.target>1.8</maven.compiler.target>
+ </properties>
+
+ <build>
+ <extensions>
+ <extension>
+ <groupId>org.apache.maven.extensions</groupId>
+ <artifactId>maven-build-cache-extension</artifactId>
+ <version>${projectVersion}</version>
+ </extension>
+ </extensions>
+ </build>
+
+ <modules>
+ <module>module1</module>
+ </modules>
+</project>