diff --git a/src/it/MCOMPILER-1087_dependency-removed/application/pom.xml b/src/it/MCOMPILER-1087_dependency-removed/application/pom.xml new file mode 100644 index 000000000..0df588997 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-removed/application/pom.xml @@ -0,0 +1,45 @@ + + + + 4.0.0 + + + org.apache.maven.plugins.compiler.it + mcompiler1087-test + 1.0-SNAPSHOT + + + application + + + + with-dependency + + + org.apache.maven.plugins.compiler.it + dependency + ${project.version} + + + + + diff --git a/src/it/MCOMPILER-1087_dependency-removed/application/src/main/java/org/example/Application.java b/src/it/MCOMPILER-1087_dependency-removed/application/src/main/java/org/example/Application.java new file mode 100644 index 000000000..7e21d75d7 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-removed/application/src/main/java/org/example/Application.java @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.example; + +public class Application { + private DependencyType dependency; +} diff --git a/src/it/MCOMPILER-1087_dependency-removed/dependency/pom.xml b/src/it/MCOMPILER-1087_dependency-removed/dependency/pom.xml new file mode 100644 index 000000000..82c6b4ca1 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-removed/dependency/pom.xml @@ -0,0 +1,32 @@ + + + + 4.0.0 + + + org.apache.maven.plugins.compiler.it + mcompiler1087-test + 1.0-SNAPSHOT + + + dependency + diff --git a/src/it/MCOMPILER-1087_dependency-removed/dependency/src/main/java/org/example/DependencyType.java b/src/it/MCOMPILER-1087_dependency-removed/dependency/src/main/java/org/example/DependencyType.java new file mode 100644 index 000000000..b6789f888 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-removed/dependency/src/main/java/org/example/DependencyType.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.example; + +public class DependencyType {} diff --git a/src/it/MCOMPILER-1087_dependency-removed/invoker.properties b/src/it/MCOMPILER-1087_dependency-removed/invoker.properties new file mode 100644 index 000000000..c98e01d53 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-removed/invoker.properties @@ -0,0 +1,20 @@ +# 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. + +invoker.goals.1 = clean compile -Pwith-dependency +invoker.goals.2 = compile +invoker.buildResult.2 = failure diff --git a/src/it/MCOMPILER-1087_dependency-removed/pom.xml b/src/it/MCOMPILER-1087_dependency-removed/pom.xml new file mode 100644 index 000000000..b94961321 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-removed/pom.xml @@ -0,0 +1,50 @@ + + + + 4.0.0 + + org.apache.maven.plugins.compiler.it + mcompiler1087-test + 1.0-SNAPSHOT + pom + + + dependency + application + + + + 8 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + @project.version@ + + + + + diff --git a/src/it/MCOMPILER-1087_dependency-removed/verify.groovy b/src/it/MCOMPILER-1087_dependency-removed/verify.groovy new file mode 100644 index 000000000..b9c7b042b --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-removed/verify.groovy @@ -0,0 +1,26 @@ +/* + * 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. + */ +def log = new File(basedir, 'build.log').text +def dependencyState = new File( + basedir, 'application/target/maven-status/maven-compiler-plugin/compile/default-compile/dependencies.lst') + +assert log.contains('Recompiling the module because of changed dependency.') +assert log.contains('cannot find symbol') +assert dependencyState.isFile() +assert dependencyState.text.isEmpty() diff --git a/src/it/MCOMPILER-1087_dependency-replaced/dependency/pom.xml b/src/it/MCOMPILER-1087_dependency-replaced/dependency/pom.xml new file mode 100644 index 000000000..116643dc3 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-replaced/dependency/pom.xml @@ -0,0 +1,37 @@ + + + + 4.0.0 + + org.apache.maven.plugins.compiler.it + dependency + 1.0-SNAPSHOT + + + old + 8 + + + + ${project.basedir}/src/${dependency.api}/java + + diff --git a/src/it/MCOMPILER-1087_dependency-replaced/dependency/src/new/java/org/example/DependencyType.java b/src/it/MCOMPILER-1087_dependency-replaced/dependency/src/new/java/org/example/DependencyType.java new file mode 100644 index 000000000..f879183b4 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-replaced/dependency/src/new/java/org/example/DependencyType.java @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.example; + +public class DependencyType { + public void replacementMethod() {} +} diff --git a/src/it/MCOMPILER-1087_dependency-replaced/dependency/src/old/java/org/example/DependencyType.java b/src/it/MCOMPILER-1087_dependency-replaced/dependency/src/old/java/org/example/DependencyType.java new file mode 100644 index 000000000..8a4c295e1 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-replaced/dependency/src/old/java/org/example/DependencyType.java @@ -0,0 +1,23 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.example; + +public class DependencyType { + public void removedMethod() {} +} diff --git a/src/it/MCOMPILER-1087_dependency-replaced/invoker.properties b/src/it/MCOMPILER-1087_dependency-replaced/invoker.properties new file mode 100644 index 000000000..983166db3 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-replaced/invoker.properties @@ -0,0 +1,22 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +invoker.goals.1 = -f dependency/pom.xml clean install -Ddependency.api=old +invoker.goals.2 = compile +invoker.goals.3 = -f dependency/pom.xml clean install -Ddependency.api=new +invoker.goals.4 = compile +invoker.buildResult.4 = failure diff --git a/src/it/MCOMPILER-1087_dependency-replaced/pom.xml b/src/it/MCOMPILER-1087_dependency-replaced/pom.xml new file mode 100644 index 000000000..a227fd60f --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-replaced/pom.xml @@ -0,0 +1,50 @@ + + + + 4.0.0 + + org.apache.maven.plugins.compiler.it + application + 1.0-SNAPSHOT + + + 8 + + + + + org.apache.maven.plugins.compiler.it + dependency + 1.0-SNAPSHOT + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + @project.version@ + + + + diff --git a/src/it/MCOMPILER-1087_dependency-replaced/src/main/java/org/example/Application.java b/src/it/MCOMPILER-1087_dependency-replaced/src/main/java/org/example/Application.java new file mode 100644 index 000000000..fe6a180a2 --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-replaced/src/main/java/org/example/Application.java @@ -0,0 +1,25 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.example; + +public class Application { + public void run() { + new DependencyType().removedMethod(); + } +} diff --git a/src/it/MCOMPILER-1087_dependency-replaced/verify.groovy b/src/it/MCOMPILER-1087_dependency-replaced/verify.groovy new file mode 100644 index 000000000..330d8184d --- /dev/null +++ b/src/it/MCOMPILER-1087_dependency-replaced/verify.groovy @@ -0,0 +1,26 @@ +/* + * 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. + */ +def log = new File(basedir, 'build.log').text +def dependencyState = new File( + basedir, 'target/maven-status/maven-compiler-plugin/compile/default-compile/dependencies.lst') + +assert log.contains('Dependency modified: classpath:') +assert log.contains('Recompiling the module because of changed dependency.') +assert log.contains('cannot find symbol') +assert dependencyState.readLines().every { it.contains('\t') } diff --git a/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java b/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java index edb9ad7ed..8350a6552 100644 --- a/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java +++ b/src/main/java/org/apache/maven/plugin/compiler/AbstractCompilerMojo.java @@ -80,7 +80,6 @@ import org.codehaus.plexus.compiler.util.scan.mapping.SuffixMapping; import org.codehaus.plexus.languages.java.jpms.JavaModuleDescriptor; import org.codehaus.plexus.languages.java.version.JavaVersion; -import org.codehaus.plexus.util.FileUtils; import org.eclipse.aether.RepositorySystem; import org.eclipse.aether.artifact.Artifact; import org.eclipse.aether.artifact.ArtifactTypeRegistry; @@ -949,7 +948,19 @@ private void executeReal() throws MojoExecutionException, CompilationFailureExce && !canUpdateTarget) ? "immutable single output file" : null; - String dependencyChanged = isDependencyChanged() ? "changed dependency" : null; + String dependencyChanged = DependencyState.hasChanged( + incrementalBuildHelper, + getOutputDirectory(), + getClasspathElements(), + getModulepathElements(), + new DependencyState.Configuration( + fileExtensions, + getBuildStartTimeInstant().orElse(null), + staleMillis, + getLog(), + showCompilationChanges)) + ? "changed dependency" + : null; String sourceChanged = isSourceChanged(compilerConfiguration, compiler) ? "changed source code" : null; String inputFileTreeChanged = hasInputFileTreeChanged(incrementalBuildHelper, sources) ? "added or removed source files" @@ -1605,81 +1616,6 @@ private static List removeEmptyCompileSourceRoots(List compileSo return newCompileSourceRootsList; } - /** - * We just compare the timestamps of all local dependency files (inter-module dependency classpath) and the own - * generated classes and if we got a file which is >= the build-started timestamp, then we caught a file which - * got changed during this build. - * - * @return {@code true} if at least one single dependency has changed. - */ - protected boolean isDependencyChanged() { - final Instant buildStartTime = getBuildStartTimeInstant().orElse(null); - if (buildStartTime == null) { - // we just cannot determine it, so don't do anything beside logging - getLog().debug("Cannot determine build start time, skipping incremental build detection."); - return false; - } - - if (fileExtensions == null || fileExtensions.isEmpty()) { - fileExtensions = new HashSet<>(Arrays.asList("class", "jar")); - } - - List pathElements = new ArrayList<>(); - pathElements.addAll(getClasspathElements()); - pathElements.addAll(getModulepathElements()); - - for (String pathElement : pathElements) { - Path artifactPath = Paths.get(pathElement); - - // Search files only on dependencies (other modules), not on the current project, - if (Files.isDirectory(artifactPath) - && !artifactPath.equals(getOutputDirectory().toPath())) { - try (Stream walk = Files.walk(artifactPath)) { - if (walk.anyMatch(p -> hasNewFile(p, buildStartTime))) { - return true; - } - } catch (IOException ex) { - // we just cannot determine it, so don't do anything beside logging - getLog().warn("I/O error walking the path: " + ex.getMessage()); - return false; - } - } else if (hasNewFile(artifactPath, buildStartTime)) { - return true; - } - } - - // obviously there was no new file detected. - return false; - } - - /** - * @param file entry to check - * @param buildStartTime time build start - * @return if any changes occurred - */ - private boolean hasNewFile(Path file, Instant buildStartTime) { - if (Files.isRegularFile(file) - && fileExtensions.contains( - FileUtils.extension(file.getFileName().toString()))) { - try { - Instant lastModifiedTime = Files.getLastModifiedTime(file) - .toInstant() - .minusMillis(staleMillis) - .truncatedTo(ChronoUnit.MILLIS); - boolean hasChanged = lastModifiedTime.isAfter(buildStartTime); - if (hasChanged && (getLog().isDebugEnabled() || showCompilationChanges)) { - getLog().info("\tNew dependency detected: " + file.toAbsolutePath()); - } - return hasChanged; - } catch (IOException ex) { - // we just cannot determine it, so don't do anything beside logging - getLog().warn("I/O error reading the lastModifiedTime: " + ex.getMessage()); - } - } - - return false; - } - private List resolveProcessorPathEntries() throws MojoExecutionException { if (annotationProcessorPaths == null || annotationProcessorPaths.isEmpty()) { return null; diff --git a/src/main/java/org/apache/maven/plugin/compiler/DependencyState.java b/src/main/java/org/apache/maven/plugin/compiler/DependencyState.java new file mode 100644 index 000000000..8160fbd43 --- /dev/null +++ b/src/main/java/org/apache/maven/plugin/compiler/DependencyState.java @@ -0,0 +1,306 @@ +/* + * 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.plugin.compiler; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.shared.incremental.IncrementalBuildHelper; +import org.codehaus.plexus.util.FileUtils; + +/** + * Persists the class path and module path used by a compiler Mojo execution and detects dependencies modified during + * the current Maven build while collecting that state. + */ +final class DependencyState { + private static final String STATE_FILE = "dependencies.lst"; + private static final String STATE_SEPARATOR = "\t"; + private static final Set DEFAULT_FILE_EXTENSIONS = + Collections.unmodifiableSet(new HashSet<>(Arrays.asList("class", "jar"))); + + private DependencyState() {} + + static boolean hasChanged( + IncrementalBuildHelper incrementalBuildHelper, + File outputDirectory, + List classpathElements, + List modulepathElements, + Configuration configuration) { + Path stateFile = null; + try { + stateFile = incrementalBuildHelper.getMojoStatusDirectory().toPath().resolve(STATE_FILE); + } catch (MojoExecutionException e) { + configuration.log.warn("Error reading mojo status directory."); + } + + List oldState = Collections.emptyList(); + boolean hasPreviousState = stateFile != null && Files.isRegularFile(stateFile); + if (hasPreviousState) { + try { + oldState = Files.readAllLines(stateFile); + } catch (IOException e) { + configuration.log.warn("Error while reading old dependency status: " + stateFile); + hasPreviousState = false; + } + } + + List newState = new ArrayList<>(); + ScanContext context = new ScanContext(outputDirectory, configuration); + Path changedDependency = addDependencies(newState, "classpath:", classpathElements, context, null); + changedDependency = addDependencies(newState, "modulepath:", modulepathElements, context, changedDependency); + + if (stateFile != null) { + try { + Files.write(stateFile, newState); + } catch (IOException e) { + configuration.log.warn("Error while writing new dependency status: " + stateFile); + } + } + + if (hasPreviousState && !oldState.equals(newState)) { + if (configuration.log.isDebugEnabled() || configuration.showChanges) { + logChanges(oldState, newState, configuration.log); + } + return true; + } + if (changedDependency != null) { + if (configuration.log.isDebugEnabled() || configuration.showChanges) { + configuration.log.info("\tNew dependency detected: " + changedDependency.toAbsolutePath()); + } + return true; + } + if (configuration.buildStartTime == null) { + configuration.log.debug("Cannot determine build start time, skipping incremental build detection."); + } + return false; + } + + private static Path addDependencies( + List state, String prefix, List pathElements, ScanContext context, Path changedDependency) { + for (String pathElement : pathElements) { + Path dependencyPath = Paths.get(pathElement).toAbsolutePath().normalize(); + if (!dependencyPath.equals(context.outputPath)) { + ModificationState modification = context.modifications.get(dependencyPath); + if (modification == null) { + modification = modificationState(dependencyPath, context); + context.modifications.put(dependencyPath, modification); + } + state.add(prefix + dependencyPath + STATE_SEPARATOR + modification.recordedState); + if (changedDependency == null && modification.changedDependency != null) { + changedDependency = modification.changedDependency; + } + } + } + return changedDependency; + } + + /** + * Returns {@code size:mtime} for a file, or {@code relevant-file-count:metadata-sha256} for a directory. + * The directory digest covers each relevant file's relative path, size and modification time. + */ + private static ModificationState modificationState(Path dependency, ScanContext context) { + if (!Files.isDirectory(dependency)) { + try { + BasicFileAttributes attributes = readAttributes(dependency); + Path changedDependency = context.fileExtensions.contains(FileUtils.extension( + dependency.getFileName().toString())) + && changedSinceBuildStart(attributes, context) + ? dependency + : null; + return new ModificationState(fileMetadata(attributes), changedDependency); + } catch (IOException e) { + context.log.warn("I/O error reading dependency state: " + dependency + ": " + e.getMessage()); + return new ModificationState("unreadable", null); + } + } + + List files; + try (Stream walk = Files.walk(dependency)) { + files = walk.filter(Files::isRegularFile) + .filter(file -> context.fileExtensions.contains( + FileUtils.extension(file.getFileName().toString()))) + .sorted() + .collect(Collectors.toList()); + } catch (IOException e) { + context.log.warn("I/O error reading dependency state: " + dependency + ": " + e.getMessage()); + return new ModificationState("unreadable", null); + } + + MessageDigest digest = newDigest(); + Path changedDependency = null; + boolean unreadable = false; + for (Path file : files) { + try { + BasicFileAttributes attributes = readAttributes(file); + update(digest, dependency.relativize(file).toString()); + update(digest, fileMetadata(attributes)); + if (changedDependency == null && changedSinceBuildStart(attributes, context)) { + changedDependency = file; + } + } catch (IOException e) { + if (!unreadable) { + context.log.warn("I/O error reading dependency state: " + dependency + ": " + e.getMessage()); + unreadable = true; + } + } + } + String recordedState = unreadable ? "unreadable" : files.size() + ":" + toHexString(digest.digest()); + return new ModificationState(recordedState, changedDependency); + } + + private static BasicFileAttributes readAttributes(Path file) throws IOException { + return Files.readAttributes(file, BasicFileAttributes.class); + } + + private static String fileMetadata(BasicFileAttributes attributes) { + return attributes.size() + ":" + attributes.lastModifiedTime().toMillis(); + } + + private static boolean changedSinceBuildStart(BasicFileAttributes attributes, ScanContext context) { + return context.buildStartTime != null + && attributes + .lastModifiedTime() + .toInstant() + .minusMillis(context.staleMillis) + .truncatedTo(ChronoUnit.MILLIS) + .isAfter(context.buildStartTime); + } + + private static MessageDigest newDigest() { + try { + return MessageDigest.getInstance("SHA-256"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } + + private static void update(MessageDigest digest, String value) { + digest.update(value.getBytes(StandardCharsets.UTF_8)); + digest.update((byte) 0); + } + + private static String toHexString(byte[] bytes) { + StringBuilder buffer = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + int unsigned = value & 0xFF; + buffer.append(Character.forDigit(unsigned >>> 4, 16)); + buffer.append(Character.forDigit(unsigned & 0x0F, 16)); + } + return buffer.toString(); + } + + private static void logChanges(List oldState, List newState, Log log) { + List oldPaths = paths(oldState); + List newPaths = paths(newState); + DeltaList pathChanges = new DeltaList<>(oldPaths, newPaths); + for (String dependencyAdded : pathChanges.getAdded()) { + log.info("\tDependency path (+): " + dependencyAdded); + } + for (String dependencyRemoved : pathChanges.getRemoved()) { + log.info("\tDependency path (-): " + dependencyRemoved); + } + if (!pathChanges.hasChanged()) { + if (!oldPaths.equals(newPaths)) { + log.info("\tDependency path order changed."); + } else { + for (int i = 0; i < newState.size(); i++) { + if (!oldState.get(i).equals(newState.get(i))) { + log.info("\tDependency modified: " + newPaths.get(i)); + } + } + } + } + } + + private static List paths(Collection state) { + return state.stream().map(DependencyState::path).collect(Collectors.toList()); + } + + private static String path(String stateEntry) { + int separator = stateEntry.indexOf(STATE_SEPARATOR); + return separator >= 0 ? stateEntry.substring(0, separator) : stateEntry; + } + + static final class Configuration { + private final Set fileExtensions; + private final Instant buildStartTime; + private final int staleMillis; + private final Log log; + private final boolean showChanges; + + Configuration( + Set fileExtensions, Instant buildStartTime, int staleMillis, Log log, boolean showChanges) { + this.fileExtensions = + fileExtensions == null || fileExtensions.isEmpty() ? DEFAULT_FILE_EXTENSIONS : fileExtensions; + this.buildStartTime = buildStartTime; + this.staleMillis = staleMillis; + this.log = log; + this.showChanges = showChanges; + } + } + + private static final class ScanContext { + private final Path outputPath; + private final Set fileExtensions; + private final Instant buildStartTime; + private final int staleMillis; + private final Log log; + private final Map modifications = new HashMap<>(); + + private ScanContext(File outputDirectory, Configuration configuration) { + outputPath = outputDirectory.toPath().toAbsolutePath().normalize(); + fileExtensions = configuration.fileExtensions; + buildStartTime = configuration.buildStartTime; + staleMillis = configuration.staleMillis; + log = configuration.log; + } + } + + private static final class ModificationState { + private final String recordedState; + private final Path changedDependency; + + private ModificationState(String recordedState, Path changedDependency) { + this.recordedState = recordedState; + this.changedDependency = changedDependency; + } + } +} diff --git a/src/test/java/org/apache/maven/plugin/compiler/DependencyStateTest.java b/src/test/java/org/apache/maven/plugin/compiler/DependencyStateTest.java new file mode 100644 index 000000000..e2acaa381 --- /dev/null +++ b/src/test/java/org/apache/maven/plugin/compiler/DependencyStateTest.java @@ -0,0 +1,154 @@ +/* + * 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.plugin.compiler; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.apache.maven.plugin.MojoExecutionException; +import org.apache.maven.plugin.logging.Log; +import org.apache.maven.shared.incremental.IncrementalBuildHelper; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DependencyStateTest { + private static final Instant BUILD_START = Instant.ofEpochMilli(2_000); + + @TempDir + Path temporaryDirectory; + + @Test + void detectsDependencyNewerThanBuildStartWithoutPreviousState() throws Exception { + Path dependency = dependency("dependency.jar", 3_000, 1); + + assertTrue(hasChanged(Collections.singletonList(dependency), Collections.emptyList(), BUILD_START, 0)); + assertTrue(Files.isRegularFile(temporaryDirectory.resolve("dependencies.lst"))); + } + + @Test + void createsBaselineWithoutRebuildingForOlderDependency() throws Exception { + Path dependency = dependency("dependency.jar", 1_000, 1); + + assertFalse(hasChanged(Collections.singletonList(dependency), Collections.emptyList(), BUILD_START, 0)); + assertFalse(hasChanged(Collections.singletonList(dependency), Collections.emptyList(), BUILD_START, 0)); + } + + @Test + void detectsSamePathReplacementBeforeNextBuildStarts() throws Exception { + Path dependency = dependency("dependency.jar", 1_000, 1); + assertFalse(hasChanged(Collections.singletonList(dependency), Collections.emptyList(), BUILD_START, 0)); + + Files.write(dependency, new byte[] {1, 2}); + Files.setLastModifiedTime(dependency, FileTime.fromMillis(3_000)); + + assertTrue(hasChanged( + Collections.singletonList(dependency), Collections.emptyList(), Instant.ofEpochMilli(4_000), 0)); + } + + @Test + void detectsPersistedChangeWithoutBuildStartTime() throws Exception { + Path dependency = dependency("dependency.jar", 1_000, 1); + assertFalse(hasChanged(Collections.singletonList(dependency), Collections.emptyList(), null, 0)); + + Files.write(dependency, new byte[] {1, 2}); + + assertTrue(hasChanged(Collections.singletonList(dependency), Collections.emptyList(), null, 0)); + } + + @Test + void detectsChangedFileInExplodedDependencyWithoutPreviousState() throws Exception { + Path dependency = Files.createDirectories(temporaryDirectory.resolve("classes")); + Path classFile = dependency.resolve("example/Dependency.class"); + Files.createDirectories(classFile.getParent()); + Files.write(classFile, new byte[] {1}); + Files.setLastModifiedTime(classFile, FileTime.fromMillis(3_000)); + + assertTrue(hasChanged(Collections.singletonList(dependency), Collections.emptyList(), BUILD_START, 0)); + } + + @Test + void appliesStaleMillisToCurrentBuildDetection() throws Exception { + Path dependency = dependency("dependency.jar", 3_000, 1); + + assertFalse(hasChanged(Collections.singletonList(dependency), Collections.emptyList(), BUILD_START, 1_000)); + } + + @Test + void preservesRepeatedDependencyEntriesAndPathKinds() throws Exception { + Path dependency = dependency("dependency.jar", 1_000, 1); + + assertFalse(hasChanged( + Arrays.asList(dependency, dependency), Collections.singletonList(dependency), BUILD_START, 0)); + + List state = Files.readAllLines(temporaryDirectory.resolve("dependencies.lst")); + assertEquals(3, state.size()); + assertTrue(state.get(0).startsWith("classpath:")); + assertTrue(state.get(1).startsWith("classpath:")); + assertTrue(state.get(2).startsWith("modulepath:")); + } + + @Test + void retainsCurrentBuildDetectionWhenStatusDirectoryIsUnavailable() throws Exception { + Path dependency = dependency("dependency.jar", 3_000, 1); + IncrementalBuildHelper helper = mock(IncrementalBuildHelper.class); + when(helper.getMojoStatusDirectory()).thenThrow(new MojoExecutionException("unavailable")); + + assertTrue(DependencyState.hasChanged( + helper, + temporaryDirectory.resolve("output").toFile(), + Collections.singletonList(dependency.toString()), + Collections.emptyList(), + new DependencyState.Configuration(Collections.emptySet(), BUILD_START, 0, mock(Log.class), false))); + } + + private Path dependency(String name, long modifiedTime, int size) throws Exception { + Path dependency = temporaryDirectory.resolve(name); + Files.write(dependency, new byte[size]); + Files.setLastModifiedTime(dependency, FileTime.fromMillis(modifiedTime)); + return dependency; + } + + private boolean hasChanged(List classpath, List modulepath, Instant buildStartTime, int staleMillis) + throws Exception { + IncrementalBuildHelper helper = mock(IncrementalBuildHelper.class); + when(helper.getMojoStatusDirectory()).thenReturn(temporaryDirectory.toFile()); + return DependencyState.hasChanged( + helper, + temporaryDirectory.resolve("output").toFile(), + paths(classpath), + paths(modulepath), + new DependencyState.Configuration( + Collections.emptySet(), buildStartTime, staleMillis, mock(Log.class), false)); + } + + private static List paths(List paths) { + return paths.stream().map(Path::toString).collect(java.util.stream.Collectors.toList()); + } +}