diff --git a/pom.xml b/pom.xml index a41e9386..79d7883c 100644 --- a/pom.xml +++ b/pom.xml @@ -62,13 +62,12 @@ 17 - 4.0.0-rc-5 + 4.1.0-SNAPSHOT 3.0 5.14.4 5.23.0 4.0.0-beta-4 - 0.0.7 1.29 2.0.13 @@ -109,11 +108,6 @@ slf4j-api ${slf4jVersion} - - org.sonatype.plexus - plexus-build-api - ${plexusBuildApiVersion} - org.codehaus.plexus plexus-utils diff --git a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java index fab9657f..b65e4d2c 100644 --- a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java +++ b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenFileFilter.java @@ -24,12 +24,8 @@ import org.apache.maven.api.Project; import org.apache.maven.api.Session; -import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Singleton; -import org.sonatype.plexus.build.incremental.BuildContext; - -import static java.util.Objects.requireNonNull; /** * @author Olivier Lamy @@ -37,12 +33,6 @@ @Singleton @Named public class DefaultMavenFileFilter extends BaseFilter implements MavenFileFilter { - private final BuildContext buildContext; - - @Inject - public DefaultMavenFileFilter(BuildContext buildContext) { - this.buildContext = requireNonNull(buildContext); - } @Override public void copyFile( @@ -90,8 +80,7 @@ public void copyFile(Path from, Path to, boolean filtering, List getLogger().debug("copy {} to {}", from, to); FilteringUtils.copyFile(from, to, encoding, new FilterWrapper[0], false); } - - buildContext.refresh(to.toFile()); + // Output refresh is handled by the BuildContext output association in the mojo } catch (IOException e) { throw new MavenFilteringException( (filtering ? "filtering " : "copying ") + from + " to " + to + " failed with " diff --git a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java index 406330c3..31429dff 100644 --- a/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java +++ b/src/main/java/org/apache/maven/shared/filtering/DefaultMavenResourcesFiltering.java @@ -25,19 +25,23 @@ import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.PathMatcher; import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.Locale; +import org.apache.maven.api.annotations.Nullable; +import org.apache.maven.api.build.context.BuildContext; +import org.apache.maven.api.build.context.Input; +import org.apache.maven.api.build.context.Status; import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.Named; import org.apache.maven.api.di.Singleton; -import org.codehaus.plexus.util.Scanner; +import org.apache.maven.api.services.PathMatcherFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import org.sonatype.plexus.build.incremental.BuildContext; import static java.util.Objects.requireNonNull; @@ -49,9 +53,7 @@ public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering { private static final Logger LOGGER = LoggerFactory.getLogger(DefaultMavenResourcesFiltering.class); - private static final String[] EMPTY_STRING_ARRAY = {}; - - private static final String[] DEFAULT_INCLUDES = {"**/**"}; + // DEFAULT_INCLUDES is no longer needed — includes are passed directly to BuildContext.registerAndProcessInputs() private static final int BUFFER_LENGTH = 8192; private final List defaultNonFilteredFileExtensions; @@ -60,10 +62,16 @@ public class DefaultMavenResourcesFiltering implements MavenResourcesFiltering { private final BuildContext buildContext; + private final PathMatcherFactory pathMatcherFactory; + @Inject - public DefaultMavenResourcesFiltering(MavenFileFilter mavenFileFilter, BuildContext buildContext) { + public DefaultMavenResourcesFiltering( + MavenFileFilter mavenFileFilter, + @Nullable BuildContext buildContext, + @Nullable PathMatcherFactory pathMatcherFactory) { this.mavenFileFilter = requireNonNull(mavenFileFilter); - this.buildContext = requireNonNull(buildContext); + this.buildContext = buildContext; // null when running without incremental support (e.g. tests) + this.pathMatcherFactory = pathMatcherFactory; // null when running outside Maven 4 DI this.defaultNonFilteredFileExtensions = new ArrayList<>(5); this.defaultNonFilteredFileExtensions.add("jpg"); this.defaultNonFilteredFileExtensions.add("jpeg"); @@ -188,6 +196,10 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr continue; } + // Normalize to absolute path for consistent path operations + // (BuildContext canonicalizes paths, so relativize() would fail on mixed relative/absolute) + resourceDirectory = resourceDirectory.toAbsolutePath().normalize(); + // this part is required in case the user specified "../something" // as destination // see MNG-1345 @@ -205,97 +217,178 @@ public void filterResources(MavenResourcesExecution mavenResourcesExecution) thr isFilteringUsed = true; } - boolean ignoreDelta = !outputExists - || buildContext.hasDelta(mavenResourcesExecution.getFileFilters()) - || buildContext.hasDelta(getRelativeOutputDirectory(mavenResourcesExecution)); - LOGGER.debug("ignoreDelta " + ignoreDelta); - Scanner scanner = buildContext.newScanner(resourceDirectory.toFile(), ignoreDelta); - - setupScanner(resource, scanner, mavenResourcesExecution.isAddDefaultExcludes()); + // Resolve include/exclude patterns for this resource + List includes = resource.getIncludes(); + if (includes == null || includes.isEmpty()) { + includes = List.of("**/**"); + } + List excludes = resource.getExcludes(); + if (excludes == null) { + excludes = List.of(); + } + boolean addDefaultExcludes = mavenResourcesExecution.isAddDefaultExcludes(); + + // Register inputs with the BuildContext and get per-file change status. + // registerAndProcessInputs() returns ALL matching inputs (including UNMODIFIED) + // and internally marks NEW/MODIFIED ones as "processed". + // For REMOVED inputs, the BuildContext automatically cleans up associated outputs. + Collection allInputs; + if (buildContext != null) { + // BuildContext needs default excludes in the excludes list directly + List effectiveExcludes = excludes; + if (addDefaultExcludes) { + effectiveExcludes = new ArrayList<>(excludes); + addDefaultExcludes(effectiveExcludes); + } + allInputs = buildContext.registerAndProcessInputs(resourceDirectory, includes, effectiveExcludes); + } else { + allInputs = null; + } - scanner.scan(); + // Separate changed inputs from unchanged ones + List changedInputs; + List allFiles; + if (allInputs != null) { + final Path resDirFinal = resourceDirectory; + changedInputs = new ArrayList<>(); + allFiles = new ArrayList<>(); + for (Input input : allInputs) { + allFiles.add(resDirFinal.relativize(input.getPath()).toString()); + if (input.getStatus() != Status.UNMODIFIED) { + changedInputs.add(input); + } + } + } else { + // No build context — scan directory with PathMatcherFactory (handles + // Ant-style patterns and default excludes) and process everything + changedInputs = null; + allFiles = scanDirectory(resourceDirectory, includes, excludes, addDefaultExcludes); + } if (mavenResourcesExecution.isIncludeEmptyDirs()) { try { Path targetDirectory = targetPath == null ? outputDirectory : outputDirectory.resolve(targetPath); - copyDirectoryLayout(resourceDirectory, targetDirectory, scanner); + copyDirectoryLayout(resourceDirectory, targetDirectory); } catch (IOException e) { throw new MavenFilteringException( "Cannot copy directory structure from " + resourceDirectory + " to " + outputDirectory); } } - List includedFiles = Arrays.asList(scanner.getIncludedFiles()); + // Determine which files to actually process + boolean incremental = changedInputs != null; + int totalCount = allFiles.size(); + int processCount = incremental ? changedInputs.size() : totalCount; + // Log the processing summary try { Path basedir = mavenResourcesExecution.getMavenProject().getBasedir().toAbsolutePath(); Path destination = getDestinationFile(outputDirectory, targetPath, "", mavenResourcesExecution) .toAbsolutePath(); - LOGGER.info("Copying " + includedFiles.size() + " resource" + (includedFiles.size() > 1 ? "s" : "") - + " from " - + basedir.relativize(resourceDirectory.toAbsolutePath()) - + " to " - + basedir.relativize(destination)); + if (incremental && processCount < totalCount) { + LOGGER.info("Copying " + processCount + " of " + totalCount + " resource" + + (totalCount > 1 ? "s" : "") + " from " + + basedir.relativize(resourceDirectory.toAbsolutePath()) + " to " + + basedir.relativize(destination) + + " (" + (totalCount - processCount) + " unchanged)"); + } else { + LOGGER.info("Copying " + totalCount + " resource" + (totalCount > 1 ? "s" : "") + " from " + + basedir.relativize(resourceDirectory.toAbsolutePath()) + " to " + + basedir.relativize(destination)); + } } catch (Exception e) { // be foolproof: if for ANY reason throws, do not abort, just fall back to old message - LOGGER.info("Copying " + includedFiles.size() + " resource" + (includedFiles.size() > 1 ? "s" : "") + LOGGER.info("Copying " + processCount + " resource" + (processCount > 1 ? "s" : "") + (targetPath == null ? "" : " to " + targetPath)); } - for (String name : includedFiles) { - - LOGGER.debug("Copying file " + name); - Path source = resourceDirectory.resolve(name); + // Process only changed files (or all files when no BuildContext is available) + if (incremental) { + // Incremental mode: only copy/filter files with status NEW or MODIFIED, + // then register input→output associations for stale output cleanup + for (Input input : changedInputs) { + Path source = input.getPath(); + String name = resourceDirectory.relativize(source).toString(); + + Path destinationFile = + getDestinationFile(outputDirectory, targetPath, name, mavenResourcesExecution); + + if (mavenResourcesExecution.isFlatten() && Files.exists(destinationFile)) { + if (mavenResourcesExecution.isOverwrite()) { + LOGGER.warn("existing file " + destinationFile.getFileName() + " will be overwritten by " + + name); + } else { + throw new MavenFilteringException("existing file " + destinationFile.getFileName() + + " will be overwritten by " + name + " and overwrite was not set to true"); + } + } - Path destinationFile = getDestinationFile(outputDirectory, targetPath, name, mavenResourcesExecution); + boolean filteredExt = filteredFileExtension( + source.getFileName().toString(), mavenResourcesExecution.getNonFilteredFileExtensions()); + if (resource.isFiltering() && isPropertiesFile(source)) { + propertiesFiles.add(source); + } - if (mavenResourcesExecution.isFlatten() && Files.exists(destinationFile)) { - if (mavenResourcesExecution.isOverwrite()) { - LOGGER.warn( - "existing file " + destinationFile.getFileName() + " will be overwritten by " + name); - } else { - throw new MavenFilteringException("existing file " + destinationFile.getFileName() - + " will be overwritten by " + name + " and overwrite was not set to true"); + String encoding = getEncoding( + source, + mavenResourcesExecution.getEncoding(), + mavenResourcesExecution.getPropertiesEncoding()); + LOGGER.debug("Using '" + encoding + "' encoding to copy filtered resource '" + source.getFileName() + + "'."); + mavenFileFilter.copyFile( + source, + destinationFile, + resource.isFiltering() && filteredExt, + mavenResourcesExecution.getFilterWrappers(), + encoding); + + // Register the input→output association so the BuildContext can: + // 1. Track which outputs belong to which inputs + // 2. Automatically delete stale outputs when their input is removed + // Skip when flattening: multiple inputs may map to the same output file, + // which violates the BuildContext's one-input-to-one-output constraint. + if (!mavenResourcesExecution.isFlatten()) { + input.associateOutput(destinationFile); } } - boolean filteredExt = filteredFileExtension( - source.getFileName().toString(), mavenResourcesExecution.getNonFilteredFileExtensions()); - if (resource.isFiltering() && isPropertiesFile(source)) { - propertiesFiles.add(source); - } - - // Determine which encoding to use when filtering this file - String encoding = getEncoding( - source, mavenResourcesExecution.getEncoding(), mavenResourcesExecution.getPropertiesEncoding()); - LOGGER.debug( - "Using '" + encoding + "' encoding to copy filtered resource '" + source.getFileName() + "'."); - mavenFileFilter.copyFile( - source, - destinationFile, - resource.isFiltering() && filteredExt, - mavenResourcesExecution.getFilterWrappers(), - encoding); - } - - // deal with deleted source files - - scanner = buildContext.newDeleteScanner(resourceDirectory.toFile()); - - setupScanner(resource, scanner, mavenResourcesExecution.isAddDefaultExcludes()); - - scanner.scan(); + } else { + // Non-incremental mode: process all files + for (String name : allFiles) { + LOGGER.debug("Copying file " + name); + Path source = resourceDirectory.resolve(name); + Path destinationFile = + getDestinationFile(outputDirectory, targetPath, name, mavenResourcesExecution); + + if (mavenResourcesExecution.isFlatten() && Files.exists(destinationFile)) { + if (mavenResourcesExecution.isOverwrite()) { + LOGGER.warn("existing file " + destinationFile.getFileName() + " will be overwritten by " + + name); + } else { + throw new MavenFilteringException("existing file " + destinationFile.getFileName() + + " will be overwritten by " + name + " and overwrite was not set to true"); + } + } - for (String name : scanner.getIncludedFiles()) { - Path destinationFile = getDestinationFile(outputDirectory, targetPath, name, mavenResourcesExecution); + boolean filteredExt = filteredFileExtension( + source.getFileName().toString(), mavenResourcesExecution.getNonFilteredFileExtensions()); + if (resource.isFiltering() && isPropertiesFile(source)) { + propertiesFiles.add(source); + } - try { - Files.deleteIfExists(destinationFile); - } catch (IOException e) { - // ignore + String encoding = getEncoding( + source, + mavenResourcesExecution.getEncoding(), + mavenResourcesExecution.getPropertiesEncoding()); + LOGGER.debug("Using '" + encoding + "' encoding to copy filtered resource '" + source.getFileName() + + "'."); + mavenFileFilter.copyFile( + source, + destinationFile, + resource.isFiltering() && filteredExt, + mavenResourcesExecution.getFilterWrappers(), + encoding); } - - buildContext.refresh(destinationFile.toFile()); } } @@ -405,27 +498,51 @@ private Path getDestinationFile( return destinationFile; } - private void setupScanner(Resource resource, Scanner scanner, boolean addDefaultExcludes) { - String[] includes; - if (resource.getIncludes() != null && !resource.getIncludes().isEmpty()) { - includes = resource.getIncludes().toArray(EMPTY_STRING_ARRAY); - } else { - includes = DEFAULT_INCLUDES; - } - scanner.setIncludes(includes); - - if (resource.getExcludes() != null && !resource.getExcludes().isEmpty()) { - String[] excludes = resource.getExcludes().toArray(EMPTY_STRING_ARRAY); - scanner.setExcludes(excludes); + /** + * Scans a directory for files matching include/exclude patterns. + * Uses {@link PathMatcherFactory} when available (Maven 4 API — handles Ant-style + * patterns correctly), otherwise falls back to a simple unfiltered walk. + */ + private List scanDirectory( + Path baseDir, List includes, List excludes, boolean addDefaultExcludes) + throws MavenFilteringException { + List result = new ArrayList<>(); + try { + PathMatcher matcher; + if (pathMatcherFactory != null) { + matcher = pathMatcherFactory.createPathMatcher(baseDir, includes, excludes, addDefaultExcludes); + } else { + matcher = null; // no filtering available — include everything + } + Files.walk(baseDir).filter(Files::isRegularFile).forEach(path -> { + if (matcher == null || matcher.matches(path)) { + String relative = baseDir.relativize(path).toString().replace('\\', '/'); + result.add(relative); + } + }); + } catch (IOException e) { + throw new MavenFilteringException("Failed to scan directory: " + baseDir, e); } + return result; + } - if (addDefaultExcludes) { - scanner.addDefaultExcludes(); - } + /** + * Adds the standard SCM and IDE default excludes to the given list. + */ + private static void addDefaultExcludes(List excludes) { + // Standard default excludes (SCM dirs, IDE files, OS files) + excludes.add("**/.git/**"); + excludes.add("**/.svn/**"); + excludes.add("**/.hg/**"); + excludes.add("**/.bzr/**"); + excludes.add("**/CVS/**"); + excludes.add("**/.gitignore"); + excludes.add("**/.cvsignore"); + excludes.add("**/.DS_Store"); + excludes.add("**/Thumbs.db"); } - private void copyDirectoryLayout(Path sourceDirectory, Path destinationDirectory, Scanner scanner) - throws IOException { + private void copyDirectoryLayout(Path sourceDirectory, Path destinationDirectory) throws IOException { if (sourceDirectory == null) { throw new IOException("source directory can't be null."); } @@ -442,34 +559,20 @@ private void copyDirectoryLayout(Path sourceDirectory, Path destinationDirectory throw new IOException("Source directory doesn't exists (" + sourceDirectory.toAbsolutePath() + ")."); } - for (String name : scanner.getIncludedDirectories()) { - Path source = sourceDirectory.resolve(name); - - if (source.equals(sourceDirectory)) { - continue; - } - - Path destination = destinationDirectory.resolve(name); - Files.createDirectories(destination); - } + Files.walk(sourceDirectory) + .filter(Files::isDirectory) + .filter(p -> !p.equals(sourceDirectory)) + .forEach(dir -> { + Path destination = destinationDirectory.resolve(sourceDirectory.relativize(dir)); + try { + Files.createDirectories(destination); + } catch (IOException e) { + throw new java.io.UncheckedIOException(e); + } + }); } - private String getRelativeOutputDirectory(MavenResourcesExecution execution) { - String relOutDir = execution.getOutputDirectory().toAbsolutePath().toString(); - - if (execution.getMavenProject() != null) { - String basedir = - execution.getMavenProject().getBasedir().toAbsolutePath().toString(); - relOutDir = FilteringUtils.getRelativeFilePath(basedir, relOutDir); - if (relOutDir == null) { - relOutDir = execution.getOutputDirectory().toString(); - } else { - relOutDir = relOutDir.replace('\\', '/'); - } - } - - return relOutDir; - } + // getRelativeOutputDirectory was only used for old BuildContext.hasDelta() — no longer needed /* * Filter the name of a file using the same mechanism for filtering the content of the file. diff --git a/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java b/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java index b8259d36..fe6f3c6b 100644 --- a/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java +++ b/src/test/java/org/apache/maven/shared/filtering/DefaultMavenFileFilterTest.java @@ -40,11 +40,9 @@ import org.codehaus.plexus.interpolation.AbstractValueSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.sonatype.plexus.build.incremental.BuildContext; import static org.apache.maven.api.di.testing.MavenDIExtension.getBasedir; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.mock; /** * @author Olivier Lamy @@ -95,7 +93,7 @@ void nullSafeDefaultFilterWrappers() throws Exception { @Test void multiFilterFileInheritance() throws Exception { - DefaultMavenFileFilter mavenFileFilter = new DefaultMavenFileFilter(mock(BuildContext.class)); + DefaultMavenFileFilter mavenFileFilter = new DefaultMavenFileFilter(); File testDir = new File(getBasedir(), "src/test/units-files/MSHARED-177"); diff --git a/src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java b/src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java index 15929c48..3311c902 100644 --- a/src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java +++ b/src/test/java/org/apache/maven/shared/filtering/IncrementalResourceFilteringTest.java @@ -22,189 +22,363 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; -import java.util.HashSet; +import java.util.HashMap; import java.util.List; import java.util.Properties; -import java.util.Set; import org.apache.maven.api.di.Inject; import org.apache.maven.api.di.testing.MavenDITest; import org.apache.maven.api.plugin.testing.stubs.ProjectStub; +import org.apache.maven.api.services.PathMatcherFactory; import org.apache.maven.di.Injector; -import org.codehaus.plexus.util.FileUtils; -import org.junit.jupiter.api.AfterEach; +import org.apache.maven.impl.DefaultPathMatcherFactory; +import org.apache.maven.internal.build.context.impl.DefaultBuildContext; +import org.apache.maven.internal.build.context.impl.FilesystemWorkspace; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.sonatype.plexus.build.incremental.ThreadBuildContext; +import org.junit.jupiter.api.io.TempDir; -import static org.apache.maven.api.di.testing.MavenDIExtension.getBasedir; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +/** + * Tests incremental resource filtering using the Maven 4 BuildContext API. + * + *

These tests exercise the incremental code path in {@link DefaultMavenResourcesFiltering} + * by wiring in a real {@link DefaultBuildContext} (backed by a {@link FilesystemWorkspace}). + * This verifies: + *

    + *
  • Initial build processes all files
  • + *
  • Rebuild with no changes processes nothing
  • + *
  • Modified input is re-processed
  • + *
  • Deleted input causes stale output cleanup
  • + *
  • Include/exclude patterns are respected
  • + *
+ */ @MavenDITest class IncrementalResourceFilteringTest { - final Path baseDirectory = Paths.get(getBasedir()); - final Path outputDirectory = baseDirectory.resolve("target/IncrementalResourceFilteringTest"); - final Path unitDirectory = baseDirectory.resolve("src/test/units-files/incremental"); - final Path filters = unitDirectory.resolve("filters.txt"); - final Path inputFile01 = unitDirectory.resolve("files/file01.txt"); - Path inputFile02 = unitDirectory.resolve("files/file02.txt"); - final Path outputFile01 = outputDirectory.resolve("file01.txt"); - final Path outputFile02 = outputDirectory.resolve("file02.txt"); - @Inject Injector container; + @TempDir + Path tempDir; + + private Path sourceDir; + private Path outputDir; + private Path stateFile; + private ProjectStub mavenProject; + private PathMatcherFactory pathMatcherFactory; + @BeforeEach - protected void setUp() throws Exception { - FileUtils.deleteDirectory(outputDirectory.toFile()); - Files.createDirectories(outputDirectory); - } + void setUp() throws Exception { + sourceDir = tempDir.resolve("src"); + outputDir = tempDir.resolve("output"); + stateFile = tempDir.resolve("buildstate.ctx"); + Files.createDirectories(sourceDir); + Files.createDirectories(outputDir); + + mavenProject = new ProjectStub().setBasedir(tempDir); + mavenProject.setVersion("1.0"); + mavenProject.setGroupId("org.apache"); + mavenProject.setName("test project"); - @AfterEach - protected void tearDown() { - ThreadBuildContext.setThreadBuildContext(null); + pathMatcherFactory = new DefaultPathMatcherFactory(); } @Test - void simpleIncrementalFiltering() throws Exception { - // run full build first - filter("time"); + void initialBuildProcessesAllFiles() throws Exception { + // Create two source files + writeProperties(sourceDir.resolve("file01.txt"), "time", "initial"); + writeProperties(sourceDir.resolve("file02.txt"), "time", "initial"); + + // First build — all files should be processed + DefaultBuildContext ctx = newBuildContext(); + filterResources(ctx); + ctx.commit(null); + + // Both outputs should exist with correct content + assertPropertyValue("initial", outputDir.resolve("file01.txt"), "time"); + assertPropertyValue("initial", outputDir.resolve("file02.txt"), "time"); + } - assertTime("time", "file01.txt"); - assertTime("time", "file02.txt"); + @Test + void noChangeRebuildSkipsProcessing() throws Exception { + // Create source files + writeProperties(sourceDir.resolve("file01.txt"), "time", "initial"); + writeProperties(sourceDir.resolve("file02.txt"), "time", "initial"); + + // Initial build + DefaultBuildContext ctx1 = newBuildContext(); + filterResources(ctx1); + ctx1.commit(null); + + // Record output modification times + long mtime1 = Files.getLastModifiedTime(outputDir.resolve("file01.txt")).toMillis(); + long mtime2 = Files.getLastModifiedTime(outputDir.resolve("file02.txt")).toMillis(); + + // Small delay to ensure mtime would differ if files were rewritten + Thread.sleep(100); + + // Rebuild with no changes — outputs should NOT be rewritten + DefaultBuildContext ctx2 = newBuildContext(); + filterResources(ctx2); + ctx2.commit(null); + + // Outputs should still exist but NOT have been rewritten + assertTrue(Files.exists(outputDir.resolve("file01.txt"))); + assertTrue(Files.exists(outputDir.resolve("file02.txt"))); + assertEquals( + mtime1, + Files.getLastModifiedTime(outputDir.resolve("file01.txt")).toMillis(), + "file01.txt should not have been rewritten"); + assertEquals( + mtime2, + Files.getLastModifiedTime(outputDir.resolve("file02.txt")).toMillis(), + "file02.txt should not have been rewritten"); + } - // only one file is expected to change - Set changedFiles = new HashSet<>(); - changedFiles.add(inputFile01); + @Test + void modifiedInputIsReprocessed() throws Exception { + // Create source files + writeProperties(sourceDir.resolve("file01.txt"), "time", "initial"); + writeProperties(sourceDir.resolve("file02.txt"), "time", "initial"); + + // Initial build + DefaultBuildContext ctx1 = newBuildContext(); + filterResources(ctx1); + ctx1.commit(null); + + assertPropertyValue("initial", outputDir.resolve("file01.txt"), "time"); + assertPropertyValue("initial", outputDir.resolve("file02.txt"), "time"); + + // Modify only file01 + Thread.sleep(100); // ensure different mtime + writeProperties(sourceDir.resolve("file01.txt"), "time", "modified"); + + // Rebuild — only file01 should be re-processed + DefaultBuildContext ctx2 = newBuildContext(); + filterResources(ctx2); + ctx2.commit(null); + + assertPropertyValue("modified", outputDir.resolve("file01.txt"), "time"); + assertPropertyValue("initial", outputDir.resolve("file02.txt"), "time"); + } - TestIncrementalBuildContext ctx = new TestIncrementalBuildContext(baseDirectory, changedFiles); - ThreadBuildContext.setThreadBuildContext(ctx); + @Test + void deletedInputCausesStaleOutputCleanup() throws Exception { + // Create source files + writeProperties(sourceDir.resolve("file01.txt"), "time", "initial"); + writeProperties(sourceDir.resolve("file02.txt"), "time", "initial"); + + // Initial build + DefaultBuildContext ctx1 = newBuildContext(); + filterResources(ctx1); + ctx1.commit(null); + + assertTrue(Files.exists(outputDir.resolve("file01.txt"))); + assertTrue(Files.exists(outputDir.resolve("file02.txt"))); + + // Delete file01 from source + Files.delete(sourceDir.resolve("file01.txt")); + + // Rebuild — file01's output should be cleaned up + DefaultBuildContext ctx2 = newBuildContext(); + filterResources(ctx2); + ctx2.commit(null); + + assertFalse(Files.exists(outputDir.resolve("file01.txt")), "Stale output should be deleted"); + assertTrue(Files.exists(outputDir.resolve("file02.txt")), "Remaining output should still exist"); + assertPropertyValue("initial", outputDir.resolve("file02.txt"), "time"); + } - filter("notime"); - assertTime("notime", "file01.txt"); - assertTime("time", "file02.txt"); // this one is unchanged + @Test + void includeExcludePatternsRespected() throws Exception { + // Create source files with different extensions + writeProperties(sourceDir.resolve("included.txt"), "key", "included"); + writeProperties(sourceDir.resolve("excluded.xml"), "key", "excluded"); + writeProperties(sourceDir.resolve("also-included.txt"), "key", "also"); - assertTrue(ctx.getRefreshFiles().contains(outputFile01)); + // Build with include pattern for .txt only + DefaultBuildContext ctx = newBuildContext(); - // only one file is expected to change - Set deletedFiles = new HashSet<>(); - deletedFiles.add(inputFile01); + Resource resource = new Resource(); + resource.setDirectory(sourceDir.toString()); + resource.setFiltering(false); + resource.setIncludes(List.of("**/*.txt")); + + MavenResourcesExecution mre = createExecution(resource); + MavenResourcesFiltering filtering = createFilteringWithBuildContext(ctx); + filtering.filterResources(mre); + ctx.commit(null); + + assertTrue(Files.exists(outputDir.resolve("included.txt"))); + assertTrue(Files.exists(outputDir.resolve("also-included.txt"))); + assertFalse(Files.exists(outputDir.resolve("excluded.xml")), ".xml should be excluded by pattern"); + } - ctx = new TestIncrementalBuildContext(baseDirectory, null, deletedFiles); - ThreadBuildContext.setThreadBuildContext(ctx); + @Test + void excludePatternsRespected() throws Exception { + // Create source files + writeProperties(sourceDir.resolve("keep.txt"), "key", "kept"); + Files.createDirectories(sourceDir.resolve(".git")); + writeProperties(sourceDir.resolve(".git/config"), "key", "scm"); + writeProperties(sourceDir.resolve("skip.log"), "key", "skipped"); - filter("moretime"); - assertFalse(outputFile01.toFile().exists()); - assertTime("time", "file02.txt"); // this one is unchanged + // Build with exclude pattern + DefaultBuildContext ctx = newBuildContext(); - assertTrue(ctx.getRefreshFiles().contains(outputFile01)); + Resource resource = new Resource(); + resource.setDirectory(sourceDir.toString()); + resource.setFiltering(false); + resource.setExcludes(List.of("**/*.log")); + + MavenResourcesExecution mre = createExecution(resource); + mre.setAddDefaultExcludes(true); + MavenResourcesFiltering filtering = createFilteringWithBuildContext(ctx); + filtering.filterResources(mre); + ctx.commit(null); + + assertTrue(Files.exists(outputDir.resolve("keep.txt"))); + assertFalse(Files.exists(outputDir.resolve("skip.log")), ".log should be excluded"); + assertFalse(Files.exists(outputDir.resolve(".git/config")), ".git should be excluded by default"); } @Test - void outputChange() throws Exception { - // run full build first - filter("time"); + void newFileAddedIncrementally() throws Exception { + // Start with one file + writeProperties(sourceDir.resolve("file01.txt"), "time", "initial"); + + // Initial build + DefaultBuildContext ctx1 = newBuildContext(); + filterResources(ctx1); + ctx1.commit(null); - // all files are reprocessed after contents of output directory changed (e.g. was deleted) - Set changedFiles = new HashSet<>(); - changedFiles.add(outputDirectory); - TestIncrementalBuildContext ctx = new TestIncrementalBuildContext(baseDirectory, changedFiles); - ThreadBuildContext.setThreadBuildContext(ctx); + assertTrue(Files.exists(outputDir.resolve("file01.txt"))); + assertFalse(Files.exists(outputDir.resolve("file02.txt"))); - filter("notime"); - assertTime("notime", "file01.txt"); - assertTime("notime", "file02.txt"); + // Add a new file + writeProperties(sourceDir.resolve("file02.txt"), "time", "new"); - assertTrue(ctx.getRefreshFiles().contains(outputFile01)); - assertTrue(ctx.getRefreshFiles().contains(outputFile02)); + // Rebuild — new file should be picked up + DefaultBuildContext ctx2 = newBuildContext(); + filterResources(ctx2); + ctx2.commit(null); + + assertTrue(Files.exists(outputDir.resolve("file01.txt"))); + assertTrue(Files.exists(outputDir.resolve("file02.txt"))); + assertPropertyValue("new", outputDir.resolve("file02.txt"), "time"); } @Test - void filterChange() throws Exception { - // run full build first - filter("time"); + void filteringAppliedIncrementally() throws Exception { + // Create source file with property placeholder + writeProperties(sourceDir.resolve("app.properties"), "version", "${project.version}"); - // all files are reprocessed after content of filters changes - Set changedFiles = new HashSet<>(); - changedFiles.add(filters); - TestIncrementalBuildContext ctx = new TestIncrementalBuildContext(baseDirectory, changedFiles); - ThreadBuildContext.setThreadBuildContext(ctx); + mavenProject.addProperty("project.version", "1.0"); - filter("notime"); - assertTime("notime", "file01.txt"); - assertTime("notime", "file02.txt"); + // Initial build with filtering enabled + DefaultBuildContext ctx1 = newBuildContext(); + Resource resource = new Resource(); + resource.setDirectory(sourceDir.toString()); + resource.setFiltering(true); - assertTrue(ctx.getRefreshFiles().contains(outputFile01)); - assertTrue(ctx.getRefreshFiles().contains(outputFile02)); - } + MavenResourcesExecution mre = createExecution(resource); + mre.setUseDefaultFilterWrappers(true); + MavenResourcesFiltering filtering = createFilteringWithBuildContext(ctx1); + filtering.filterResources(mre); + ctx1.commit(null); - @Test - void filterDeleted() throws Exception { - // run full build first - filter("time"); + assertPropertyValue("1.0", outputDir.resolve("app.properties"), "version"); - // all files are reprocessed after content of filters changes - Set deletedFiles = new HashSet<>(); - deletedFiles.add(filters); - TestIncrementalBuildContext ctx = new TestIncrementalBuildContext(unitDirectory, null, deletedFiles); - ThreadBuildContext.setThreadBuildContext(ctx); + // Modify the source + Thread.sleep(100); + writeProperties(sourceDir.resolve("app.properties"), "version", "${project.version}-SNAPSHOT"); - filter("notime"); - assertTime("notime", "file01.txt"); - assertTime("notime", "file02.txt"); + // Rebuild — should re-filter with the property + DefaultBuildContext ctx2 = newBuildContext(); + mre = createExecution(resource); + mre.setUseDefaultFilterWrappers(true); + filtering = createFilteringWithBuildContext(ctx2); + filtering.filterResources(mre); + ctx2.commit(null); - assertTrue(ctx.getRefreshFiles().contains(outputFile01)); - assertTrue(ctx.getRefreshFiles().contains(outputFile02)); + assertPropertyValue("1.0-SNAPSHOT", outputDir.resolve("app.properties"), "version"); } - private void assertTime(String time, String relpath) throws IOException { - Properties properties = new Properties(); + @Test + void subdirectoryStructurePreserved() throws Exception { + // Create nested source files + Files.createDirectories(sourceDir.resolve("sub/deep")); + writeProperties(sourceDir.resolve("root.txt"), "key", "root"); + writeProperties(sourceDir.resolve("sub/nested.txt"), "key", "nested"); + writeProperties(sourceDir.resolve("sub/deep/deep.txt"), "key", "deep"); + + // Build + DefaultBuildContext ctx = newBuildContext(); + filterResources(ctx); + ctx.commit(null); + + assertTrue(Files.exists(outputDir.resolve("root.txt"))); + assertTrue(Files.exists(outputDir.resolve("sub/nested.txt"))); + assertTrue(Files.exists(outputDir.resolve("sub/deep/deep.txt"))); + assertPropertyValue("deep", outputDir.resolve("sub/deep/deep.txt"), "key"); + } - try (InputStream is = Files.newInputStream(outputDirectory.resolve(relpath))) { - properties.load(is); - } + // --- Helpers --- - assertEquals(time, properties.getProperty("time")); + private DefaultBuildContext newBuildContext() { + return new DefaultBuildContext(new FilesystemWorkspace(), stateFile, new HashMap<>(), null, pathMatcherFactory); } - private void filter(String time) throws Exception { - Path baseDir = Paths.get(getBasedir()); - ProjectStub mavenProject = new ProjectStub().setBasedir(baseDir); - mavenProject.setVersion("1.0"); - mavenProject.setGroupId("org.apache"); - mavenProject.setName("test project"); - - mavenProject.addProperty("time", time); - mavenProject.addProperty("java.version", "zloug"); - MavenResourcesFiltering mavenResourcesFiltering = container.getInstance(MavenResourcesFiltering.class); + private void filterResources(DefaultBuildContext ctx) throws MavenFilteringException { + Resource resource = new Resource(); + resource.setDirectory(sourceDir.toString()); + resource.setFiltering(false); - String unitFilesDir = unitDirectory.resolve("files").toString(); + MavenResourcesExecution mre = createExecution(resource); + MavenResourcesFiltering filtering = createFilteringWithBuildContext(ctx); + filtering.filterResources(mre); + } - Resource resource = new Resource(); + private MavenResourcesExecution createExecution(Resource resource) { List resources = new ArrayList<>(); resources.add(resource); - resource.setDirectory(unitFilesDir); - resource.setFiltering(true); - - List filtersFile = new ArrayList<>(); - filtersFile.add(unitDirectory.resolve("filters.txt").toString()); MavenResourcesExecution mre = new MavenResourcesExecution(); mre.setResources(resources); - mre.setOutputDirectory(outputDirectory); + mre.setOutputDirectory(outputDir); mre.setEncoding("UTF-8"); mre.setMavenProject(mavenProject); - mre.setFilters(filtersFile); + mre.setFilters(Collections.emptyList()); mre.setNonFilteredFileExtensions(Collections.emptyList()); mre.setMavenSession(new StubSession()); - mre.setUseDefaultFilterWrappers(true); + return mre; + } - mavenResourcesFiltering.filterResources(mre); + private MavenResourcesFiltering createFilteringWithBuildContext(DefaultBuildContext ctx) { + MavenFileFilter fileFilter = container.getInstance(MavenFileFilter.class); + return new DefaultMavenResourcesFiltering(fileFilter, ctx, pathMatcherFactory); + } + + private static void writeProperties(Path file, String key, String value) throws IOException { + Files.createDirectories(file.getParent()); + Properties props = new Properties(); + props.setProperty(key, value); + try (var out = Files.newOutputStream(file)) { + props.store(out, null); + } + } + + private static void assertPropertyValue(String expected, Path file, String key) throws IOException { + assertTrue(Files.exists(file), "File should exist: " + file); + Properties props = new Properties(); + try (InputStream is = Files.newInputStream(file)) { + props.load(is); + } + assertEquals(expected, props.getProperty(key), "Property '" + key + "' in " + file.getFileName()); } } diff --git a/src/test/java/org/apache/maven/shared/filtering/Providers.java b/src/test/java/org/apache/maven/shared/filtering/Providers.java index a21f21f0..cf8565a9 100644 --- a/src/test/java/org/apache/maven/shared/filtering/Providers.java +++ b/src/test/java/org/apache/maven/shared/filtering/Providers.java @@ -18,16 +18,44 @@ */ package org.apache.maven.shared.filtering; +import java.util.HashMap; + +import org.apache.maven.api.build.context.BuildContext; import org.apache.maven.api.di.Named; +import org.apache.maven.api.di.Priority; import org.apache.maven.api.di.Provides; -import org.sonatype.plexus.build.incremental.BuildContext; -import org.sonatype.plexus.build.incremental.ThreadBuildContext; +import org.apache.maven.api.services.PathMatcherFactory; +import org.apache.maven.impl.DefaultPathMatcherFactory; +import org.apache.maven.internal.build.context.impl.DefaultBuildContext; +import org.apache.maven.internal.build.context.impl.FilesystemWorkspace; +/** + * DI provider for test-scoped bindings. + * + *

Provides a real {@link BuildContext} backed by a {@link FilesystemWorkspace} so that + * tests running with {@code @MavenDITest} can inject {@link DefaultMavenResourcesFiltering} + * without requiring the full Maven runtime (which needs mojo execution scope infrastructure). + * The BuildContext created here has no state file, so it treats every build as a full build — + * all files are reported as {@code NEW}.

+ * + *

The {@code @Priority(10)} annotation ensures this binding wins over the auto-discovered + * {@code MavenBuildContext} from maven-core on the test classpath.

+ */ @Named class Providers { @Provides - static BuildContext buildContext() { - return new ThreadBuildContext(); + @Priority(10) + PathMatcherFactory pathMatcherFactory() { + return new DefaultPathMatcherFactory(); + } + + @Provides + @Priority(10) + BuildContext buildContext() { + // No state file (null) → every invocation is a full build (all files are NEW). + // This matches the behavior expected by the existing non-incremental tests. + return new DefaultBuildContext( + new FilesystemWorkspace(), null, new HashMap<>(), null, new DefaultPathMatcherFactory()); } } diff --git a/src/test/java/org/apache/maven/shared/filtering/TestIncrementalBuildContext.java b/src/test/java/org/apache/maven/shared/filtering/TestIncrementalBuildContext.java deleted file mode 100644 index 95e1e328..00000000 --- a/src/test/java/org/apache/maven/shared/filtering/TestIncrementalBuildContext.java +++ /dev/null @@ -1,224 +0,0 @@ -/* - * 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.shared.filtering; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.nio.file.Path; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.Set; - -import org.codehaus.plexus.util.DirectoryScanner; -import org.codehaus.plexus.util.Scanner; -import org.sonatype.plexus.build.incremental.BuildContext; - -/** - * {@link TestIncrementalBuildContext} mock for testing purpose. It allows to - * check build behavior based on {@link #isUptodate(File, File)} return value. - * - * Constructor parameters allow to indicate folder/files to declare files as - * modified / deleted. - * - * hasDelta, isUptodate, newScanner, newDeleteScanner methods output consistent - * values based upon changedFiles / deletedFiles inputs. - * - * getRefreshFiles method allows to check files modified by build. - */ -public class TestIncrementalBuildContext implements BuildContext { - - private final Path basedir; - private final Set refresh = new HashSet<>(); - private final Set changedFiles = new HashSet<>(); - private final Set deletedFiles = new HashSet<>(); - private final Map context = new HashMap<>(); - - public TestIncrementalBuildContext(Path basedir, Set changedFiles) { - this(basedir, changedFiles, null); - } - - public TestIncrementalBuildContext(Path basedir, Set changedFiles, Set deletedFiles) { - checkPath(basedir); - this.basedir = basedir; - Optional.ofNullable(changedFiles).ifPresent(this.changedFiles::addAll); - Optional.ofNullable(deletedFiles).ifPresent(this.deletedFiles::addAll); - this.changedFiles.forEach(TestIncrementalBuildContext::checkPath); - this.deletedFiles.forEach(TestIncrementalBuildContext::checkPath); - } - - public static void checkPath(Path path) { - if (!path.isAbsolute()) { - throw new IllegalStateException(String.format("Absolute path are expected. Failing path %s", path)); - } - } - - public Set getRefreshFiles() { - return Collections.unmodifiableSet(refresh); - } - - /** - * Check that relpath or parent folders of relpath is not listed in modified / - * deleted files. - */ - @Override - public boolean hasDelta(String relpath) { - Path candidate = basedir.resolve(relpath); - boolean changed = false; - while (candidate != null) { - changed = changedFiles.contains(candidate) || deletedFiles.contains(candidate); - if (changed || candidate.equals(basedir)) { - break; - } - candidate = candidate.getParent(); - } - return changed; - } - - @SuppressWarnings("unchecked") - @Override - public boolean hasDelta(List relpaths) { - return ((List) relpaths).stream().anyMatch(this::hasDelta); - } - - @Override - public boolean hasDelta(File file) { - return hasDelta(file.getAbsolutePath()); - } - - @Override - public boolean isIncremental() { - return true; - } - - @Override - public Scanner newDeleteScanner(File basedir) { - return new TestScanner(basedir, deletedFiles); - } - - @Override - public OutputStream newFileOutputStream(File file) throws IOException { - refresh(file); - return new FileOutputStream(file); - } - - @Override - public Scanner newScanner(final File basedir) { - return new TestScanner(basedir, changedFiles); - } - - @Override - public Scanner newScanner(File basedir, boolean ignoreDelta) { - if (ignoreDelta) { - DirectoryScanner directoryScanner = new DirectoryScanner(); - directoryScanner.setBasedir(basedir); - return directoryScanner; - } - - return newScanner(basedir); - } - - @Override - public void refresh(File file) { - refresh.add(file.toPath()); - } - - @Override - public Object getValue(String key) { - return context.get(key); - } - - @Override - public void setValue(String key, Object value) { - context.put(key, value); - } - - @Override - public void addError(File file, int line, int column, String message, Throwable cause) {} - - @Override - public void addWarning(File file, int line, int column, String message, Throwable cause) {} - - @Override - public void addMessage(File file, int line, int column, String message, int severity, Throwable cause) {} - - @Override - public void removeMessages(File file) {} - - @Override - public boolean isUptodate(File target, File source) { - return target != null - && target.exists() - && !hasDelta(target) - && source != null - && source.exists() - && !hasDelta(source) - && target.lastModified() > source.lastModified(); - } - - private static final class TestScanner implements Scanner { - private final Path basedir; - private final Set files = new HashSet<>(); - - private TestScanner(File basedir, Set files) { - this.basedir = basedir.toPath().toAbsolutePath(); - Optional.ofNullable(files).ifPresent(this.files::addAll); - } - - @Override - public void addDefaultExcludes() {} - - @Override - public String[] getIncludedDirectories() { - return new String[0]; - } - - @Override - public String[] getIncludedFiles() { - return files.stream() - .filter(p -> p.startsWith(basedir)) - .map(basedir::relativize) - .map(Path::toString) - .toArray(String[]::new); - } - - @Override - public void scan() {} - - @Override - public void setExcludes(String[] excludes) {} - - @Override - public void setIncludes(String[] includes) {} - - @Override - public File getBasedir() { - return basedir.toFile(); - } - - @Override - public void setFilenameComparator(Comparator filenameComparator) {} - } -}