diff --git a/src/main/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategy.java b/src/main/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategy.java index 33b6f011..9801f43d 100644 --- a/src/main/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategy.java +++ b/src/main/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategy.java @@ -270,6 +270,18 @@ private CacheRestorationStatus restoreProject( final Build build = cacheResult.getBuildInfo(); final MavenProject project = cacheResult.getContext().getProject(); final MavenSession session = cacheResult.getContext().getSession(); + final String highestRequestPhase = lifecyclePhasesHelper.resolveHighestLifecyclePhase(project, mojoExecutions); + + if (requiresPackagedArtifact(highestRequestPhase) + && buildStoresDirectoryAsMainArtifact(build) + && !lifecyclePhasesHelper.isLaterPhaseThanBuild("package", build)) { + LOGGER.info( + "Cached build for {} cannot satisfy requested {} phase because the main artifact is stored as a directory", + getVersionlessProjectKey(project), + highestRequestPhase); + return CacheRestorationStatus.FAILURE; + } + final List cachedSegment = lifecyclePhasesHelper.getCachedSegment(project, mojoExecutions, build); @@ -333,6 +345,11 @@ private CacheRestorationStatus restoreProject( // Execute mojos after the cache segment LOGGER.debug("Execute mojos post cache segment"); + if (shouldResetProjectArtifactForPackaging(build, highestRequestPhase) && project.getArtifact() != null) { + LOGGER.debug( + "Resetting project artifact file before package-or-later mojos after legacy directory-backed main artifact restoration"); + project.getArtifact().setFile(null); + } List postCachedSegment = lifecyclePhasesHelper.getPostCachedSegment(project, mojoExecutions, build); for (MojoExecution mojoExecution : postCachedSegment) { @@ -341,6 +358,20 @@ private CacheRestorationStatus restoreProject( return CacheRestorationStatus.SUCCESS; } + private boolean shouldResetProjectArtifactForPackaging(Build build, String highestRequestPhase) { + return requiresPackagedArtifact(highestRequestPhase) + && buildStoresDirectoryAsMainArtifact(build) + && lifecyclePhasesHelper.isLaterPhaseThanBuild("package", build); + } + + private boolean buildStoresDirectoryAsMainArtifact(Build build) { + return build.getArtifact() != null && build.getArtifact().isIsDirectory(); + } + + private boolean requiresPackagedArtifact(String lifecyclePhase) { + return "package".equals(lifecyclePhase) || lifecyclePhasesHelper.isLaterPhase(lifecyclePhase, "package"); + } + private boolean verifyCacheConsistency( MojoExecution cacheCandidate, Build cachedBuild, diff --git a/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java b/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java index 08dabc0b..27d219a8 100644 --- a/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java +++ b/src/main/java/org/apache/maven/buildcache/CacheControllerImpl.java @@ -388,13 +388,29 @@ private void restoreRegularFileArtifact(File cachedFile, Artifact artifact, Path } private boolean isPathInsideProject(final MavenProject project, Path path) { - Path restorationPath = path.toAbsolutePath().normalize(); - return restorationPath.startsWith(project.getBasedir().toPath()); + return pathInsideProject(project, path).isPresent(); + } + + private Optional pathInsideProject(final MavenProject project, Path path) { + Path normalizedPath = normalizeProjectPath(project, path); + if (normalizedPath.startsWith(getProjectBaseDir(project))) { + return Optional.of(normalizedPath); + } + return Optional.empty(); + } + + private Path normalizeProjectPath(final MavenProject project, Path path) { + Path baseDir = getProjectBaseDir(project); + return (path.isAbsolute() ? path : baseDir.resolve(path)).normalize(); + } + + private Path getProjectBaseDir(final MavenProject project) { + return project.getBasedir().toPath().toAbsolutePath().normalize(); } private void verifyRestorationInsideProject(final MavenProject project, Path path) { if (!isPathInsideProject(project, path)) { - Path normalized = path.toAbsolutePath().normalize(); + Path normalized = normalizeProjectPath(project, path); LOGGER.error(ERROR_MSG_RESTORATION_OUTSIDE_PROJECT + normalized); throw new RuntimeException(ERROR_MSG_RESTORATION_OUTSIDE_PROJECT + normalized); } @@ -415,7 +431,7 @@ public ArtifactRestorationReport restoreProjectArtifacts(CacheResult cacheResult boolean restoredProjectArtifactIsDirectory = false; List restoredAttachedArtifacts = new ArrayList<>(); - if (build.getArtifact() != null && isNotBlank(build.getArtifact().getFileName())) { + if (build.getArtifact() != null && isNotBlank(build.getArtifact().getFilePath())) { final Artifact artifactInfo = build.getArtifact(); String originalVersion = artifactInfo.getVersion(); artifactInfo.setVersion(project.getVersion()); @@ -574,15 +590,16 @@ public void save( final boolean cacheCompile = cacheConfig.isCacheCompile(); if (cacheCompile) { attachGeneratedSources(project, state, buildStartTime); + attachCompileOutputs(project, state, buildStartTime); attachOutputs(project, state, buildStartTime); } final List attachedArtifacts = project.getAttachedArtifacts() != null ? project.getAttachedArtifacts() : Collections.emptyList(); final List 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); + // Always create artifact DTO. Compile-only outputs are cached as attached outputs, not as the + // main Maven artifact; the main artifact gets a file path only after a packaging mojo assigns one. + final Artifact projectArtifactDto = projectArtifactDto(project.getArtifact(), algorithm, project, state); List completedExecution = buildExecutionInfo(mojoExecutions, executionEvents); @@ -594,9 +611,7 @@ public void save( // 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 + // 1. Project artifact file exists as a regular file (JAR/WAR/etc from package phase) // 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) // @@ -606,8 +621,7 @@ public void save( // 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())); + && projectArtifact.getFile().isFile(); boolean hasAttachedArtifacts = !attachedArtifactDtos.isEmpty() && attachedArtifactDtos.stream() .anyMatch(a -> !"consumer".equals(a.getClassifier()) || !"pom".equals(a.getType())); @@ -634,8 +648,8 @@ public void save( localCache.beforeSave(context); - // Save project artifact file if it exists (created by package or compile phase) - if (projectArtifact.getFile() != null) { + // Save project artifact file if it exists (created by package phase or later) + if (projectArtifact.getFile() != null && projectArtifact.getFile().isFile()) { saveProjectArtifact(cacheResult, projectArtifact, project); } for (org.apache.maven.artifact.Artifact attachedArtifact : attachedArtifacts) { @@ -677,50 +691,20 @@ public void save( } /** - * 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. + * Saves a project artifact to cache. */ 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); - } + 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); - } - } - public void produceDiffReport(CacheResult cacheResult, Build build) { MavenProject project = cacheResult.getContext().getProject(); Optional baselineHolder = remoteCache.findBaselineBuild(project); @@ -790,6 +774,18 @@ && isOutputArtifact(attachedArtifact.getFile().getName())) { return result; } + private Artifact projectArtifactDto( + org.apache.maven.artifact.Artifact projectArtifact, + HashAlgorithm algorithm, + MavenProject project, + ProjectCacheState state) + throws IOException { + if (projectArtifact.getFile() != null && projectArtifact.getFile().isDirectory()) { + return DtoUtils.createDto(projectArtifact); + } + return artifactDto(projectArtifact, algorithm, project, state); + } + private Artifact artifactDto( org.apache.maven.artifact.Artifact projectArtifact, HashAlgorithm algorithm, @@ -1133,11 +1129,50 @@ private void attachOutputs(MavenProject project, ProjectCacheState state, long b for (DirName dir : attachedDirs) { final Path targetDir = Paths.get(project.getBuild().getDirectory()); final Path outputDir = targetDir.resolve(dir.getValue()); - if (isPathInsideProject(project, outputDir)) { + Optional pathInsideProject = pathInsideProject(project, outputDir); + if (pathInsideProject.isPresent()) { + attachDirIfNotEmpty( + pathInsideProject.get(), + targetDir, + project, + state, + OutputType.EXTRA_OUTPUT, + dir.getGlob(), + buildStartTime); + } else { + LOGGER.warn( + "Outside project output candidate directory discarded ({})", + normalizeProjectPath(project, outputDir)); + } + } + } + + private void attachCompileOutputs(MavenProject project, ProjectCacheState state, long buildStartTime) + throws IOException { + final Path targetDir = Paths.get(project.getBuild().getDirectory()); + attachCompileOutput(project.getBuild().getOutputDirectory(), targetDir, project, state, buildStartTime); + attachCompileOutput(project.getBuild().getTestOutputDirectory(), targetDir, project, state, buildStartTime); + } + + private void attachCompileOutput( + String outputDirectory, Path targetDir, MavenProject project, ProjectCacheState state, long buildStartTime) + throws IOException { + if (isNotBlank(outputDirectory)) { + Path outputDir = Paths.get(outputDirectory); + Optional pathInsideProject = pathInsideProject(project, outputDir); + if (pathInsideProject.isPresent()) { attachDirIfNotEmpty( - outputDir, targetDir, project, state, OutputType.EXTRA_OUTPUT, dir.getGlob(), buildStartTime); + pathInsideProject.get(), + targetDir, + project, + state, + OutputType.EXTRA_OUTPUT, + DEFAULT_FILE_GLOB, + buildStartTime); } else { - LOGGER.warn("Outside project output candidate directory discarded ({})", outputDir.normalize()); + LOGGER.warn( + "Outside project output candidate directory discarded ({})", + normalizeProjectPath(project, outputDir)); } } } @@ -1152,7 +1187,12 @@ private void attachDirIfNotEmpty( final long buildStartTime) throws IOException { if (Files.isDirectory(candidateSubDir) && hasFiles(candidateSubDir)) { - final Path relativePath = project.getBasedir().toPath().relativize(candidateSubDir); + final Path relativePath = getProjectBaseDir(project) + .relativize(candidateSubDir.toAbsolutePath().normalize()); + if (state.attachedResourcesPathsById.containsValue(relativePath)) { + LOGGER.debug("Skipping already attached directory: {}", candidateSubDir); + return; + } state.attachedResourceCounter++; final String classifier = attachedOutputType.getClassifierPrefix() + state.attachedResourceCounter; @@ -1256,7 +1296,8 @@ public FileVisitResult visitFile(Path path, BasicFileAttributes basicFileAttribu */ public void stagePreExistingArtifacts(MavenSession session, MavenProject project) throws IOException { final ProjectCacheState state = getProjectState(project); - final Path multimoduleRoot = CacheUtils.getMultimoduleRoot(session); + final Path multimoduleRoot = + CacheUtils.getMultimoduleRoot(session).toAbsolutePath().normalize(); final Path stagingDir = multimoduleRoot.resolve("target").resolve("maven-build-cache-extension"); // Create or reuse staging directory from interrupted previous run @@ -1268,11 +1309,16 @@ public void stagePreExistingArtifacts(MavenSession session, MavenProject project int movedCount = 0; for (Path path : pathsToProcess) { + Path normalizedPath = path.toAbsolutePath().normalize(); + if (!normalizedPath.startsWith(multimoduleRoot)) { + LOGGER.warn("Outside reactor artifact candidate discarded ({})", normalizedPath); + continue; + } // Calculate path relative to multimodule root (preserves full path including submodule) - Path relativePath = multimoduleRoot.relativize(path); + Path relativePath = multimoduleRoot.relativize(normalizedPath); Path stagedPath = stagingDir.resolve(relativePath); - if (Files.isDirectory(path)) { + if (Files.isDirectory(normalizedPath)) { // If directory already exists in staging (from interrupted run), remove it first if (Files.exists(stagedPath)) { deleteDirectory(stagedPath); @@ -1280,10 +1326,10 @@ public void stagePreExistingArtifacts(MavenSession session, MavenProject project } // Move entire directory to staging Files.createDirectories(stagedPath.getParent()); - Files.move(path, stagedPath); + Files.move(normalizedPath, stagedPath); movedCount++; LOGGER.debug("Moved directory to staging: {} → {}", relativePath, stagedPath); - } else if (Files.isRegularFile(path)) { + } else if (Files.isRegularFile(normalizedPath)) { // If file already exists in staging (from interrupted run), remove it first if (Files.exists(stagedPath)) { Files.delete(stagedPath); @@ -1291,7 +1337,7 @@ public void stagePreExistingArtifacts(MavenSession session, MavenProject project } // Move individual file (e.g., JAR) to staging Files.createDirectories(stagedPath.getParent()); - Files.move(path, stagedPath); + Files.move(normalizedPath, stagedPath); movedCount++; LOGGER.debug("Moved file to staging: {} → {}", relativePath, stagedPath); } @@ -1309,8 +1355,9 @@ public void stagePreExistingArtifacts(MavenSession session, MavenProject project *

This includes: *

    *
  • the main project artifact file (for example, the built JAR), if it has been produced, and
  • - *
  • any attached output directories configured via {@code cacheConfig.getAttachedOutputs()} under the - * project's target directory, when {@code cacheConfig.isCacheCompile()} is enabled.
  • + *
  • compile output directories plus any attached output directories configured via + * {@code cacheConfig.getAttachedOutputs()} under the project's target directory, when + * {@code cacheConfig.isCacheCompile()} is enabled.
  • *
* Only paths that currently exist on disk are included in the returned set; non-existent files or directories * are ignored. @@ -1323,25 +1370,44 @@ private Set collectCachedArtifactPaths(MavenProject project) { 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()) { + // 1. Main project artifact (JAR/WAR/etc. file) + if (projectArtifact.getFile() != null && projectArtifact.getFile().isFile()) { paths.add(projectArtifact.getFile().toPath()); } - // 2. Attached outputs from configuration (if cacheCompile enabled) + // 2. Compile outputs and configured attached outputs (if cacheCompile enabled) if (cacheConfig.isCacheCompile()) { + addExistingOutputPath(project, paths, project.getBuild().getOutputDirectory()); + addExistingOutputPath(project, paths, project.getBuild().getTestOutputDirectory()); List attachedDirs = cacheConfig.getAttachedOutputs(); for (DirName dir : attachedDirs) { - Path outputDir = targetDir.resolve(dir.getValue()); - if (Files.exists(outputDir)) { - paths.add(outputDir); - } + addExistingOutputPath(project, paths, targetDir.resolve(dir.getValue())); } } return paths; } + private void addExistingOutputPath(MavenProject project, Set paths, String outputDirectory) { + if (isNotBlank(outputDirectory)) { + addExistingOutputPath(project, paths, Paths.get(outputDirectory)); + } + } + + private void addExistingOutputPath(MavenProject project, Set paths, Path outputDir) { + Optional pathInsideProject = pathInsideProject(project, outputDir); + if (pathInsideProject.isPresent()) { + Path normalizedOutputDir = pathInsideProject.get(); + if (Files.exists(normalizedOutputDir)) { + paths.add(normalizedOutputDir); + } + } else { + LOGGER.warn( + "Outside project output candidate directory discarded ({})", + normalizeProjectPath(project, outputDir)); + } + } + /** * Restore artifacts from staging directory after save() completes. * diff --git a/src/test/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategyTest.java b/src/test/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategyTest.java index ca382537..ce04a176 100644 --- a/src/test/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategyTest.java +++ b/src/test/java/org/apache/maven/buildcache/BuildCacheMojosExecutionStrategyTest.java @@ -19,25 +19,36 @@ package org.apache.maven.buildcache; import java.io.File; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; import java.util.List; +import java.util.Set; import java.util.stream.Collectors; import org.apache.commons.lang3.SystemUtils; import org.apache.commons.lang3.tuple.Pair; +import org.apache.maven.artifact.handler.manager.ArtifactHandlerManager; import org.apache.maven.buildcache.xml.CacheConfig; +import org.apache.maven.buildcache.xml.XmlService; import org.apache.maven.buildcache.xml.build.CompletedExecution; import org.apache.maven.buildcache.xml.build.PropertyValue; +import org.apache.maven.buildcache.xml.config.DirName; import org.apache.maven.buildcache.xml.config.TrackedProperty; import org.apache.maven.execution.MavenSession; import org.apache.maven.execution.scope.internal.MojoExecutionScope; +import org.apache.maven.model.Build; import org.apache.maven.plugin.MavenPluginManager; import org.apache.maven.plugin.MojoExecution; import org.apache.maven.project.MavenProject; +import org.apache.maven.project.MavenProjectHelper; +import org.eclipse.aether.RepositorySystem; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -205,3 +216,78 @@ private Pair setupProperty(String propertyName, } } } + +class CacheControllerImplPathCollectionTest { + + @Test + void collectCachedArtifactPathsIgnoresOutputDirectoriesOutsideProject(@TempDir Path tempDir) throws Exception { + Path projectDir = tempDir.resolve("project").toAbsolutePath().normalize(); + Path targetDir = projectDir.resolve("target"); + Path outputDir = targetDir.resolve("classes"); + Path attachedOutputDir = targetDir.resolve("generated"); + Path outsideOutputDir = + tempDir.resolve("outside-classes").toAbsolutePath().normalize(); + Path outsideAttachedOutputDir = + tempDir.resolve("outside-attached").toAbsolutePath().normalize(); + + Files.createDirectories(outputDir); + Files.createDirectories(attachedOutputDir); + Files.createDirectories(outsideOutputDir); + Files.createDirectories(outsideAttachedOutputDir); + + DirName attachedOutput = dirName("generated"); + DirName outsideAttachedOutput = dirName("../../outside-attached"); + + CacheConfig cacheConfig = mock(CacheConfig.class); + when(cacheConfig.isCacheCompile()).thenReturn(true); + when(cacheConfig.getAttachedOutputs()).thenReturn(Arrays.asList(attachedOutput, outsideAttachedOutput)); + + Build build = mock(Build.class); + when(build.getDirectory()).thenReturn(targetDir.toString()); + when(build.getOutputDirectory()).thenReturn(outputDir.toString()); + when(build.getTestOutputDirectory()).thenReturn(outsideOutputDir.toString()); + + MavenProject project = mock(MavenProject.class); + when(project.getBasedir()).thenReturn(projectDir.toFile()); + when(project.getBuild()).thenReturn(build); + when(project.getArtifact()).thenReturn(mock(org.apache.maven.artifact.Artifact.class)); + + Set paths = collectCachedArtifactPaths(newCacheController(cacheConfig), project); + Set normalizedPaths = + paths.stream().map(path -> path.toAbsolutePath().normalize()).collect(Collectors.toSet()); + + assertTrue(normalizedPaths.contains(outputDir)); + assertTrue(normalizedPaths.contains(attachedOutputDir)); + assertFalse(normalizedPaths.contains(outsideOutputDir)); + assertFalse(normalizedPaths.contains(outsideAttachedOutputDir)); + assertTrue(normalizedPaths.stream().allMatch(path -> path.startsWith(projectDir))); + } + + private CacheControllerImpl newCacheController(CacheConfig cacheConfig) { + return new CacheControllerImpl( + mock(MavenProjectHelper.class), + mock(RepositorySystem.class), + mock(ArtifactHandlerManager.class), + mock(XmlService.class), + mock(LocalCacheRepository.class), + mock(RemoteCacheRepository.class), + cacheConfig, + mock(ProjectInputCalculator.class), + mock(RestoredArtifactHandler.class), + () -> mock(LifecyclePhasesHelper.class)); + } + + @SuppressWarnings("unchecked") + private Set collectCachedArtifactPaths(CacheControllerImpl controller, MavenProject project) + throws Exception { + Method method = CacheControllerImpl.class.getDeclaredMethod("collectCachedArtifactPaths", MavenProject.class); + method.setAccessible(true); + return (Set) method.invoke(controller, project); + } + + private DirName dirName(String value) { + DirName dirName = new DirName(); + dirName.setValue(value); + return dirName; + } +} diff --git a/src/test/java/org/apache/maven/buildcache/its/lifecyclephases/CompileThenInstallEscalationTest.java b/src/test/java/org/apache/maven/buildcache/its/lifecyclephases/CompileThenInstallEscalationTest.java new file mode 100644 index 00000000..12c5c6dd --- /dev/null +++ b/src/test/java/org/apache/maven/buildcache/its/lifecyclephases/CompileThenInstallEscalationTest.java @@ -0,0 +1,96 @@ +/* + * 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.lifecyclephases; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import org.apache.maven.buildcache.its.junit.IntegrationTest; +import org.apache.maven.it.Verifier; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Verifies that escalating from a compile-only cache entry to {@code install} restores the compile + * segment from cache, rebuilds the package/install phases, and upgrades the cache entry to store a + * packaged main artifact. + */ +@Tag("smoke") +@IntegrationTest("src/test/projects/lifecycle-phases") +class CompileThenInstallEscalationTest { + + private static final String PROJECT_NAME = "org.apache.maven.caching.test:lifecycle-phases"; + private static final String PARTIAL_RESTORE = + "Project " + PROJECT_NAME + " restored partially. Highest cached goal: compile, requested: install"; + private static final String SKIPPED_COMPILE = "Skipping plugin execution (cached): compiler:compile"; + private static final String CACHE_SAVED = "Saved Build to local file"; + private static final String CLASSES_OUTPUT_PATH = "target/classes"; + private static final String PACKAGED_ARTIFACT_PATH = + "target/lifecycle-phases-0.0.1-SNAPSHOT.jar"; + private static final String DIRECTORY_ARTIFACT_MARKER = "true"; + + @Test + void installEscalatesFromCompileCache(Verifier verifier) throws Exception { + verifier.setAutoclean(false); + Path cacheLocation = configureCacheLocation(verifier); + + verifier.setLogFileName("../log-1.txt"); + verifier.executeGoal("compile"); + verifier.verifyErrorFreeLog(); + verifier.verifyTextInLog(CACHE_SAVED); + String compileBuildInfo = Files.readString(findBuildInfo(cacheLocation)); + assertTrue(compileBuildInfo.contains(CLASSES_OUTPUT_PATH), "Compile cache entry must store classes output"); + assertFalse( + compileBuildInfo.contains(DIRECTORY_ARTIFACT_MARKER), + "Compile cache entry must not store target/classes as the main artifact"); + + verifier.setLogFileName("../log-2.txt"); + verifier.executeGoal("install"); + verifier.verifyErrorFreeLog(); + verifier.verifyTextInLog(PARTIAL_RESTORE); + verifier.verifyTextInLog(SKIPPED_COMPILE); + verifier.verifyTextInLog(CACHE_SAVED); + + String buildInfo = Files.readString(findBuildInfo(cacheLocation)); + assertTrue( + buildInfo.contains(PACKAGED_ARTIFACT_PATH), "Install-level cache entry must point to the packaged JAR"); + assertFalse( + buildInfo.contains(DIRECTORY_ARTIFACT_MARKER), + "Install-level cache entry must not keep target/classes as the main artifact"); + } + + private static Path configureCacheLocation(Verifier verifier) { + Path cacheLocation = Path.of(verifier.getBasedir()).getParent().resolve("build-cache"); + verifier.addCliOption("-Dmaven.build.cache.location=" + cacheLocation.toAbsolutePath()); + return cacheLocation; + } + + private static Path findBuildInfo(Path cacheLocation) throws IOException { + try (Stream files = Files.walk(cacheLocation)) { + return files.filter(path -> path.getFileName().toString().equals("buildinfo.xml")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No buildinfo.xml found under " + cacheLocation)); + } + } +} diff --git a/src/test/java/org/apache/maven/buildcache/its/lifecyclephases/CompileThenPackageEscalationTest.java b/src/test/java/org/apache/maven/buildcache/its/lifecyclephases/CompileThenPackageEscalationTest.java index 97eeb849..09cb9a67 100644 --- a/src/test/java/org/apache/maven/buildcache/its/lifecyclephases/CompileThenPackageEscalationTest.java +++ b/src/test/java/org/apache/maven/buildcache/its/lifecyclephases/CompileThenPackageEscalationTest.java @@ -18,12 +18,19 @@ */ package org.apache.maven.buildcache.its.lifecyclephases; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +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.Tag; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + /** * Verifies the phase-escalation scenario: after a {@code compile}-level cache entry exists, * running {@code mvn package} partially restores the compile segment from cache and then executes @@ -38,16 +45,26 @@ class CompileThenPackageEscalationTest { "Project " + PROJECT_NAME + " restored partially. Highest cached goal: compile, requested: package"; private static final String SKIPPED_COMPILE = "Skipping plugin execution (cached): compiler:compile"; private static final String CACHE_SAVED = "Saved Build to local file"; + private static final String CLASSES_OUTPUT_PATH = "target/classes"; + private static final String PACKAGED_ARTIFACT_PATH = + "target/lifecycle-phases-0.0.1-SNAPSHOT.jar"; + private static final String DIRECTORY_ARTIFACT_MARKER = "true"; @Test - void packageEscalatesFromCompileCache(Verifier verifier) throws VerificationException { + void packageEscalatesFromCompileCache(Verifier verifier) throws Exception { verifier.setAutoclean(false); + Path cacheLocation = configureCacheLocation(verifier); // Build 1 — compile; cache saved at compile level verifier.setLogFileName("../log-1.txt"); verifier.executeGoal("compile"); verifier.verifyErrorFreeLog(); verifier.verifyTextInLog(CACHE_SAVED); + String compileBuildInfo = Files.readString(findBuildInfo(cacheLocation)); + assertTrue(compileBuildInfo.contains(CLASSES_OUTPUT_PATH), "Compile cache entry must store classes output"); + assertFalse( + compileBuildInfo.contains(DIRECTORY_ARTIFACT_MARKER), + "Compile cache entry must not store target/classes as the main artifact"); // Build 2 — request package; compile segment restored from cache, package mojos run verifier.setLogFileName("../log-2.txt"); @@ -57,5 +74,26 @@ void packageEscalatesFromCompileCache(Verifier verifier) throws VerificationExce verifier.verifyTextInLog(SKIPPED_COMPILE); // New cache entry at package level saved verifier.verifyTextInLog(CACHE_SAVED); + + String buildInfo = Files.readString(findBuildInfo(cacheLocation)); + assertTrue( + buildInfo.contains(PACKAGED_ARTIFACT_PATH), "Package-level cache entry must point to the packaged JAR"); + assertFalse( + buildInfo.contains(DIRECTORY_ARTIFACT_MARKER), + "Package-level cache entry must not keep target/classes as the main artifact"); + } + + private static Path configureCacheLocation(Verifier verifier) { + Path cacheLocation = Path.of(verifier.getBasedir()).getParent().resolve("build-cache"); + verifier.addCliOption("-Dmaven.build.cache.location=" + cacheLocation.toAbsolutePath()); + return cacheLocation; + } + + private static Path findBuildInfo(Path cacheLocation) throws IOException { + try (Stream files = Files.walk(cacheLocation)) { + return files.filter(path -> path.getFileName().toString().equals("buildinfo.xml")) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No buildinfo.xml found under " + cacheLocation)); + } } }