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());
+ }
+}