View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.buildcache;
20  
21  import javax.annotation.Nonnull;
22  import javax.inject.Inject;
23  import javax.inject.Named;
24  import javax.inject.Provider;
25  
26  import java.io.File;
27  import java.io.FileNotFoundException;
28  import java.io.IOException;
29  import java.lang.reflect.Field;
30  import java.lang.reflect.InvocationTargetException;
31  import java.lang.reflect.Method;
32  import java.nio.charset.StandardCharsets;
33  import java.nio.file.FileAlreadyExistsException;
34  import java.nio.file.FileVisitResult;
35  import java.nio.file.Files;
36  import java.nio.file.Path;
37  import java.nio.file.Paths;
38  import java.nio.file.SimpleFileVisitor;
39  import java.nio.file.StandardCopyOption;
40  import java.nio.file.attribute.BasicFileAttributes;
41  import java.util.ArrayList;
42  import java.util.Collections;
43  import java.util.HashMap;
44  import java.util.HashSet;
45  import java.util.List;
46  import java.util.Map;
47  import java.util.Objects;
48  import java.util.Optional;
49  import java.util.Set;
50  import java.util.TreeSet;
51  import java.util.UUID;
52  import java.util.concurrent.ConcurrentHashMap;
53  import java.util.concurrent.ConcurrentMap;
54  import java.util.concurrent.Future;
55  import java.util.concurrent.FutureTask;
56  import java.util.concurrent.atomic.AtomicBoolean;
57  import java.util.function.UnaryOperator;
58  import java.util.regex.Pattern;
59  
60  import org.apache.commons.io.FilenameUtils;
61  import org.apache.commons.lang3.StringUtils;
62  import org.apache.commons.lang3.Strings;
63  import org.apache.commons.lang3.mutable.MutableBoolean;
64  import org.apache.maven.SessionScoped;
65  import org.apache.maven.artifact.handler.ArtifactHandler;
66  import org.apache.maven.artifact.handler.manager.ArtifactHandlerManager;
67  import org.apache.maven.buildcache.artifact.ArtifactRestorationReport;
68  import org.apache.maven.buildcache.artifact.OutputType;
69  import org.apache.maven.buildcache.artifact.RestoredArtifact;
70  import org.apache.maven.buildcache.checksum.MavenProjectInput;
71  import org.apache.maven.buildcache.hash.HashAlgorithm;
72  import org.apache.maven.buildcache.hash.HashFactory;
73  import org.apache.maven.buildcache.xml.Build;
74  import org.apache.maven.buildcache.xml.CacheConfig;
75  import org.apache.maven.buildcache.xml.CacheSource;
76  import org.apache.maven.buildcache.xml.DtoUtils;
77  import org.apache.maven.buildcache.xml.XmlService;
78  import org.apache.maven.buildcache.xml.build.Artifact;
79  import org.apache.maven.buildcache.xml.build.CompletedExecution;
80  import org.apache.maven.buildcache.xml.build.DigestItem;
81  import org.apache.maven.buildcache.xml.build.ProjectsInputInfo;
82  import org.apache.maven.buildcache.xml.build.Scm;
83  import org.apache.maven.buildcache.xml.config.DirName;
84  import org.apache.maven.buildcache.xml.config.PropertyName;
85  import org.apache.maven.buildcache.xml.config.TrackedProperty;
86  import org.apache.maven.buildcache.xml.diff.Diff;
87  import org.apache.maven.buildcache.xml.report.CacheReport;
88  import org.apache.maven.buildcache.xml.report.ProjectReport;
89  import org.apache.maven.execution.MavenSession;
90  import org.apache.maven.execution.MojoExecutionEvent;
91  import org.apache.maven.plugin.MojoExecution;
92  import org.apache.maven.plugin.descriptor.Parameter;
93  import org.apache.maven.project.MavenProject;
94  import org.apache.maven.project.MavenProjectHelper;
95  import org.codehaus.plexus.util.ReflectionUtils;
96  import org.eclipse.aether.RepositorySystem;
97  import org.slf4j.Logger;
98  import org.slf4j.LoggerFactory;
99  
100 import static java.nio.file.StandardOpenOption.CREATE;
101 import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING;
102 import static org.apache.commons.lang3.StringUtils.isNotBlank;
103 import static org.apache.commons.lang3.StringUtils.split;
104 import static org.apache.maven.buildcache.CacheResult.empty;
105 import static org.apache.maven.buildcache.CacheResult.failure;
106 import static org.apache.maven.buildcache.CacheResult.partialSuccess;
107 import static org.apache.maven.buildcache.CacheResult.success;
108 import static org.apache.maven.buildcache.RemoteCacheRepository.BUILDINFO_XML;
109 import static org.apache.maven.buildcache.checksum.KeyUtils.getVersionlessProjectKey;
110 import static org.apache.maven.buildcache.checksum.MavenProjectInput.CACHE_IMPLEMENTATION_VERSION;
111 
112 /**
113  * CacheControllerImpl
114  */
115 @SessionScoped
116 @Named
117 @SuppressWarnings("unused")
118 public class CacheControllerImpl implements CacheController {
119 
120     private static final Logger LOGGER = LoggerFactory.getLogger(CacheControllerImpl.class);
121     private static final String DEFAULT_FILE_GLOB = "*";
122     public static final String ERROR_MSG_RESTORATION_OUTSIDE_PROJECT =
123             "Blocked an attempt to restore files outside of a project directory: ";
124 
125     private final MavenProjectHelper projectHelper;
126     private final ArtifactHandlerManager artifactHandlerManager;
127     private final XmlService xmlService;
128     private final CacheConfig cacheConfig;
129     private final LocalCacheRepository localCache;
130     private final RemoteCacheRepository remoteCache;
131     private final ConcurrentMap<String, CacheResult> cacheResults = new ConcurrentHashMap<>();
132     private final Provider<LifecyclePhasesHelper> providerLifecyclePhasesHelper;
133     private volatile Map<String, MavenProject> projectIndex;
134     private final ProjectInputCalculator projectInputCalculator;
135     private final RestoredArtifactHandler restoreArtifactHandler;
136     private volatile Scm scm;
137 
138     /**
139      * Per-project cache state to ensure thread safety in multi-threaded builds.
140      * Each project gets isolated state for resource tracking, counters, and restored output tracking.
141      */
142     private static class ProjectCacheState {
143         final Map<String, Path> attachedResourcesPathsById = new HashMap<>();
144         int attachedResourceCounter = 0;
145         final Set<String> restoredOutputClassifiers = new HashSet<>();
146 
147         /**
148          * Tracks the staging directory path where pre-existing artifacts are moved.
149          * Artifacts are moved here before mojos run and restored after save() completes.
150          */
151         Path stagingDirectory;
152     }
153 
154     private final ConcurrentMap<String, ProjectCacheState> projectStates = new ConcurrentHashMap<>();
155 
156     /**
157      * Get or create cache state for the given project (thread-safe).
158      */
159     private ProjectCacheState getProjectState(MavenProject project) {
160         String key = getVersionlessProjectKey(project);
161         return projectStates.computeIfAbsent(key, k -> new ProjectCacheState());
162     }
163     // CHECKSTYLE_OFF: ParameterNumber
164     @Inject
165     public CacheControllerImpl(
166             MavenProjectHelper projectHelper,
167             RepositorySystem repoSystem,
168             ArtifactHandlerManager artifactHandlerManager,
169             XmlService xmlService,
170             LocalCacheRepository localCache,
171             RemoteCacheRepository remoteCache,
172             CacheConfig cacheConfig,
173             ProjectInputCalculator projectInputCalculator,
174             RestoredArtifactHandler restoreArtifactHandler,
175             Provider<LifecyclePhasesHelper> providerLifecyclePhasesHelper) {
176         // CHECKSTYLE_OFF: ParameterNumber
177         this.projectHelper = projectHelper;
178         this.localCache = localCache;
179         this.remoteCache = remoteCache;
180         this.cacheConfig = cacheConfig;
181         this.artifactHandlerManager = artifactHandlerManager;
182         this.xmlService = xmlService;
183         this.providerLifecyclePhasesHelper = providerLifecyclePhasesHelper;
184         this.projectInputCalculator = projectInputCalculator;
185         this.restoreArtifactHandler = restoreArtifactHandler;
186     }
187 
188     @Override
189     @Nonnull
190     public CacheResult findCachedBuild(
191             MavenSession session, MavenProject project, List<MojoExecution> mojoExecutions, boolean skipCache) {
192         final LifecyclePhasesHelper lifecyclePhasesHelper = providerLifecyclePhasesHelper.get();
193         final String highestPhase = lifecyclePhasesHelper.resolveHighestLifecyclePhase(project, mojoExecutions);
194 
195         if (!lifecyclePhasesHelper.isLaterPhaseThanClean(highestPhase)) {
196             return empty();
197         }
198 
199         String projectName = getVersionlessProjectKey(project);
200 
201         ProjectsInputInfo inputInfo = projectInputCalculator.calculateInput(project);
202 
203         final CacheContext context = new CacheContext(project, inputInfo, session);
204 
205         CacheResult result = empty(context);
206         if (!skipCache) {
207 
208             LOGGER.info("Attempting to restore project {} from build cache", projectName);
209 
210             // remote build first
211             if (cacheConfig.isRemoteCacheEnabled()) {
212                 result = findCachedBuild(mojoExecutions, context);
213                 if (!result.isSuccess() && result.getContext() != null) {
214                     LOGGER.info("Remote cache is incomplete or missing, trying local build for {}", projectName);
215                 }
216             }
217 
218             if (!result.isSuccess() && result.getContext() != null) {
219                 CacheResult localBuild = findLocalBuild(mojoExecutions, context);
220                 if (localBuild.isSuccess() || (localBuild.isPartialSuccess() && !result.isPartialSuccess())) {
221                     result = localBuild;
222                 } else {
223                     LOGGER.info(
224                             "Local build was not found by checksum {} for {}", inputInfo.getChecksum(), projectName);
225                 }
226             }
227         } else {
228             LOGGER.info(
229                     "Project {} is marked as requiring force rebuild, will skip lookup in build cache", projectName);
230         }
231         cacheResults.put(getVersionlessProjectKey(project), result);
232 
233         return result;
234     }
235 
236     private CacheResult findCachedBuild(List<MojoExecution> mojoExecutions, CacheContext context) {
237         Optional<Build> cachedBuild = Optional.empty();
238         try {
239             cachedBuild = localCache.findBuild(context);
240             if (cachedBuild.isPresent()) {
241                 return analyzeResult(context, mojoExecutions, cachedBuild.get());
242             }
243         } catch (Exception e) {
244             LOGGER.error("Cannot read cached remote build", e);
245         }
246         return cachedBuild.map(build -> failure(build, context)).orElseGet(() -> empty(context));
247     }
248 
249     private CacheResult findLocalBuild(List<MojoExecution> mojoExecutions, CacheContext context) {
250         Optional<Build> localBuild = Optional.empty();
251         try {
252             localBuild = localCache.findLocalBuild(context);
253             if (localBuild.isPresent()) {
254                 return analyzeResult(context, mojoExecutions, localBuild.get());
255             }
256         } catch (Exception e) {
257             LOGGER.error("Cannot read local build", e);
258         }
259         return localBuild.map(build -> failure(build, context)).orElseGet(() -> empty(context));
260     }
261 
262     private CacheResult analyzeResult(CacheContext context, List<MojoExecution> mojoExecutions, Build build) {
263         try {
264             final ProjectsInputInfo inputInfo = context.getInputInfo();
265             String projectName = getVersionlessProjectKey(context.getProject());
266 
267             LOGGER.info(
268                     "Found cached build, restoring {} from cache by checksum {}", projectName, inputInfo.getChecksum());
269             LOGGER.debug("Cached build details: {}", build);
270 
271             final String cacheImplementationVersion = build.getCacheImplementationVersion();
272             if (!CACHE_IMPLEMENTATION_VERSION.equals(cacheImplementationVersion)) {
273                 LOGGER.warn(
274                         "Maven and cached build implementations mismatch, caching might not work correctly. "
275                                 + "Implementation version: " + CACHE_IMPLEMENTATION_VERSION + ", cached build: {}",
276                         build.getCacheImplementationVersion());
277             }
278 
279             final LifecyclePhasesHelper lifecyclePhasesHelper = providerLifecyclePhasesHelper.get();
280             List<MojoExecution> cachedSegment =
281                     lifecyclePhasesHelper.getCachedSegment(context.getProject(), mojoExecutions, build);
282             List<MojoExecution> missingMojos = build.getMissingExecutions(cachedSegment);
283 
284             if (!missingMojos.isEmpty()) {
285                 LOGGER.warn(
286                         "Cached build doesn't contains all requested plugin executions "
287                                 + "(missing: {}), cannot restore",
288                         missingMojos);
289                 return failure(build, context);
290             }
291 
292             if (!isCachedSegmentPropertiesPresent(context.getProject(), build, cachedSegment)) {
293                 LOGGER.info("Cached build violates cache rules, cannot restore");
294                 return failure(build, context);
295             }
296 
297             final String highestRequestPhase =
298                     lifecyclePhasesHelper.resolveHighestLifecyclePhase(context.getProject(), mojoExecutions);
299 
300             if (lifecyclePhasesHelper.isLaterPhaseThanBuild(highestRequestPhase, build)
301                     && !canIgnoreMissingSegment(context.getProject(), build, mojoExecutions)) {
302                 LOGGER.info(
303                         "Project {} restored partially. Highest cached goal: {}, requested: {}",
304                         projectName,
305                         build.getHighestCompletedGoal(),
306                         highestRequestPhase);
307                 return partialSuccess(build, context);
308             }
309 
310             return success(build, context);
311 
312         } catch (Exception e) {
313             LOGGER.error("Failed to restore project", e);
314             localCache.clearCache(context);
315             return failure(build, context);
316         }
317     }
318 
319     private boolean canIgnoreMissingSegment(MavenProject project, Build info, List<MojoExecution> mojoExecutions) {
320         final LifecyclePhasesHelper lifecyclePhasesHelper = providerLifecyclePhasesHelper.get();
321         final List<MojoExecution> postCachedSegment =
322                 lifecyclePhasesHelper.getPostCachedSegment(project, mojoExecutions, info);
323 
324         for (MojoExecution mojoExecution : postCachedSegment) {
325             if (!cacheConfig.canIgnore(mojoExecution)) {
326                 return false;
327             }
328         }
329         return true;
330     }
331 
332     private UnaryOperator<File> createRestorationToDiskConsumer(final MavenProject project, final Artifact artifact) {
333 
334         if (cacheConfig.isRestoreOnDiskArtifacts() && MavenProjectInput.isRestoreOnDiskArtifacts(project)) {
335             Path restorationPath = project.getBasedir().toPath().resolve(artifact.getFilePath());
336             final AtomicBoolean restored = new AtomicBoolean(false);
337             return file -> {
338                 // Set to restored even if it fails later, we don't want multiple try
339                 if (restored.compareAndSet(false, true)) {
340                     verifyRestorationInsideProject(project, restorationPath);
341                     try {
342                         restoreArtifactToDisk(file, artifact, restorationPath);
343                     } catch (IOException e) {
344                         LOGGER.error("Cannot restore file " + artifact.getFileName(), e);
345                         throw new RuntimeException(e);
346                     }
347                 }
348                 return restorationPath.toFile();
349             };
350         }
351         // Return a consumer doing nothing
352         return file -> file;
353     }
354 
355     /**
356      * Restores an artifact from cache to disk, handling both regular files and directory artifacts.
357      * Directory artifacts (cached as zips) are unzipped back to their original directory structure.
358      */
359     private void restoreArtifactToDisk(File cachedFile, Artifact artifact, Path restorationPath) throws IOException {
360         // Check the explicit isDirectory flag set during save.
361         // Directory artifacts (e.g., target/classes) are saved as zips and need to be unzipped on restore.
362         if (artifact.isIsDirectory()) {
363             restoreDirectoryArtifact(cachedFile, artifact, restorationPath);
364         } else {
365             restoreRegularFileArtifact(cachedFile, artifact, restorationPath);
366         }
367     }
368 
369     /**
370      * Restores a directory artifact by unzipping the cached zip file.
371      */
372     private void restoreDirectoryArtifact(File cachedZip, Artifact artifact, Path restorationPath) throws IOException {
373         if (!Files.exists(restorationPath)) {
374             Files.createDirectories(restorationPath);
375         }
376         CacheUtils.unzip(cachedZip.toPath(), restorationPath, cacheConfig.isPreservePermissions());
377         LOGGER.debug("Restored directory artifact by unzipping: {} -> {}", artifact.getFileName(), restorationPath);
378     }
379 
380     /**
381      * Restores a regular file artifact by copying it from cache.
382      */
383     private void restoreRegularFileArtifact(File cachedFile, Artifact artifact, Path restorationPath)
384             throws IOException {
385         Files.createDirectories(restorationPath.getParent());
386         Files.copy(cachedFile.toPath(), restorationPath, StandardCopyOption.REPLACE_EXISTING);
387         LOGGER.debug("Restored file on disk ({} to {})", artifact.getFileName(), restorationPath);
388     }
389 
390     private boolean isPathInsideProject(final MavenProject project, Path path) {
391         Path restorationPath = path.toAbsolutePath().normalize();
392         return restorationPath.startsWith(project.getBasedir().toPath());
393     }
394 
395     private void verifyRestorationInsideProject(final MavenProject project, Path path) {
396         if (!isPathInsideProject(project, path)) {
397             Path normalized = path.toAbsolutePath().normalize();
398             LOGGER.error(ERROR_MSG_RESTORATION_OUTSIDE_PROJECT + normalized);
399             throw new RuntimeException(ERROR_MSG_RESTORATION_OUTSIDE_PROJECT + normalized);
400         }
401     }
402 
403     @Override
404     public ArtifactRestorationReport restoreProjectArtifacts(CacheResult cacheResult) {
405 
406         LOGGER.debug("Restore project artifacts");
407         final Build build = cacheResult.getBuildInfo();
408         final CacheContext context = cacheResult.getContext();
409         final MavenProject project = context.getProject();
410         final ProjectCacheState state = getProjectState(project);
411         ArtifactRestorationReport restorationReport = new ArtifactRestorationReport();
412 
413         try {
414             RestoredArtifact restoredProjectArtifact = null;
415             List<RestoredArtifact> restoredAttachedArtifacts = new ArrayList<>();
416 
417             if (build.getArtifact() != null && isNotBlank(build.getArtifact().getFileName())) {
418                 final Artifact artifactInfo = build.getArtifact();
419                 String originalVersion = artifactInfo.getVersion();
420                 artifactInfo.setVersion(project.getVersion());
421                 // TODO if remote is forced, probably need to refresh or reconcile all files
422                 final Future<File> downloadTask =
423                         createDownloadTask(cacheResult, context, project, artifactInfo, originalVersion);
424                 restoredProjectArtifact = restoredArtifact(
425                         project.getArtifact(),
426                         artifactInfo.getType(),
427                         artifactInfo.getClassifier(),
428                         downloadTask,
429                         createRestorationToDiskConsumer(project, artifactInfo));
430                 if (!cacheConfig.isLazyRestore()) {
431                     restoredProjectArtifact.getFile();
432                 }
433             }
434 
435             for (Artifact attachedArtifactInfo : build.getAttachedArtifacts()) {
436                 String originalVersion = attachedArtifactInfo.getVersion();
437                 attachedArtifactInfo.setVersion(project.getVersion());
438                 if (isNotBlank(attachedArtifactInfo.getFileName())) {
439                     OutputType outputType = OutputType.fromClassifier(attachedArtifactInfo.getClassifier());
440                     if (OutputType.ARTIFACT != outputType) {
441                         // restoring generated sources / extra output might be unnecessary in CI, could be disabled for
442                         // performance reasons
443                         // it may also be disabled on a per-project level (defaults to true - enable)
444                         if (cacheConfig.isRestoreGeneratedSources()
445                                 && MavenProjectInput.isRestoreGeneratedSources(project)) {
446                             // Set this value before trying the restoration, to keep a trace of the attempt if it fails
447                             restorationReport.setRestoredFilesInProjectDirectory(true);
448                             // generated sources artifact
449                             final Path attachedArtifactFile =
450                                     localCache.getArtifactFile(context, cacheResult.getSource(), attachedArtifactInfo);
451                             restoreGeneratedSources(attachedArtifactInfo, attachedArtifactFile, project);
452                             // Track this classifier as restored so save() includes it even with old timestamp
453                             state.restoredOutputClassifiers.add(attachedArtifactInfo.getClassifier());
454                         }
455                     } else {
456                         Future<File> downloadTask = createDownloadTask(
457                                 cacheResult, context, project, attachedArtifactInfo, originalVersion);
458                         final RestoredArtifact restoredAttachedArtifact = restoredArtifact(
459                                 restoredProjectArtifact == null ? project.getArtifact() : restoredProjectArtifact,
460                                 attachedArtifactInfo.getType(),
461                                 attachedArtifactInfo.getClassifier(),
462                                 downloadTask,
463                                 createRestorationToDiskConsumer(project, attachedArtifactInfo));
464                         if (!cacheConfig.isLazyRestore()) {
465                             restoredAttachedArtifact.getFile();
466                         }
467                         restoredAttachedArtifacts.add(restoredAttachedArtifact);
468                     }
469                 }
470             }
471             // Actually modify project at the end in case something went wrong during restoration,
472             // in which case, the project is unmodified and we continue with normal build.
473             if (restoredProjectArtifact != null) {
474                 project.setArtifact(restoredProjectArtifact);
475                 // need to include package lifecycle to save build info for incremental builds
476                 if (!project.hasLifecyclePhase("package")) {
477                     project.addLifecyclePhase("package");
478                 }
479             }
480             restoredAttachedArtifacts.forEach(project::addAttachedArtifact);
481             restorationReport.setSuccess(true);
482         } catch (Exception e) {
483             LOGGER.debug("Cannot restore cache, continuing with normal build.", e);
484         }
485         return restorationReport;
486     }
487 
488     /**
489      * Helper method similar to {@link org.apache.maven.project.MavenProjectHelper#attachArtifact} to work specifically
490      * with restored from cache artifacts
491      */
492     private RestoredArtifact restoredArtifact(
493             org.apache.maven.artifact.Artifact parent,
494             String artifactType,
495             String artifactClassifier,
496             Future<File> artifactFile,
497             UnaryOperator<File> restoreToDiskConsumer) {
498         ArtifactHandler handler = null;
499 
500         if (artifactType != null) {
501             handler = artifactHandlerManager.getArtifactHandler(artifactType);
502         }
503 
504         if (handler == null) {
505             handler = artifactHandlerManager.getArtifactHandler("jar");
506         }
507 
508         // todo: probably need update download url to cache
509         RestoredArtifact artifact = new RestoredArtifact(
510                 parent, artifactFile, artifactType, artifactClassifier, handler, restoreToDiskConsumer);
511         artifact.setResolved(true);
512 
513         return artifact;
514     }
515 
516     private Future<File> createDownloadTask(
517             CacheResult cacheResult,
518             CacheContext context,
519             MavenProject project,
520             Artifact artifact,
521             String originalVersion) {
522         final FutureTask<File> downloadTask = new FutureTask<>(() -> {
523             LOGGER.debug("Downloading artifact {}", artifact.getArtifactId());
524             final Path artifactFile = localCache.getArtifactFile(context, cacheResult.getSource(), artifact);
525 
526             if (!Files.exists(artifactFile)) {
527                 throw new FileNotFoundException("Missing file for cached build, cannot restore. File: " + artifactFile);
528             }
529             LOGGER.debug("Downloaded artifact {} to: {}", artifact.getArtifactId(), artifactFile);
530             return restoreArtifactHandler
531                     .adjustArchiveArtifactVersion(project, originalVersion, artifactFile)
532                     .toFile();
533         });
534         if (!cacheConfig.isLazyRestore()) {
535             downloadTask.run();
536         }
537         return downloadTask;
538     }
539 
540     @Override
541     public void save(
542             CacheResult cacheResult,
543             List<MojoExecution> mojoExecutions,
544             Map<String, MojoExecutionEvent> executionEvents) {
545         CacheContext context = cacheResult.getContext();
546 
547         if (context == null || context.getInputInfo() == null) {
548             LOGGER.info("Cannot save project in cache, skipping");
549             return;
550         }
551 
552         final MavenProject project = context.getProject();
553         final MavenSession session = context.getSession();
554         final ProjectCacheState state = getProjectState(project);
555         try {
556             state.attachedResourcesPathsById.clear();
557             state.attachedResourceCounter = 0;
558 
559             // Get build start time to filter out stale artifacts from previous builds
560             final long buildStartTime = session.getRequest().getStartTime().getTime();
561 
562             final HashFactory hashFactory = cacheConfig.getHashFactory();
563             final HashAlgorithm algorithm = hashFactory.createAlgorithm();
564             final org.apache.maven.artifact.Artifact projectArtifact = project.getArtifact();
565 
566             // Cache compile outputs (classes, test-classes, generated sources) if enabled
567             // This allows compile-only builds to create restorable cache entries
568             // Can be disabled with -Dmaven.build.cache.cacheCompile=false to reduce IO overhead
569             final boolean cacheCompile = cacheConfig.isCacheCompile();
570             if (cacheCompile) {
571                 attachGeneratedSources(project, state, buildStartTime);
572                 attachOutputs(project, state, buildStartTime);
573             }
574 
575             final List<org.apache.maven.artifact.Artifact> attachedArtifacts =
576                     project.getAttachedArtifacts() != null ? project.getAttachedArtifacts() : Collections.emptyList();
577             final List<Artifact> attachedArtifactDtos = artifactDtos(attachedArtifacts, algorithm, project, state);
578             // Always create artifact DTO - if package phase hasn't run, the file will be null
579             // and restoration will safely skip it. This ensures all builds have an artifact DTO.
580             final Artifact projectArtifactDto = artifactDto(project.getArtifact(), algorithm, project, state);
581 
582             List<CompletedExecution> completedExecution = buildExecutionInfo(mojoExecutions, executionEvents);
583 
584             // CRITICAL: Don't create incomplete cache entries!
585             // Only save cache entry if we have SOMETHING useful to restore.
586             // Exclude consumer POMs (Maven metadata) from the "useful artifacts" check.
587             // This prevents the bug where:
588             //   1. mvn compile (cacheCompile=false) creates cache entry with only metadata
589             //   2. mvn compile (cacheCompile=true) tries to restore incomplete cache and fails
590             //
591             // Save cache entry if ANY of these conditions are met:
592             // 1. Project artifact file exists:
593             //    a) Regular file (JAR/WAR/etc from package phase)
594             //    b) Directory (target/classes from compile-only builds) - only if cacheCompile=true
595             // 2. Has attached artifacts (classes/test-classes from cacheCompile=true)
596             // 3. POM project with plugin executions (worth caching to skip plugin execution on cache hit)
597             //
598             // NOTE: No timestamp checking needed - stagePreExistingArtifacts() ensures only fresh files
599             // are visible (stale files are moved to staging directory).
600 
601             // Check if project artifact is valid (exists and is correct type)
602             boolean hasArtifactFile = projectArtifact.getFile() != null
603                     && projectArtifact.getFile().exists()
604                     && (projectArtifact.getFile().isFile()
605                             || (cacheCompile && projectArtifact.getFile().isDirectory()));
606             boolean hasAttachedArtifacts = !attachedArtifactDtos.isEmpty()
607                     && attachedArtifactDtos.stream()
608                             .anyMatch(a -> !"consumer".equals(a.getClassifier()) || !"pom".equals(a.getType()));
609             // Only save POM projects if they executed plugins (not just aggregator POMs with no work)
610             boolean isPomProjectWithWork = "pom".equals(project.getPackaging()) && !completedExecution.isEmpty();
611 
612             if (!hasArtifactFile && !hasAttachedArtifacts && !isPomProjectWithWork) {
613                 LOGGER.info(
614                         "Skipping cache save: no artifacts to save ({}only metadata present)",
615                         cacheCompile ? "" : "cacheCompile=false, ");
616                 return;
617             }
618 
619             final Build build = new Build(
620                     session.getGoals(),
621                     projectArtifactDto,
622                     attachedArtifactDtos,
623                     context.getInputInfo(),
624                     completedExecution,
625                     hashFactory.getAlgorithm());
626             populateGitInfo(build, session);
627             build.getDto().set_final(cacheConfig.isSaveToRemoteFinal());
628             cacheResults.put(getVersionlessProjectKey(project), CacheResult.rebuilt(cacheResult, build));
629 
630             localCache.beforeSave(context);
631 
632             // Save project artifact file if it exists (created by package or compile phase)
633             if (projectArtifact.getFile() != null) {
634                 saveProjectArtifact(cacheResult, projectArtifact, project);
635             }
636             for (org.apache.maven.artifact.Artifact attachedArtifact : attachedArtifacts) {
637                 if (attachedArtifact.getFile() != null) {
638                     boolean storeArtifact =
639                             isOutputArtifact(attachedArtifact.getFile().getName());
640                     if (storeArtifact) {
641                         localCache.saveArtifactFile(cacheResult, attachedArtifact);
642                     } else {
643                         LOGGER.debug(
644                                 "Skipping attached project artifact '{}' = "
645                                         + " it is marked for exclusion from caching",
646                                 attachedArtifact.getFile().getName());
647                     }
648                 }
649             }
650 
651             localCache.saveBuildInfo(cacheResult, build);
652 
653             if (cacheConfig.isBaselineDiffEnabled()) {
654                 produceDiffReport(cacheResult, build);
655             }
656 
657         } catch (Exception e) {
658             LOGGER.error("Failed to save project, cleaning cache. Project: {}", project, e);
659             try {
660                 localCache.clearCache(context);
661             } catch (Exception ex) {
662                 LOGGER.error("Failed to clean cache due to unexpected error:", ex);
663             }
664         } finally {
665             // Cleanup project state to free memory, but preserve stagingDirectory for restore
666             // Note: stagingDirectory must persist until restoreStagedArtifacts() is called
667             state.attachedResourcesPathsById.clear();
668             state.attachedResourceCounter = 0;
669             state.restoredOutputClassifiers.clear();
670             // stagingDirectory is NOT cleared here - it's cleared in restoreStagedArtifacts()
671         }
672     }
673 
674     /**
675      * Saves a project artifact to cache, handling both regular files and directory artifacts.
676      * Directory artifacts (e.g., target/classes from compile-only builds) are zipped before saving
677      * since Files.copy() cannot handle directories.
678      */
679     private void saveProjectArtifact(
680             CacheResult cacheResult, org.apache.maven.artifact.Artifact projectArtifact, MavenProject project)
681             throws IOException {
682         File originalFile = projectArtifact.getFile();
683         try {
684             if (originalFile.isDirectory()) {
685                 saveDirectoryArtifact(cacheResult, projectArtifact, project, originalFile);
686             } else {
687                 // Regular file (JAR/WAR) - save directly
688                 localCache.saveArtifactFile(cacheResult, projectArtifact);
689             }
690         } finally {
691             // Restore original file reference in case it was temporarily changed
692             projectArtifact.setFile(originalFile);
693         }
694     }
695 
696     /**
697      * Saves a directory artifact by zipping it first, then saving the zip to cache.
698      */
699     private void saveDirectoryArtifact(
700             CacheResult cacheResult,
701             org.apache.maven.artifact.Artifact projectArtifact,
702             MavenProject project,
703             File originalFile)
704             throws IOException {
705         Path tempZip = Files.createTempFile("maven-cache-", "-" + project.getArtifactId() + ".zip");
706         boolean hasFiles = CacheUtils.zip(originalFile.toPath(), tempZip, "*", cacheConfig.isPreservePermissions());
707         if (hasFiles) {
708             // Temporarily replace artifact file with zip for saving
709             projectArtifact.setFile(tempZip.toFile());
710             localCache.saveArtifactFile(cacheResult, projectArtifact);
711             LOGGER.debug("Saved directory artifact as zip: {} -> {}", originalFile, tempZip);
712             // Clean up temp file after it's been saved to cache
713             Files.deleteIfExists(tempZip);
714         } else {
715             LOGGER.info("Skipping empty directory artifact: {}", originalFile);
716         }
717     }
718 
719     public void produceDiffReport(CacheResult cacheResult, Build build) {
720         MavenProject project = cacheResult.getContext().getProject();
721         Optional<Build> baselineHolder = remoteCache.findBaselineBuild(project);
722         if (baselineHolder.isPresent()) {
723             Build baseline = baselineHolder.get();
724             String outputDirectory = project.getBuild().getDirectory();
725             Path reportOutputDir = Paths.get(outputDirectory, "incremental-maven");
726             LOGGER.info("Saving cache builds diff to: {}", reportOutputDir);
727             Diff diff = new CacheDiff(build.getDto(), baseline.getDto(), cacheConfig).compare();
728             try {
729                 Files.createDirectories(reportOutputDir);
730                 final ProjectsInputInfo baselineInputs = baseline.getDto().getProjectsInputInfo();
731                 final String checksum = baselineInputs.getChecksum();
732                 Files.write(
733                         reportOutputDir.resolve("buildinfo-baseline-" + checksum + ".xml"),
734                         xmlService.toBytes(baseline.getDto()),
735                         TRUNCATE_EXISTING,
736                         CREATE);
737                 Files.write(
738                         reportOutputDir.resolve("buildinfo-" + checksum + ".xml"),
739                         xmlService.toBytes(build.getDto()),
740                         TRUNCATE_EXISTING,
741                         CREATE);
742                 Files.write(
743                         reportOutputDir.resolve("buildsdiff-" + checksum + ".xml"),
744                         xmlService.toBytes(diff),
745                         TRUNCATE_EXISTING,
746                         CREATE);
747                 final Optional<DigestItem> pom =
748                         CacheDiff.findPom(build.getDto().getProjectsInputInfo());
749                 if (pom.isPresent()) {
750                     Files.write(
751                             reportOutputDir.resolve("effective-pom-" + checksum + ".xml"),
752                             pom.get().getValue().getBytes(StandardCharsets.UTF_8),
753                             TRUNCATE_EXISTING,
754                             CREATE);
755                 }
756                 final Optional<DigestItem> baselinePom = CacheDiff.findPom(baselineInputs);
757                 if (baselinePom.isPresent()) {
758                     Files.write(
759                             reportOutputDir.resolve("effective-pom-baseline-" + baselineInputs.getChecksum() + ".xml"),
760                             baselinePom.get().getValue().getBytes(StandardCharsets.UTF_8),
761                             TRUNCATE_EXISTING,
762                             CREATE);
763                 }
764             } catch (IOException e) {
765                 LOGGER.error("Cannot produce build diff for project", e);
766             }
767         } else {
768             LOGGER.info("Cannot find project in baseline build, skipping diff");
769         }
770     }
771 
772     private List<Artifact> artifactDtos(
773             List<org.apache.maven.artifact.Artifact> attachedArtifacts,
774             HashAlgorithm digest,
775             MavenProject project,
776             ProjectCacheState state)
777             throws IOException {
778         List<Artifact> result = new ArrayList<>();
779         for (org.apache.maven.artifact.Artifact attachedArtifact : attachedArtifacts) {
780             if (attachedArtifact.getFile() != null
781                     && isOutputArtifact(attachedArtifact.getFile().getName())) {
782                 result.add(artifactDto(attachedArtifact, digest, project, state));
783             }
784         }
785         return result;
786     }
787 
788     private Artifact artifactDto(
789             org.apache.maven.artifact.Artifact projectArtifact,
790             HashAlgorithm algorithm,
791             MavenProject project,
792             ProjectCacheState state)
793             throws IOException {
794         final Artifact dto = DtoUtils.createDto(projectArtifact);
795         if (projectArtifact.getFile() != null) {
796             final Path file = projectArtifact.getFile().toPath();
797 
798             // Only set hash and size for regular files (not directories like target/classes for JPMS projects)
799             if (Files.isRegularFile(file)) {
800                 dto.setFileHash(algorithm.hash(file));
801                 dto.setFileSize(Files.size(file));
802             } else if (Files.isDirectory(file)) {
803                 // Mark directory artifacts explicitly so we can unzip them on restore
804                 dto.setIsDirectory(true);
805             }
806 
807             // Always set filePath (needed for artifact restoration)
808             // Get the relative path of any extra zip directory added to the cache
809             Path relativePath = state.attachedResourcesPathsById.get(projectArtifact.getClassifier());
810             if (relativePath == null) {
811                 // If the path was not a member of this map, we are in presence of an original artifact.
812                 // we get its location on the disk
813                 relativePath = project.getBasedir().toPath().relativize(file.toAbsolutePath());
814             }
815             dto.setFilePath(FilenameUtils.separatorsToUnix(relativePath.toString()));
816         }
817         return dto;
818     }
819 
820     private List<CompletedExecution> buildExecutionInfo(
821             List<MojoExecution> mojoExecutions, Map<String, MojoExecutionEvent> executionEvents) {
822         List<CompletedExecution> list = new ArrayList<>();
823         for (MojoExecution mojoExecution : mojoExecutions) {
824             final String executionKey = CacheUtils.mojoExecutionKey(mojoExecution);
825             final MojoExecutionEvent executionEvent =
826                     executionEvents != null ? executionEvents.get(executionKey) : null;
827             CompletedExecution executionInfo = new CompletedExecution();
828             executionInfo.setExecutionKey(executionKey);
829             executionInfo.setMojoClassName(mojoExecution.getMojoDescriptor().getImplementation());
830             if (executionEvent != null) {
831                 recordMojoProperties(executionInfo, executionEvent);
832             }
833             list.add(executionInfo);
834         }
835         return list;
836     }
837 
838     private void recordMojoProperties(CompletedExecution execution, MojoExecutionEvent executionEvent) {
839         final MojoExecution mojoExecution = executionEvent.getExecution();
840 
841         final boolean logAll = cacheConfig.isLogAllProperties(mojoExecution);
842         List<TrackedProperty> trackedProperties = cacheConfig.getTrackedProperties(mojoExecution);
843         List<PropertyName> noLogProperties = cacheConfig.getNologProperties(mojoExecution);
844         List<PropertyName> forceLogProperties = cacheConfig.getLoggedProperties(mojoExecution);
845         final Object mojo = executionEvent.getMojo();
846 
847         final File baseDir = executionEvent.getProject().getBasedir();
848         final String baseDirPath = FilenameUtils.normalizeNoEndSeparator(baseDir.getAbsolutePath()) + File.separator;
849 
850         final List<Parameter> parameters = mojoExecution.getMojoDescriptor().getParameters();
851         for (Parameter parameter : parameters) {
852             // editable parameters could be configured by user
853             if (!parameter.isEditable()) {
854                 continue;
855             }
856 
857             final String propertyName = parameter.getName();
858             final boolean tracked = isTracked(propertyName, trackedProperties);
859             if (!tracked && isExcluded(propertyName, logAll, noLogProperties, forceLogProperties)) {
860                 continue;
861             }
862 
863             try {
864                 Field field = ReflectionUtils.getFieldByNameIncludingSuperclasses(propertyName, mojo.getClass());
865                 if (field != null) {
866                     final Object value = ReflectionUtils.getValueIncludingSuperclasses(propertyName, mojo);
867                     DtoUtils.addProperty(execution, propertyName, value, baseDirPath, tracked);
868                     continue;
869                 }
870                 // no field but maybe there is a getter with standard naming and no args
871                 Method getter = getGetter(propertyName, mojo.getClass());
872                 if (getter != null) {
873                     Object value = getter.invoke(mojo);
874                     DtoUtils.addProperty(execution, propertyName, value, baseDirPath, tracked);
875                     continue;
876                 }
877 
878                 if (LOGGER.isWarnEnabled()) {
879                     LOGGER.warn(
880                             "Cannot find a Mojo parameter '{}' to read for Mojo {}. This parameter should be ignored.",
881                             propertyName,
882                             mojoExecution);
883                 }
884 
885             } catch (IllegalAccessException | InvocationTargetException e) {
886                 LOGGER.info("Cannot get property {} value from {}: {}", propertyName, mojo, e.getMessage());
887                 if (tracked) {
888                     throw new IllegalArgumentException("Property configured in cache introspection config " + "for "
889                             + mojo + " is not accessible: " + propertyName);
890                 }
891             }
892         }
893     }
894 
895     private static Method getGetter(String fieldName, Class<?> clazz) {
896         String getterMethodName = "get" + org.codehaus.plexus.util.StringUtils.capitalizeFirstLetter(fieldName);
897         Method[] methods = clazz.getMethods();
898         for (Method method : methods) {
899             if (method.getName().equals(getterMethodName)
900                     && !method.getReturnType().equals(Void.TYPE)
901                     && method.getParameterCount() == 0) {
902                 return method;
903             }
904         }
905         return null;
906     }
907 
908     private boolean isExcluded(
909             String propertyName,
910             boolean logAll,
911             List<PropertyName> excludedProperties,
912             List<PropertyName> forceLogProperties) {
913         if (!forceLogProperties.isEmpty()) {
914             for (PropertyName logProperty : forceLogProperties) {
915                 if (Strings.CS.equals(propertyName, logProperty.getPropertyName())) {
916                     return false;
917                 }
918             }
919             return true;
920         }
921 
922         if (!excludedProperties.isEmpty()) {
923             for (PropertyName excludedProperty : excludedProperties) {
924                 if (Strings.CS.equals(propertyName, excludedProperty.getPropertyName())) {
925                     return true;
926                 }
927             }
928             return false;
929         }
930 
931         return !logAll;
932     }
933 
934     private boolean isTracked(String propertyName, List<TrackedProperty> trackedProperties) {
935         for (TrackedProperty trackedProperty : trackedProperties) {
936             if (Strings.CS.equals(propertyName, trackedProperty.getPropertyName())) {
937                 return true;
938             }
939         }
940         return false;
941     }
942 
943     private boolean isCachedSegmentPropertiesPresent(
944             MavenProject project, Build build, List<MojoExecution> mojoExecutions) {
945         for (MojoExecution mojoExecution : mojoExecutions) {
946             // completion of all mojos checked above, so we expect tp have execution info here
947             final List<TrackedProperty> trackedProperties = cacheConfig.getTrackedProperties(mojoExecution);
948             final CompletedExecution cachedExecution = build.findMojoExecutionInfo(mojoExecution);
949 
950             if (cachedExecution == null) {
951                 LOGGER.info(
952                         "Execution is not cached. Plugin: {}, goal {}, executionId: {}",
953                         mojoExecution.getPlugin(),
954                         mojoExecution.getGoal(),
955                         mojoExecution.getExecutionId());
956                 return false;
957             }
958 
959             if (!DtoUtils.containsAllProperties(cachedExecution, trackedProperties)) {
960                 LOGGER.warn(
961                         "Cached build record doesn't contain all tracked properties. Plugin: {}, goal: {},"
962                                 + " executionId: {}",
963                         mojoExecution.getPlugin(),
964                         mojoExecution.getGoal(),
965                         mojoExecution.getExecutionId());
966                 return false;
967             }
968         }
969         return true;
970     }
971 
972     @Override
973     public boolean isForcedExecution(MavenProject project, MojoExecution execution) {
974         if (cacheConfig.isForcedExecution(execution)) {
975             return true;
976         }
977 
978         if (StringUtils.isNotBlank(cacheConfig.getAlwaysRunPlugins())) {
979             String[] alwaysRunPluginsList = split(cacheConfig.getAlwaysRunPlugins(), ",");
980             for (String pluginAndGoal : alwaysRunPluginsList) {
981                 String[] tokens = pluginAndGoal.split(":");
982                 String alwaysRunPlugin = tokens[0];
983                 String alwaysRunGoal = tokens.length == 1 ? "*" : tokens[1];
984                 if (Objects.equals(execution.getPlugin().getArtifactId(), alwaysRunPlugin)
985                         && ("*".equals(alwaysRunGoal) || Objects.equals(execution.getGoal(), alwaysRunGoal))) {
986                     return true;
987                 }
988             }
989         }
990         return false;
991     }
992 
993     @Override
994     public void saveCacheReport(MavenSession session) {
995         try {
996             CacheReport cacheReport = new CacheReport();
997             for (CacheResult result : cacheResults.values()) {
998                 ProjectReport projectReport = new ProjectReport();
999                 CacheContext context = result.getContext();
1000                 MavenProject project = context.getProject();
1001                 projectReport.setGroupId(project.getGroupId());
1002                 projectReport.setArtifactId(project.getArtifactId());
1003                 projectReport.setChecksum(context.getInputInfo().getChecksum());
1004                 boolean checksumMatched = result.getStatus() != RestoreStatus.EMPTY;
1005                 projectReport.setChecksumMatched(checksumMatched);
1006                 projectReport.setLifecycleMatched(checksumMatched && result.isSuccess());
1007                 projectReport.setSource(String.valueOf(result.getSource()));
1008                 if (result.getSource() == CacheSource.REMOTE) {
1009                     projectReport.setUrl(remoteCache.getResourceUrl(context, BUILDINFO_XML));
1010                 } else if (result.getSource() == CacheSource.BUILD && cacheConfig.isSaveToRemote()) {
1011                     projectReport.setSharedToRemote(true);
1012                     projectReport.setUrl(remoteCache.getResourceUrl(context, BUILDINFO_XML));
1013                 }
1014                 cacheReport.addProject(projectReport);
1015             }
1016 
1017             String buildId = UUID.randomUUID().toString();
1018             localCache.saveCacheReport(buildId, session, cacheReport);
1019         } catch (Exception e) {
1020             LOGGER.error("Cannot save incremental build aggregated report", e);
1021         }
1022     }
1023 
1024     private void populateGitInfo(Build build, MavenSession session) {
1025         if (scm == null) {
1026             synchronized (this) {
1027                 if (scm == null) {
1028                     try {
1029                         scm = CacheUtils.readGitInfo(session);
1030                     } catch (IOException e) {
1031                         scm = new Scm();
1032                         LOGGER.error("Cannot populate git info", e);
1033                     }
1034                 }
1035             }
1036         }
1037         build.getDto().setScm(scm);
1038     }
1039 
1040     private boolean zipAndAttachArtifact(MavenProject project, Path dir, String classifier, final String glob)
1041             throws IOException {
1042         Path temp = Files.createTempFile("maven-incremental-", project.getArtifactId());
1043         temp.toFile().deleteOnExit();
1044         boolean hasFile = CacheUtils.zip(dir, temp, glob, cacheConfig.isPreservePermissions());
1045         if (hasFile) {
1046             projectHelper.attachArtifact(project, "zip", classifier, temp.toFile());
1047         }
1048         return hasFile;
1049     }
1050 
1051     private void restoreGeneratedSources(Artifact artifact, Path artifactFilePath, MavenProject project)
1052             throws IOException {
1053         final Path baseDir = project.getBasedir().toPath();
1054         final Path outputDir = baseDir.resolve(FilenameUtils.separatorsToSystem(artifact.getFilePath()));
1055         verifyRestorationInsideProject(project, outputDir);
1056         if (!Files.exists(outputDir)) {
1057             Files.createDirectories(outputDir);
1058         }
1059         CacheUtils.unzip(artifactFilePath, outputDir, cacheConfig.isPreservePermissions());
1060     }
1061 
1062     // TODO: move to config
1063     public void attachGeneratedSources(MavenProject project, ProjectCacheState state, long buildStartTime)
1064             throws IOException {
1065         final Path targetDir = Paths.get(project.getBuild().getDirectory());
1066 
1067         final Path generatedSourcesDir = targetDir.resolve("generated-sources");
1068         attachDirIfNotEmpty(
1069                 generatedSourcesDir,
1070                 targetDir,
1071                 project,
1072                 state,
1073                 OutputType.GENERATED_SOURCE,
1074                 DEFAULT_FILE_GLOB,
1075                 buildStartTime);
1076 
1077         final Path generatedTestSourcesDir = targetDir.resolve("generated-test-sources");
1078         attachDirIfNotEmpty(
1079                 generatedTestSourcesDir,
1080                 targetDir,
1081                 project,
1082                 state,
1083                 OutputType.GENERATED_SOURCE,
1084                 DEFAULT_FILE_GLOB,
1085                 buildStartTime);
1086 
1087         Set<String> sourceRoots = new TreeSet<>();
1088         if (project.getCompileSourceRoots() != null) {
1089             sourceRoots.addAll(project.getCompileSourceRoots());
1090         }
1091         if (project.getTestCompileSourceRoots() != null) {
1092             sourceRoots.addAll(project.getTestCompileSourceRoots());
1093         }
1094 
1095         for (String sourceRoot : sourceRoots) {
1096             final Path sourceRootPath = Paths.get(sourceRoot);
1097             if (Files.isDirectory(sourceRootPath)
1098                     && sourceRootPath.startsWith(targetDir)
1099                     && !(sourceRootPath.startsWith(generatedSourcesDir)
1100                             || sourceRootPath.startsWith(generatedTestSourcesDir))) { // dir within target
1101                 attachDirIfNotEmpty(
1102                         sourceRootPath,
1103                         targetDir,
1104                         project,
1105                         state,
1106                         OutputType.GENERATED_SOURCE,
1107                         DEFAULT_FILE_GLOB,
1108                         buildStartTime);
1109             }
1110         }
1111     }
1112 
1113     private void attachOutputs(MavenProject project, ProjectCacheState state, long buildStartTime) throws IOException {
1114         final List<DirName> attachedDirs = cacheConfig.getAttachedOutputs();
1115         for (DirName dir : attachedDirs) {
1116             final Path targetDir = Paths.get(project.getBuild().getDirectory());
1117             final Path outputDir = targetDir.resolve(dir.getValue());
1118             if (isPathInsideProject(project, outputDir)) {
1119                 attachDirIfNotEmpty(
1120                         outputDir, targetDir, project, state, OutputType.EXTRA_OUTPUT, dir.getGlob(), buildStartTime);
1121             } else {
1122                 LOGGER.warn("Outside project output candidate directory discarded ({})", outputDir.normalize());
1123             }
1124         }
1125     }
1126 
1127     private void attachDirIfNotEmpty(
1128             Path candidateSubDir,
1129             Path parentDir,
1130             MavenProject project,
1131             ProjectCacheState state,
1132             final OutputType attachedOutputType,
1133             final String glob,
1134             final long buildStartTime)
1135             throws IOException {
1136         if (Files.isDirectory(candidateSubDir) && hasFiles(candidateSubDir)) {
1137             final Path relativePath = project.getBasedir().toPath().relativize(candidateSubDir);
1138             state.attachedResourceCounter++;
1139             final String classifier = attachedOutputType.getClassifierPrefix() + state.attachedResourceCounter;
1140 
1141             // NOTE: No timestamp checking needed - stagePreExistingArtifacts() ensures stale files
1142             // are moved to staging. If files exist here, they're either:
1143             // 1. Fresh files built during this session, or
1144             // 2. Files restored from cache during this session
1145             // Both cases are valid and should be cached.
1146 
1147             boolean success = zipAndAttachArtifact(project, candidateSubDir, classifier, glob);
1148             if (success) {
1149                 state.attachedResourcesPathsById.put(classifier, relativePath);
1150                 LOGGER.debug("Attached directory: {}", candidateSubDir);
1151             }
1152         }
1153     }
1154 
1155     private boolean hasFiles(Path candidateSubDir) throws IOException {
1156         final MutableBoolean hasFiles = new MutableBoolean();
1157         Files.walkFileTree(candidateSubDir, new SimpleFileVisitor<Path>() {
1158 
1159             @Override
1160             public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttributes) {
1161                 hasFiles.setTrue();
1162                 return FileVisitResult.TERMINATE;
1163             }
1164         });
1165         return hasFiles.booleanValue();
1166     }
1167 
1168     /**
1169      * Move pre-existing build artifacts to staging directory to prevent caching stale files.
1170      *
1171      * <p><b>Artifacts Staged:</b>
1172      * <ul>
1173      *   <li>{@code target/classes} - Compiled main classes directory</li>
1174      *   <li>{@code target/test-classes} - Compiled test classes directory</li>
1175      *   <li>{@code target/*.jar} - Main project artifact (JAR/WAR files)</li>
1176      *   <li>Other directories configured via {@code attachedOutputs} in cache configuration</li>
1177      * </ul>
1178      *
1179      * <p><b>DESIGN RATIONALE - Staleness Detection via Staging Directory:</b>
1180      *
1181      * <p>This approach solves three critical problems that timestamp-based checking cannot handle:
1182      *
1183      * <p><b>Problem 1: Future Timestamps from Clock Skew</b>
1184      * <ul>
1185      *   <li>Machine A (clock ahead at 11:00 AM) builds and caches artifacts
1186      *   <li>Machine B (correct clock at 10:00 AM) restores cache
1187      *   <li>Restored files have timestamps from the future (11:00 AM)
1188      *   <li>User switches branches or updates sources (sources timestamped 10:02 AM)
1189      *   <li>Maven incremental compiler sees: sources (10:02 AM) &lt; classes (11:00 AM)
1190      *   <li>Maven skips compilation (thinks sources older than classes)
1191      *   <li>Wrong classes from old source version get cached!
1192      * </ul>
1193      *
1194      * <p><b>Problem 2: Orphaned Class Files from Deleted Sources</b>
1195      * <ul>
1196      *   <li>Version A has Foo.java → compiles Foo.class
1197      *   <li>Switch to Version B (no Foo.java)
1198      *   <li>Foo.class remains in target/classes (orphaned)
1199      *   <li>Cache miss on new version triggers mojos
1200      *   <li>Without protection, orphaned Foo.class gets cached
1201      *   <li>Future cache hits restore Foo.class (which shouldn't exist!)
1202      * </ul>
1203      *
1204      * <p><b>Problem 3: Stale JARs/WARs from Previous Builds</b>
1205      * <ul>
1206      *   <li>Yesterday: built myapp.jar on old version
1207      *   <li>Today: switched to new version, sources changed
1208      *   <li>mvn package runs (cache miss)
1209      *   <li>If JAR wasn't rebuilt, stale JAR could be cached
1210      * </ul>
1211      *
1212      * <p><b>Solution: Staging Directory Physical Separation</b>
1213      * <ul>
1214      *   <li>Before mojos run: Move pre-existing artifacts to target/.maven-build-cache-stash/
1215      *   <li>Maven sees clean target/ with no pre-existing artifacts
1216      *   <li>Maven compiler MUST compile (can't skip based on timestamps)
1217      *   <li>Fresh correct files created in target/
1218      *   <li>save() only sees fresh files (stale ones are in staging directory)
1219      *   <li>After save(): Restore artifacts from staging (delete if fresh version exists)
1220      * </ul>
1221      *
1222      * <p><b>Why Better Than Timestamp Checking:</b>
1223      * <ul>
1224      *   <li>No clock skew calculations needed
1225      *   <li>Physical file separation (not heuristics)
1226      *   <li>Forces correct incremental compilation
1227      *   <li>Handles interrupted builds gracefully (just delete staging directory)
1228      *   <li>Simpler and more robust
1229      *   <li>Easier cleanup - delete one directory instead of filtering files
1230      * </ul>
1231      *
1232      * <p><b>Interrupted Build Handling:</b>
1233      * If staging directory exists from interrupted previous run, it's deleted and recreated.
1234      *
1235      * @param session The Maven session
1236      * @param project The Maven project being built
1237      * @throws IOException if file move operations fail
1238      */
1239     public void stagePreExistingArtifacts(MavenSession session, MavenProject project) throws IOException {
1240         final ProjectCacheState state = getProjectState(project);
1241         final Path multimoduleRoot = CacheUtils.getMultimoduleRoot(session);
1242         final Path stagingDir = multimoduleRoot.resolve("target").resolve("maven-build-cache-extension");
1243 
1244         // Create or reuse staging directory from interrupted previous run
1245         Files.createDirectories(stagingDir);
1246         state.stagingDirectory = stagingDir;
1247 
1248         // Collect all paths that will be cached
1249         Set<Path> pathsToProcess = collectCachedArtifactPaths(project);
1250 
1251         int movedCount = 0;
1252         for (Path path : pathsToProcess) {
1253             // Calculate path relative to multimodule root (preserves full path including submodule)
1254             Path relativePath = multimoduleRoot.relativize(path);
1255             Path stagedPath = stagingDir.resolve(relativePath);
1256 
1257             if (Files.isDirectory(path)) {
1258                 // If directory already exists in staging (from interrupted run), remove it first
1259                 if (Files.exists(stagedPath)) {
1260                     deleteDirectory(stagedPath);
1261                     LOGGER.debug("Removed existing staged directory: {}", stagedPath);
1262                 }
1263                 // Move entire directory to staging
1264                 Files.createDirectories(stagedPath.getParent());
1265                 Files.move(path, stagedPath);
1266                 movedCount++;
1267                 LOGGER.debug("Moved directory to staging: {} → {}", relativePath, stagedPath);
1268             } else if (Files.isRegularFile(path)) {
1269                 // If file already exists in staging (from interrupted run), remove it first
1270                 if (Files.exists(stagedPath)) {
1271                     Files.delete(stagedPath);
1272                     LOGGER.debug("Removed existing staged file: {}", stagedPath);
1273                 }
1274                 // Move individual file (e.g., JAR) to staging
1275                 Files.createDirectories(stagedPath.getParent());
1276                 Files.move(path, stagedPath);
1277                 movedCount++;
1278                 LOGGER.debug("Moved file to staging: {} → {}", relativePath, stagedPath);
1279             }
1280         }
1281 
1282         if (movedCount > 0) {
1283             LOGGER.info(
1284                     "Moved {} pre-existing artifacts to staging directory to prevent caching stale files", movedCount);
1285         }
1286     }
1287 
1288     /**
1289      * Collects paths to all artifacts that will be considered for caching for the given project.
1290      *
1291      * <p>This includes:
1292      * <ul>
1293      *     <li>the main project artifact file (for example, the built JAR), if it has been produced, and</li>
1294      *     <li>any attached output directories configured via {@code cacheConfig.getAttachedOutputs()} under the
1295      *         project's target directory, when {@code cacheConfig.isCacheCompile()} is enabled.</li>
1296      * </ul>
1297      * Only paths that currently exist on disk are included in the returned set; non-existent files or directories
1298      * are ignored.
1299      *
1300      * @param project the Maven project whose artifact and attached output paths should be collected
1301      * @return a set of existing filesystem paths for the project's main artifact and configured attached outputs
1302      */
1303     private Set<Path> collectCachedArtifactPaths(MavenProject project) {
1304         Set<Path> paths = new HashSet<>();
1305         final org.apache.maven.artifact.Artifact projectArtifact = project.getArtifact();
1306         final Path targetDir = Paths.get(project.getBuild().getDirectory());
1307 
1308         // 1. Main project artifact (JAR file or target/classes directory)
1309         if (projectArtifact.getFile() != null && projectArtifact.getFile().exists()) {
1310             paths.add(projectArtifact.getFile().toPath());
1311         }
1312 
1313         // 2. Attached outputs from configuration (if cacheCompile enabled)
1314         if (cacheConfig.isCacheCompile()) {
1315             List<DirName> attachedDirs = cacheConfig.getAttachedOutputs();
1316             for (DirName dir : attachedDirs) {
1317                 Path outputDir = targetDir.resolve(dir.getValue());
1318                 if (Files.exists(outputDir)) {
1319                     paths.add(outputDir);
1320                 }
1321             }
1322         }
1323 
1324         return paths;
1325     }
1326 
1327     /**
1328      * Restore artifacts from staging directory after save() completes.
1329      *
1330      * <p>For each artifact in staging:
1331      * <ul>
1332      *   <li>If fresh version exists in target/: Delete staged version (was rebuilt correctly)
1333      *   <li>If fresh version missing: Move staged version back to target/ (wasn't rebuilt, still valid)
1334      * </ul>
1335      *
1336      * <p>This ensures:
1337      * <ul>
1338      *   <li>save() only cached fresh files (stale ones were in staging directory)
1339      *   <li>Developers see complete target/ directory after build
1340      *   <li>Incremental builds work correctly (unchanged files restored)
1341      * </ul>
1342      *
1343      * <p>Finally, deletes the staging directory.
1344      *
1345      * @param session The Maven session
1346      * @param project The Maven project being built
1347      */
1348     public void restoreStagedArtifacts(MavenSession session, MavenProject project) {
1349         final ProjectCacheState state = getProjectState(project);
1350         final Path stagingDir = state.stagingDirectory;
1351 
1352         if (stagingDir == null || !Files.exists(stagingDir)) {
1353             return; // Nothing to restore
1354         }
1355 
1356         try {
1357             final Path multimoduleRoot = CacheUtils.getMultimoduleRoot(session);
1358 
1359             // Collect directories to delete (where fresh versions exist)
1360             final List<Path> dirsToDelete = new ArrayList<>();
1361 
1362             // Walk staging directory and process files
1363             Files.walkFileTree(stagingDir, new SimpleFileVisitor<Path>() {
1364                 @Override
1365                 public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
1366                     if (dir.equals(stagingDir)) {
1367                         return FileVisitResult.CONTINUE; // Skip root
1368                     }
1369 
1370                     Path relativePath = stagingDir.relativize(dir);
1371                     Path targetPath = multimoduleRoot.resolve(relativePath);
1372 
1373                     if (Files.exists(targetPath)) {
1374                         // Fresh directory exists - mark entire tree for deletion
1375                         dirsToDelete.add(dir);
1376                         LOGGER.debug("Fresh directory exists, marking for recursive deletion: {}", relativePath);
1377                         return FileVisitResult.SKIP_SUBTREE;
1378                     }
1379                     return FileVisitResult.CONTINUE;
1380                 }
1381 
1382                 @Override
1383                 public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
1384                     Path relativePath = stagingDir.relativize(file);
1385                     Path targetPath = multimoduleRoot.resolve(relativePath);
1386 
1387                     try {
1388                         // Atomically move file back if destination doesn't exist
1389                         Files.createDirectories(targetPath.getParent());
1390                         Files.move(file, targetPath);
1391                         LOGGER.debug("Restored unchanged file from staging: {}", relativePath);
1392                     } catch (FileAlreadyExistsException e) {
1393                         // Fresh file exists (was rebuilt) - delete stale version
1394                         Files.delete(file);
1395                         LOGGER.debug("Fresh file exists, deleted stale file: {}", relativePath);
1396                     }
1397                     return FileVisitResult.CONTINUE;
1398                 }
1399 
1400                 @Override
1401                 public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
1402                     if (exc != null) {
1403                         throw exc;
1404                     }
1405                     // Try to delete empty directories bottom-up
1406                     if (!dir.equals(stagingDir)) {
1407                         try {
1408                             Files.delete(dir);
1409                             LOGGER.debug("Deleted empty directory: {}", stagingDir.relativize(dir));
1410                         } catch (IOException e) {
1411                             // Not empty yet - other modules may still have files here
1412                         }
1413                     }
1414                     return FileVisitResult.CONTINUE;
1415                 }
1416             });
1417 
1418             // Recursively delete directories where fresh versions exist
1419             for (Path dirToDelete : dirsToDelete) {
1420                 LOGGER.debug("Recursively deleting stale directory: {}", stagingDir.relativize(dirToDelete));
1421                 deleteDirectory(dirToDelete);
1422             }
1423 
1424             // Try to delete staging directory itself if now empty
1425             try {
1426                 Files.delete(stagingDir);
1427                 LOGGER.debug("Deleted empty staging directory: {}", stagingDir);
1428             } catch (IOException e) {
1429                 LOGGER.debug("Staging directory not empty, preserving for other modules");
1430             }
1431 
1432         } catch (IOException e) {
1433             LOGGER.warn("Failed to restore artifacts from staging directory: {}", e.getMessage());
1434         }
1435 
1436         // Clear the staging directory reference
1437         state.stagingDirectory = null;
1438 
1439         // Remove the project state from map to free memory (called after save() cleanup)
1440         String key = getVersionlessProjectKey(project);
1441         projectStates.remove(key);
1442     }
1443 
1444     /**
1445      * Recursively delete a directory and all its contents.
1446      */
1447     private void deleteDirectory(Path dir) throws IOException {
1448         if (!Files.exists(dir)) {
1449             return;
1450         }
1451 
1452         Files.walkFileTree(dir, new SimpleFileVisitor<Path>() {
1453             @Override
1454             public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
1455                 Files.delete(file);
1456                 return FileVisitResult.CONTINUE;
1457             }
1458 
1459             @Override
1460             public FileVisitResult postVisitDirectory(Path dir, IOException exc) throws IOException {
1461                 Files.delete(dir);
1462                 return FileVisitResult.CONTINUE;
1463             }
1464         });
1465     }
1466 
1467     private boolean isOutputArtifact(String name) {
1468         List<Pattern> excludePatterns = cacheConfig.getExcludePatterns();
1469         for (Pattern pattern : excludePatterns) {
1470             if (pattern.matcher(name).matches()) {
1471                 return false;
1472             }
1473         }
1474         return true;
1475     }
1476 }