diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java
new file mode 100644
index 000000000000..dac07135867d
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContext.java
@@ -0,0 +1,138 @@
+/*
+ * 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.api.build.context;
+
+import java.nio.file.Path;
+import java.util.Collection;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Provides incremental build support by tracking inputs, outputs and their relationships.
+ *
+ * A first use case is a basic build where inputs and outputs are identified but without
+ * any relationship between those. For such cases, the code would look like:
+ *
+ * context.registerInput(path1);
+ * context.registerInput(path2);
+ *
+ *
+ * A second use case is an aggregated build where multiple inputs contribute to a single
+ * output. Use {@link #newInputSet()} to create an {@link InputSet} and register inputs that are then
+ * aggregated into outputs.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface BuildContext {
+
+ /**
+ * {@return whether the build needs to process any inputs}
+ */
+ boolean isProcessingRequired();
+
+ /**
+ * Registers and processes the given output file.
+ *
+ * @param outputFile the output file path
+ * @return the processed output resource
+ */
+ @Nonnull
+ Output processOutput(@Nonnull Path outputFile);
+
+ /**
+ * Creates a new {@link InputSet} which can be used to associate inputs to outputs.
+ *
+ * @return a new input set, never {@code null}
+ */
+ @Nonnull
+ InputSet newInputSet();
+
+ /**
+ * Registers the specified input {@code Path} with this build context.
+ *
+ * @param inputFile the input file to register
+ * @return the metadata representing the input file, never {@code null}
+ * @throws IllegalArgumentException if {@code inputFile} is not a file or cannot be read
+ */
+ @Nonnull
+ Metadata registerInput(@Nonnull Path inputFile);
+
+ /**
+ * Registers inputs identified by {@code basedir} and {@code includes}/{@code excludes} ant
+ * patterns.
+ *
+ * When a file is found under {@code basedir}, it will be registered if it does not match
+ * {@code excludes} patterns and matches {@code includes} patterns. {@code null} or empty includes
+ * parameter will match all files. {@code excludes} match takes precedence over {@code includes}:
+ * if a file matches one of the excludes patterns it will not be registered regardless of includes
+ * patterns match.
+ *
+ * The implementation is not expected to handle changes to {@code basedir}, {@code includes} or
+ * {@code excludes} incrementally.
+ *
+ * @param basedir the base directory to look for inputs, must not be {@code null}
+ * @param includes patterns of the files to register, may be {@code null}
+ * @param excludes patterns of the files to ignore, may be {@code null}
+ * @return the metadata for the registered inputs
+ * @throws BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ Collection extends Metadata > registerInputs(
+ @Nonnull Path basedir, @Nullable Collection includes, @Nullable Collection excludes);
+
+ /**
+ * Registers inputs identified by {@code basedir} and {@code includes}/{@code excludes} ant
+ * patterns, then processes inputs that are new or modified since the previous build.
+ *
+ * @param basedir the base directory to look for inputs, must not be {@code null}
+ * @param includes patterns of the files to register, may be {@code null}
+ * @param excludes patterns of the files to ignore, may be {@code null}
+ * @return the processed inputs
+ * @throws BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ Collection extends Input> registerAndProcessInputs(
+ @Nonnull Path basedir, @Nullable Collection includes, @Nullable Collection excludes);
+
+ /**
+ * Marks skipped build execution. All inputs, outputs and their associated metadata are carried
+ * over to the next build as-is. No context modification operations ({@code register*} or
+ * {@code process}) are permitted after this call.
+ */
+ void markSkipExecution();
+
+ /**
+ * Sets whether the build should continue even if there are build errors.
+ *
+ * @param failOnError {@code true} to fail on error, {@code false} to continue
+ */
+ void setFailOnError(boolean failOnError);
+
+ /**
+ * {@return whether the build should fail on error}
+ */
+ boolean getFailOnError();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java
new file mode 100644
index 000000000000..e3951420da62
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/BuildContextException.java
@@ -0,0 +1,50 @@
+/*
+ * 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.api.build.context;
+
+import java.io.Serial;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.services.MavenException;
+
+/**
+ * Exception thrown when an error occurs during incremental build context operations.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+public class BuildContextException extends MavenException {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ public BuildContextException() {}
+
+ public BuildContextException(String message) {
+ super(message);
+ }
+
+ public BuildContextException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+ public BuildContextException(Throwable cause) {
+ super(cause);
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java
new file mode 100644
index 000000000000..4251f1034e08
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Incremental.java
@@ -0,0 +1,51 @@
+/*
+ * 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.api.build.context;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+import org.apache.maven.api.annotations.Experimental;
+
+import static java.lang.annotation.ElementType.FIELD;
+import static java.lang.annotation.ElementType.METHOD;
+import static java.lang.annotation.ElementType.PARAMETER;
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+/**
+ * Optional annotation that customizes how the incremental build implementation handles
+ * configuration parameters.
+ *
+ * This annotation is mandatory on {@link org.apache.maven.api.Project}
+ * and {@link org.apache.maven.api.Session} attributes to indicate whether
+ * they should be considered for change detection.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@Retention(RUNTIME)
+@Target({FIELD, METHOD, PARAMETER, TYPE})
+public @interface Incremental {
+
+ /**
+ * {@return whether to consider (the default) or ignore the annotated configuration parameter}
+ */
+ boolean consider() default true;
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java
new file mode 100644
index 000000000000..3f00ef71c0d8
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Input.java
@@ -0,0 +1,47 @@
+/*
+ * 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.api.build.context;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Represents an input resource in the incremental build context.
+ * An input can be associated with one or more {@link Output} resources.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface Input extends Resource {
+
+ /**
+ * Associates this input with the given output file and returns the output resource.
+ *
+ * @param outputFile the path of the output file to associate
+ * @return the associated output resource
+ */
+ @Nonnull
+ Output associateOutput(@Nonnull Path outputFile);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java
new file mode 100644
index 000000000000..b06a7acdef87
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/InputSet.java
@@ -0,0 +1,105 @@
+/*
+ * 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.api.build.context;
+
+import java.io.Serializable;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.function.BiConsumer;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Represents a set of inputs being aggregated into one or more outputs.
+ *
+ * An input set allows registering multiple input files and then aggregating them
+ * into an output file, with automatic change detection across builds.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface InputSet {
+
+ /**
+ * Adds a previously registered input to this set.
+ *
+ * @param inputMetadata the input metadata to add
+ */
+ void addInput(@Nonnull Metadata inputMetadata);
+
+ /**
+ * Registers an input file and adds it to this set.
+ *
+ * @param inputFile the input file to register
+ * @return the metadata for the registered input
+ */
+ @Nonnull
+ Metadata registerInput(@Nonnull Path inputFile);
+
+ /**
+ * Registers input files matching the given patterns and adds them to this set.
+ *
+ * @param basedir the base directory to scan for inputs
+ * @param includes patterns of files to include, may be {@code null} to match all
+ * @param excludes patterns of files to exclude, may be {@code null}
+ * @return the metadata for all registered inputs
+ */
+ @Nonnull
+ Collection extends Metadata > registerInputs(
+ @Nonnull Path basedir, @Nullable Collection includes, @Nullable Collection excludes);
+
+ /**
+ * Aggregates all registered inputs into the given output file.
+ *
+ * @param outputFile the output file to write
+ * @param aggregator a consumer that receives the output and the collection of inputs
+ * @return {@code true} if the output was written, {@code false} if it was up-to-date
+ */
+ boolean aggregate(@Nonnull Path outputFile, @Nonnull BiConsumer> aggregator);
+
+ /**
+ * Performs an indirect metadata aggregation. The metadata for each input file is cached
+ * across builds, avoiding recomputation when an input has not changed.
+ *
+ * @param outputFile the output file to write
+ * @param stepId a unique identifier for this aggregation step
+ * @param identity the identity value for the accumulator
+ * @param mapper extracts metadata from each input
+ * @param accumulator combines metadata values
+ * @param writer writes the accumulated result to the output
+ * @param the metadata type, must be {@link Serializable}
+ * @return {@code true} if the output was rewritten, {@code false} if it was up-to-date
+ * @throws BuildContextException if an error occurs
+ */
+ boolean aggregate(
+ @Nonnull Path outputFile,
+ @Nonnull String stepId,
+ @Nonnull T identity,
+ @Nonnull Function mapper,
+ @Nonnull BinaryOperator accumulator,
+ @Nonnull BiConsumer writer);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java
new file mode 100644
index 000000000000..e7b4900e440b
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Metadata.java
@@ -0,0 +1,59 @@
+/*
+ * 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.api.build.context;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Wraps a registered resource with its file path and change status,
+ * and provides a {@link #process()} method to obtain the full resource handle.
+ *
+ * @param the resource type ({@link Input} or {@link Output})
+ * @since 4.0.0
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface Metadata {
+
+ /**
+ * {@return the path of the registered resource}
+ */
+ @Nonnull
+ Path getPath();
+
+ /**
+ * {@return the change status of the resource relative to the previous build}
+ */
+ @Nonnull
+ Status getStatus();
+
+ /**
+ * Marks this resource for processing and returns the full resource handle.
+ *
+ * @return the resource to process
+ */
+ @Nonnull
+ R process();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java
new file mode 100644
index 000000000000..70d9a6e4a5e5
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Output.java
@@ -0,0 +1,65 @@
+/*
+ * 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.api.build.context;
+
+import java.io.BufferedWriter;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.charset.Charset;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Represents an output resource in the incremental build context.
+ *
+ * The output stream returned by {@link #newOutputStream()} may be a caching stream that only
+ * overwrites the target file when the content has actually changed, helping IDEs and downstream
+ * tools avoid unnecessary rebuilds.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface Output extends Resource {
+
+ /**
+ * Returns a new caching output stream for this output resource.
+ *
+ * @return a new output stream, never {@code null}
+ * @throws BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ OutputStream newOutputStream();
+
+ /**
+ * Returns a new buffered writer for this output resource using the given charset.
+ *
+ * @param charset the charset to use for encoding
+ * @return a new buffered writer, never {@code null}
+ * @throws BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ default BufferedWriter newBufferedWriter(@Nonnull Charset charset) {
+ return new BufferedWriter(new OutputStreamWriter(newOutputStream(), charset));
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java
new file mode 100644
index 000000000000..6002f98562a0
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Resource.java
@@ -0,0 +1,62 @@
+/*
+ * 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.api.build.context;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.Provider;
+
+/**
+ * Represents a build resource (input or output file) tracked by the incremental build context.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface Resource {
+
+ /**
+ * {@return the path of this resource}
+ */
+ @Nonnull
+ Path getPath();
+
+ /**
+ * {@return the change status of this resource relative to the previous build}
+ */
+ @Nonnull
+ Status getStatus();
+
+ /**
+ * Attaches a diagnostic message to this resource at the given source location.
+ *
+ * @param line the 1-based line number, or {@code 0} if unknown
+ * @param column the 1-based column number, or {@code 0} if unknown
+ * @param message the human-readable message text
+ * @param severity the severity level
+ * @param cause the underlying cause, or {@code null}
+ */
+ void addMessage(
+ int line, int column, @Nonnull String message, @Nonnull Severity severity, @Nullable Throwable cause);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java
new file mode 100644
index 000000000000..dd0236afc7ee
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Severity.java
@@ -0,0 +1,38 @@
+/*
+ * 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.api.build.context;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+
+/**
+ * Severity level for build messages attached to resources.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@Immutable
+public enum Severity {
+ /** An error that should fail the build (unless fail-on-error is disabled). */
+ ERROR,
+ /** A warning that does not fail the build. */
+ WARNING,
+ /** An informational message. */
+ INFO
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java
new file mode 100644
index 000000000000..a83592de3033
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/Status.java
@@ -0,0 +1,52 @@
+/*
+ * 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.api.build.context;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+
+/**
+ * Indicates the change status of a resource between the current and previous build.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@Immutable
+public enum Status {
+
+ /**
+ * Resource is new in this build, i.e. it was not present in the previous build.
+ */
+ NEW,
+
+ /**
+ * Resource changed since the previous build.
+ */
+ MODIFIED,
+
+ /**
+ * Resource did not change since the previous build.
+ */
+ UNMODIFIED,
+
+ /**
+ * Resource was removed since the previous build.
+ */
+ REMOVED
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java
new file mode 100644
index 000000000000..e7ae06411905
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextEnvironment.java
@@ -0,0 +1,65 @@
+/*
+ * 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.api.build.context.spi;
+
+import java.io.Serializable;
+import java.nio.file.Path;
+import java.util.Map;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.annotations.ThreadSafe;
+
+/**
+ * Provides the environment configuration needed to initialize a build context,
+ * including state file location, workspace, parameters, and an optional finalizer.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@ThreadSafe
+@Consumer
+public interface BuildContextEnvironment {
+
+ /**
+ * {@return the path to the file where build context state is persisted}
+ */
+ @Nonnull
+ Path getStateFile();
+
+ /**
+ * {@return the workspace that provides file system abstraction}
+ */
+ @Nonnull
+ Workspace getWorkspace();
+
+ /**
+ * {@return the configuration parameters for the build context}
+ */
+ @Nonnull
+ Map getParameters();
+
+ /**
+ * {@return the optional context finalizer, or {@code null} if none is configured}
+ */
+ @Nullable
+ BuildContextFinalizer getFinalizer();
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java
new file mode 100644
index 000000000000..8c96811e59b5
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/BuildContextFinalizer.java
@@ -0,0 +1,43 @@
+/*
+ * 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.api.build.context.spi;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.ThreadSafe;
+
+/**
+ * Callback interface for registering build contexts that should be committed
+ * at the end of a mojo execution.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@ThreadSafe
+@Consumer
+public interface BuildContextFinalizer {
+
+ /**
+ * Registers a build context to be committed when the mojo execution completes.
+ *
+ * @param context the build context to register
+ */
+ void registerContext(@Nonnull CommitableBuildContext context);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java
new file mode 100644
index 000000000000..2869db964b9d
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/CommitableBuildContext.java
@@ -0,0 +1,44 @@
+/*
+ * 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.api.build.context.spi;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+import org.apache.maven.api.annotations.Provider;
+import org.apache.maven.api.build.context.BuildContext;
+
+/**
+ * Extended {@link BuildContext} that supports committing state changes and
+ * reporting messages through a {@link Sink}.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@NotThreadSafe
+@Provider
+public interface CommitableBuildContext extends BuildContext {
+
+ /**
+ * Commits all changes in this build context and reports messages to the given sink.
+ *
+ * @param sink the sink that receives build messages
+ */
+ void commit(@Nonnull Sink sink);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java
new file mode 100644
index 000000000000..07bfd2e09499
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/FileState.java
@@ -0,0 +1,120 @@
+/*
+ * 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.api.build.context.spi;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Status;
+
+/**
+ * Immutable snapshot of a file's state (path, last-modified time, size) and its
+ * change {@link Status} relative to the previous build.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@Immutable
+public final class FileState {
+
+ private final Path path;
+ private final FileTime lastModified;
+ private final long size;
+ private final Status status;
+
+ /**
+ * Creates a file state with explicit attributes.
+ *
+ * @param path the file path
+ * @param lastModified the last-modified time, or {@code null} for removed files
+ * @param size the file size in bytes
+ * @param status the change status
+ */
+ public FileState(@Nonnull Path path, @Nullable FileTime lastModified, long size, @Nonnull Status status) {
+ this.path = path;
+ this.lastModified = lastModified;
+ this.size = size;
+ this.status = status;
+ }
+
+ /**
+ * Creates a file state by reading attributes from the file system.
+ * For {@link Status#REMOVED} files, the last-modified time is set to {@code null}
+ * and the size to {@code 0}.
+ *
+ * @param path the file path
+ * @param status the change status
+ * @throws BuildContextException if the file attributes cannot be read
+ */
+ public FileState(@Nonnull Path path, @Nonnull Status status) {
+ this.path = path;
+ this.status = status;
+ if (status == Status.REMOVED) {
+ lastModified = null;
+ size = 0;
+ } else {
+ try {
+ BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
+ this.lastModified = attrs.lastModifiedTime();
+ this.size = attrs.size();
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+ }
+
+ /**
+ * {@return the file path}
+ */
+ @Nonnull
+ public Path getPath() {
+ return path;
+ }
+
+ /**
+ * {@return the last-modified time, or {@code null} for removed files}
+ */
+ @Nullable
+ public FileTime getLastModified() {
+ return lastModified;
+ }
+
+ /**
+ * {@return the file size in bytes}
+ */
+ public long getSize() {
+ return size;
+ }
+
+ /**
+ * {@return the change status relative to the previous build}
+ */
+ @Nonnull
+ public Status getStatus() {
+ return status;
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java
new file mode 100644
index 000000000000..6d2116b0c27b
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Message.java
@@ -0,0 +1,126 @@
+/*
+ * 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.api.build.context.spi;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.Objects;
+
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Immutable;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.build.context.Severity;
+
+/**
+ * An immutable diagnostic message attached to a build resource.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@Immutable
+public class Message implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 7798138299696868415L;
+
+ private final int line;
+ private final int column;
+ private final String message;
+ private final Severity severity;
+ private final Throwable cause;
+ private final int hashCode;
+
+ /**
+ * Creates a new message.
+ *
+ * @param line the 1-based line number, or {@code 0} if unknown
+ * @param column the 1-based column number, or {@code 0} if unknown
+ * @param message the human-readable message text
+ * @param severity the severity level
+ * @param cause the underlying cause, or {@code null}
+ */
+ public Message(
+ int line, int column, @Nonnull String message, @Nonnull Severity severity, @Nullable Throwable cause) {
+ this.line = line;
+ this.column = column;
+ this.message = message;
+ this.severity = severity;
+ this.cause = cause;
+ this.hashCode = Objects.hash(line, column, message, severity, cause);
+ }
+
+ /**
+ * {@return the 1-based line number, or {@code 0} if unknown}
+ */
+ public int getLine() {
+ return line;
+ }
+
+ /**
+ * {@return the 1-based column number, or {@code 0} if unknown}
+ */
+ public int getColumn() {
+ return column;
+ }
+
+ /**
+ * {@return the human-readable message text}
+ */
+ @Nonnull
+ public String getMessage() {
+ return message;
+ }
+
+ /**
+ * {@return the severity level}
+ */
+ @Nonnull
+ public Severity getSeverity() {
+ return severity;
+ }
+
+ /**
+ * {@return the underlying cause, or {@code null}}
+ */
+ @Nullable
+ public Throwable getCause() {
+ return cause;
+ }
+
+ @Override
+ public int hashCode() {
+ return hashCode;
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof Message other)) {
+ return false;
+ }
+ return line == other.line
+ && column == other.column
+ && Objects.equals(message, other.message)
+ && Objects.equals(severity, other.severity)
+ && Objects.equals(cause, other.cause);
+ }
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java
new file mode 100644
index 000000000000..756235ecd079
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Sink.java
@@ -0,0 +1,55 @@
+/*
+ * 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.api.build.context.spi;
+
+import java.nio.file.Path;
+import java.util.Collection;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.NotThreadSafe;
+
+/**
+ * Receives diagnostic messages produced during a build context commit.
+ * Implementations typically forward these to the IDE or build log.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@NotThreadSafe
+@Consumer
+public interface Sink {
+
+ /**
+ * Reports messages for a resource.
+ *
+ * @param resource the resource path the messages belong to
+ * @param isNew {@code true} if the resource is new in this build
+ * @param messages the diagnostic messages to report
+ */
+ void messages(@Nonnull Path resource, boolean isNew, @Nonnull Collection messages);
+
+ /**
+ * Clears all previously reported messages for a resource.
+ *
+ * @param resource the resource path to clear messages for
+ */
+ void clear(@Nonnull Path resource);
+}
diff --git a/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.java
new file mode 100644
index 000000000000..343861f29a21
--- /dev/null
+++ b/api/maven-api-core/src/main/java/org/apache/maven/api/build/context/spi/Workspace.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.api.build.context.spi;
+
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.annotations.Consumer;
+import org.apache.maven.api.annotations.Experimental;
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.annotations.ThreadSafe;
+import org.apache.maven.api.build.context.Status;
+
+/**
+ * Provides a layer of indirection between the {@link org.apache.maven.api.build.context.BuildContext}
+ * and the underlying file store.
+ *
+ * IDE integrations typically supply a workspace implementation that is aware of the IDE's
+ * virtual file system, while command-line builds use a direct filesystem workspace.
+ *
+ * @since 4.0.0
+ */
+@Experimental
+@ThreadSafe
+@Consumer
+public interface Workspace {
+
+ /**
+ * {@return the current workspace mode}
+ */
+ @Nonnull
+ Mode getMode();
+
+ /**
+ * Returns an escalated view of this workspace, where all files are treated as new.
+ *
+ * @return the escalated workspace
+ */
+ @Nonnull
+ Workspace escalate();
+
+ /**
+ * {@return {@code true} if the file exists in this workspace}
+ *
+ * @param file the file path to check
+ */
+ boolean isPresent(@Nonnull Path file);
+
+ /**
+ * {@return {@code true} if the path is a regular file in this workspace}
+ *
+ * @param file the file path to check
+ */
+ boolean isRegularFile(@Nonnull Path file);
+
+ /**
+ * {@return {@code true} if the path is a directory in this workspace}
+ *
+ * @param file the file path to check
+ */
+ boolean isDirectory(@Nonnull Path file);
+
+ /**
+ * Deletes the specified file from this workspace.
+ *
+ * @param file the file to delete
+ * @throws org.apache.maven.api.build.context.BuildContextException if an I/O error occurs
+ */
+ void deleteFile(@Nonnull Path file);
+
+ /**
+ * Notifies the workspace that the given output path has been processed.
+ *
+ * @param path the output path
+ */
+ void processOutput(@Nonnull Path path);
+
+ /**
+ * Returns an output stream for the specified file. The workspace may optimize this
+ * using a caching stream that only overwrites the file when the content changes.
+ *
+ * @param path the file to write to
+ * @return a new output stream
+ * @throws org.apache.maven.api.build.context.BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ OutputStream newOutputStream(@Nonnull Path path);
+
+ /**
+ * Determines the resource status based on its last-modified time and size.
+ *
+ * @param file the file to check
+ * @param lastModified the previously recorded last-modified time
+ * @param size the previously recorded file size
+ * @return the change status
+ */
+ @Nonnull
+ Status getResourceStatus(@Nonnull Path file, @Nonnull FileTime lastModified, long size);
+
+ /**
+ * Walks a file tree rooted at the given directory. The files visited and their status
+ * depend on the workspace {@link Mode}:
+ *
+ * {@code NORMAL} — all files are visited with status {@link Status#NEW}.
+ * The build context calculates the actual input status.
+ * {@code DELTA} — only {@link Status#NEW}, {@link Status#MODIFIED}
+ * or {@link Status#REMOVED} files are visited.
+ * {@code ESCALATED} — all files are visited with status {@link Status#NEW}.
+ * Used when the user explicitly requests a full rebuild in an IDE.
+ * {@code SUPPRESSED} — used during "configuration" builds where all inputs
+ * are assumed up-to-date and no outputs are expected.
+ *
+ *
+ * @param basedir the root directory to walk
+ * @return a stream of file states
+ * @throws org.apache.maven.api.build.context.BuildContextException if an I/O error occurs
+ */
+ @Nonnull
+ Stream walk(@Nonnull Path basedir);
+
+ /**
+ * The workspace operating mode.
+ *
+ * @since 4.0.0
+ */
+ enum Mode {
+ /** Normal mode — the build context determines resource status. */
+ NORMAL,
+ /** Delta mode — only changed files are visited. */
+ DELTA,
+ /** Escalated mode — all files treated as new (full rebuild). */
+ ESCALATED,
+ /** Suppressed mode — configuration-only build, no outputs expected. */
+ SUPPRESSED
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java
new file mode 100644
index 000000000000..79a4e40db109
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContext.java
@@ -0,0 +1,113 @@
+/*
+ * 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.internal.build.context.impl.maven;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+import javax.inject.Provider;
+
+import java.nio.file.Path;
+import java.util.Collection;
+
+import org.apache.maven.api.build.context.InputSet;
+import org.apache.maven.api.build.context.spi.BuildContextEnvironment;
+import org.apache.maven.api.build.context.spi.CommitableBuildContext;
+import org.apache.maven.api.build.context.spi.Sink;
+import org.apache.maven.execution.scope.MojoExecutionScoped;
+import org.apache.maven.internal.build.context.impl.DefaultBuildContext;
+import org.apache.maven.internal.build.context.impl.DefaultInput;
+import org.apache.maven.internal.build.context.impl.DefaultInputMetadata;
+import org.apache.maven.internal.build.context.impl.DefaultOutput;
+import org.eclipse.sisu.Typed;
+
+@Named
+public class MavenBuildContext implements CommitableBuildContext {
+
+ private final Provider provider;
+
+ @Inject
+ public MavenBuildContext(Provider delegate) {
+ this.provider = delegate;
+ }
+
+ MojoExecutionScopedBuildContext getDelegate() {
+ return provider.get();
+ }
+
+ public boolean getFailOnError() {
+ return getDelegate().getFailOnError();
+ }
+
+ @Override
+ public boolean isProcessingRequired() {
+ return getDelegate().isProcessingRequired();
+ }
+
+ @Override
+ public DefaultOutput processOutput(Path outputFile) {
+ return getDelegate().processOutput(outputFile);
+ }
+
+ @Override
+ public InputSet newInputSet() {
+ return getDelegate().newInputSet();
+ }
+
+ @Override
+ public DefaultInputMetadata registerInput(Path inputFile) {
+ return getDelegate().registerInput(inputFile);
+ }
+
+ @Override
+ public Collection extends DefaultInputMetadata> registerInputs(
+ Path basedir, Collection includes, Collection excludes) {
+ return getDelegate().registerInputs(basedir, includes, excludes);
+ }
+
+ @Override
+ public Collection extends DefaultInput> registerAndProcessInputs(
+ Path basedir, Collection includes, Collection excludes) {
+ return getDelegate().registerAndProcessInputs(basedir, includes, excludes);
+ }
+
+ @Override
+ public void markSkipExecution() {
+ getDelegate().markSkipExecution();
+ }
+
+ @Override
+ public void setFailOnError(boolean failOnError) {
+ getDelegate().setFailOnError(failOnError);
+ }
+
+ @Override
+ public void commit(Sink sink) {
+ getDelegate().commit(sink);
+ }
+
+ @Named
+ @Typed(MojoExecutionScopedBuildContext.class)
+ @MojoExecutionScoped
+ public static class MojoExecutionScopedBuildContext extends DefaultBuildContext {
+ @Inject
+ public MojoExecutionScopedBuildContext(BuildContextEnvironment configuration) {
+ super(configuration);
+ }
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java
new file mode 100644
index 000000000000..c7c34da347d8
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextConfiguration.java
@@ -0,0 +1,112 @@
+/*
+ * 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.internal.build.context.impl.maven;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.Map;
+
+import org.apache.maven.api.MojoExecution;
+import org.apache.maven.api.Project;
+import org.apache.maven.api.build.context.spi.BuildContextEnvironment;
+import org.apache.maven.api.build.context.spi.BuildContextFinalizer;
+import org.apache.maven.api.build.context.spi.Workspace;
+import org.apache.maven.api.plugin.descriptor.PluginDescriptor;
+import org.apache.maven.execution.scope.MojoExecutionScoped;
+import org.apache.maven.internal.build.context.impl.maven.digest.MojoConfigurationDigester;
+
+@Named
+@MojoExecutionScoped
+public class MavenBuildContextConfiguration implements BuildContextEnvironment {
+
+ private final ProjectWorkspace workspace;
+ private final Path stateFile;
+ private final Map parameters;
+ private final MavenBuildContextFinalizer finalizer;
+
+ @Inject
+ public MavenBuildContextConfiguration(
+ ProjectWorkspace workspace,
+ MojoConfigurationDigester digester,
+ MavenBuildContextFinalizer finalizer,
+ Project project,
+ MojoExecution execution)
+ throws IOException {
+ this.workspace = workspace;
+ this.finalizer = finalizer;
+ this.stateFile = getExecutionStateLocation(project, execution);
+ this.parameters = digester.digest();
+ }
+
+ @Override
+ public Path getStateFile() {
+ return stateFile;
+ }
+
+ @Override
+ public Workspace getWorkspace() {
+ return workspace;
+ }
+
+ @Override
+ public Map getParameters() {
+ return parameters;
+ }
+
+ @Override
+ public BuildContextFinalizer getFinalizer() {
+ return finalizer;
+ }
+
+ /**
+ * Returns conventional location of MojoExecution incremental build state
+ */
+ public Path getExecutionStateLocation(Project project, MojoExecution execution) {
+ Path stateDirectory = getProjectStateLocation(project);
+ String builderId = getExecutionId(execution);
+ return stateDirectory.resolve(builderId);
+ }
+
+ /**
+ * Returns conventional MojoExecution identifier used by incremental build tools.
+ */
+ public String getExecutionId(MojoExecution execution) {
+ PluginDescriptor pluginDescriptor = execution.getPlugin().getDescriptor();
+ String builderId = pluginDescriptor.getGroupId()
+ + '_'
+ + pluginDescriptor.getArtifactId()
+ + '_'
+ + execution.getGoal()
+ + '_'
+ + execution.getExecutionId();
+ return builderId;
+ }
+
+ /**
+ * Returns conventional location of MavenProject incremental build state
+ */
+ public Path getProjectStateLocation(Project project) {
+ return Paths.get(project.getBuild().getDirectory(), "incremental");
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java
new file mode 100644
index 000000000000..3e11fed985ae
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/MavenBuildContextFinalizer.java
@@ -0,0 +1,136 @@
+/*
+ * 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.internal.build.context.impl.maven;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.apache.maven.api.build.context.BuildContext;
+import org.apache.maven.api.build.context.Severity;
+import org.apache.maven.api.build.context.spi.BuildContextFinalizer;
+import org.apache.maven.api.build.context.spi.CommitableBuildContext;
+import org.apache.maven.api.build.context.spi.Message;
+import org.apache.maven.api.build.context.spi.Sink;
+import org.apache.maven.execution.MojoExecutionEvent;
+import org.apache.maven.execution.scope.MojoExecutionScoped;
+import org.apache.maven.execution.scope.WeakMojoExecutionListener;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.eclipse.sisu.Nullable;
+
+@Named
+@MojoExecutionScoped
+public class MavenBuildContextFinalizer implements WeakMojoExecutionListener, BuildContextFinalizer {
+
+ private final List contexts = new ArrayList<>();
+
+ private final Sink sink;
+
+ @Inject
+ public MavenBuildContextFinalizer(@Nullable Sink sink) {
+ this.sink = sink;
+ }
+
+ public void registerContext(CommitableBuildContext context) {
+ contexts.add(context);
+ }
+
+ protected List extends BuildContext> getRegisteredContexts() {
+ return contexts;
+ }
+
+ @Override
+ public void afterMojoExecutionSuccess(MojoExecutionEvent event) throws MojoExecutionException {
+ try {
+ final Map> allMessages = new HashMap<>();
+ for (CommitableBuildContext context : contexts) {
+ context.commit(new Sink() {
+ @Override
+ public void clear(Path resource) {
+ if (sink != null) {
+ sink.clear(resource);
+ }
+ }
+
+ @Override
+ public void messages(Path resource, boolean isNew, Collection messages) {
+ if (sink != null) {
+ sink.messages(resource, isNew, messages);
+ }
+ allMessages.put(resource, messages);
+ }
+ });
+ }
+
+ if (sink == null) {
+ failBuild(allMessages);
+ }
+ } catch (Exception e) {
+ throw new MojoExecutionException("Could not maintain incremental build state", e);
+ }
+ }
+
+ protected void failBuild(final Map> messages) throws MojoExecutionException {
+ // without messageSink, have to raise exception if there were errors
+ int errorCount = 0;
+ StringBuilder errors = new StringBuilder();
+ for (Map.Entry> entry : messages.entrySet()) {
+ Object resource = entry.getKey();
+ for (Message message : entry.getValue()) {
+ if (message.getSeverity() == Severity.ERROR) {
+ errorCount++;
+ errors.append(String.format(
+ "%s:[%d:%d] %s\n",
+ resource.toString(), message.getLine(), message.getColumn(), message.getMessage()));
+ }
+ }
+ }
+ final Set failOnErrors = extractFailOnErrors(contexts);
+ if (failOnErrors.size() != 1) {
+ throw new IllegalStateException("Contexts FailOnError property have different values.");
+ }
+
+ final Boolean failOnError = failOnErrors.iterator().next();
+ if (errorCount > 0 && failOnError) {
+ throw new MojoExecutionException(errorCount + " error(s) encountered:\n" + errors);
+ }
+ }
+
+ private Set extractFailOnErrors(List contexts) {
+ final Set result = new HashSet<>();
+ for (BuildContext context : contexts) {
+ result.add(context.getFailOnError());
+ }
+ return result;
+ }
+
+ @Override
+ public void beforeMojoExecution(MojoExecutionEvent event) throws MojoExecutionException {}
+
+ @Override
+ public void afterExecutionFailure(MojoExecutionEvent event) {}
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java
new file mode 100644
index 000000000000..b6943f52b593
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/ProjectWorkspace.java
@@ -0,0 +1,118 @@
+/*
+ * 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.internal.build.context.impl.maven;
+
+import javax.inject.Inject;
+
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.Project;
+import org.apache.maven.api.build.context.Status;
+import org.apache.maven.api.build.context.spi.FileState;
+import org.apache.maven.api.build.context.spi.Workspace;
+import org.apache.maven.execution.scope.MojoExecutionScoped;
+import org.apache.maven.internal.build.context.impl.FilesystemWorkspace;
+import org.eclipse.sisu.Typed;
+
+/**
+ * Eclipse Workspace implementation is scoped to a project and does not "see" resources outside
+ * project basedir. This implementation dispatches Workspace calls to either Eclipse implementation
+ * or Filesystem workspace implementation, depending on whether requested resource is inside or
+ * outside of project basedir.
+ */
+@Typed(ProjectWorkspace.class)
+@MojoExecutionScoped
+public class ProjectWorkspace implements Workspace {
+
+ private final Workspace workspace;
+
+ private final Project project;
+
+ private final Path basedir;
+
+ private final FilesystemWorkspace filesystem;
+
+ @Inject
+ public ProjectWorkspace(Project project, Workspace workspace, FilesystemWorkspace filesystem) {
+ this.project = project;
+ this.basedir = project.getBasedir().normalize();
+ this.workspace = workspace;
+ this.filesystem = filesystem;
+ }
+
+ protected Workspace getWorkspace(Path file) {
+ if (file.normalize().startsWith(basedir)) {
+ return workspace;
+ }
+ return filesystem;
+ }
+
+ @Override
+ public Mode getMode() {
+ return Mode.NORMAL;
+ }
+
+ @Override
+ public Workspace escalate() {
+ return new ProjectWorkspace(project, workspace.escalate(), filesystem);
+ }
+
+ @Override
+ public boolean isPresent(Path file) {
+ return getWorkspace(file).isPresent(file);
+ }
+
+ @Override
+ public boolean isRegularFile(Path file) {
+ return getWorkspace(file).isRegularFile(file);
+ }
+
+ @Override
+ public boolean isDirectory(Path file) {
+ return getWorkspace(file).isDirectory(file);
+ }
+
+ @Override
+ public void deleteFile(Path file) {
+ getWorkspace(file).deleteFile(file);
+ }
+
+ @Override
+ public void processOutput(Path path) {
+ getWorkspace(path).processOutput(path);
+ }
+
+ @Override
+ public OutputStream newOutputStream(Path path) {
+ return getWorkspace(path).newOutputStream(path);
+ }
+
+ @Override
+ public Status getResourceStatus(Path file, FileTime lastModified, long size) {
+ return getWorkspace(file).getResourceStatus(file, lastModified, size);
+ }
+
+ @Override
+ public Stream walk(Path basedir) {
+ return getWorkspace(basedir).walk(basedir);
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/BytesHash.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/BytesHash.java
new file mode 100644
index 000000000000..e2b6ae74df5c
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/BytesHash.java
@@ -0,0 +1,52 @@
+/*
+ * 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.internal.build.context.impl.maven.digest;
+
+import java.io.Serializable;
+import java.util.Arrays;
+
+@SuppressWarnings("serial")
+public class BytesHash implements Serializable {
+
+ // no serialVersionUID, want deserialization to fail if state format changes
+
+ private final byte[] bytes;
+
+ public BytesHash(byte[] bytes) {
+ this.bytes = bytes;
+ }
+
+ // TODO toString
+
+ @Override
+ public boolean equals(Object obj) {
+ if (obj == this) {
+ return true;
+ }
+ if (!(obj instanceof BytesHash)) {
+ return false;
+ }
+ return Arrays.equals(bytes, ((BytesHash) obj).bytes);
+ }
+
+ @Override
+ public int hashCode() {
+ return Arrays.hashCode(bytes);
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java
new file mode 100644
index 000000000000..1e17adad2d10
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/ClasspathDigester.java
@@ -0,0 +1,176 @@
+/*
+ * 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.internal.build.context.impl.maven.digest;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.security.MessageDigest;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+import java.util.zip.ZipFile;
+
+import org.apache.maven.api.Artifact;
+import org.apache.maven.api.Session;
+import org.apache.maven.api.SessionData;
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.services.ArtifactManager;
+
+/**
+ * Specialized digester for Maven plugin classpath dependencies. Uses class file contents and immune
+ * to file timestamp changes caused by rebuilds of the same sources.
+ */
+class ClasspathDigester {
+
+ @SuppressWarnings("unchecked")
+ private static final SessionData.Key> CACHE_KEY =
+ (SessionData.Key>)
+ (SessionData.Key>) SessionData.key(ConcurrentMap.class, ClasspathDigester.class.getName());
+
+ private final ConcurrentMap cache;
+ private final ArtifactManager artifactManager;
+
+ ClasspathDigester(Session session) {
+ this.cache = getCache(session);
+ this.artifactManager = session.getService(ArtifactManager.class);
+ }
+
+ private static ConcurrentMap getCache(Session session) {
+ // this assumes that session data does not change during reactor build
+ SessionData sessionData = session.getData();
+ return sessionData.computeIfAbsent(CACHE_KEY, ConcurrentHashMap::new);
+ }
+
+ static void digest(MessageDigest digester, InputStream is) {
+ try {
+ byte[] buf = new byte[4 * 1024];
+ int r;
+ while ((r = is.read(buf)) > 0) {
+ digester.update(buf, 0, r);
+ }
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ static void digestFile(MessageDigest digester, Path file) {
+ try (InputStream is = Files.newInputStream(file)) {
+ digest(digester, is);
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ static void digestZip(MessageDigest digester, Path file) {
+ try (ZipFile zip = new ZipFile(file.toFile())) {
+ zip.stream()
+ // sort entries.
+ // order of jar/zip entries is not important but may change from one build to the next
+ .sorted(Comparator.comparing(ZipEntry::getName))
+ .forEachOrdered(entry -> {
+ try (InputStream is = zip.getInputStream(entry)) {
+ digest(digester, is);
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ });
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ private static byte[] digestArtifactFile(Path file) {
+ byte[] hash;
+ if (Files.isRegularFile(file)) {
+ hash = digestZipOrFile(file);
+ } else if (Files.isDirectory(file)) {
+ hash = digestDirectory(file);
+ } else {
+ // does not exist, use token empty array to avoid rechecking
+ hash = new byte[0];
+ }
+ return hash;
+ }
+
+ private static byte[] digestZipOrFile(Path file) {
+ MessageDigest d = SHA1Digester.newInstance();
+ try {
+ digestZip(d, file);
+ } catch (BuildContextException e) {
+ if (e.getCause() instanceof ZipException) {
+ digestFile(d, file);
+ }
+ throw e;
+ }
+ return d.digest();
+ }
+
+ private static byte[] digestDirectory(Path file) {
+ byte[] hash;
+ MessageDigest d = SHA1Digester.newInstance();
+ try (Stream s = Files.walk(file)) {
+ s.sorted().forEach(f -> digestFile(d, f));
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ hash = d.digest();
+ return hash;
+ }
+
+ public Serializable digest(List artifacts) {
+ MessageDigest digester = SHA1Digester.newInstance();
+ for (Artifact artifact : artifacts) {
+ Path file = artifactManager.getPath(artifact).get();
+ String cacheKey = getArtifactKey(artifact);
+ byte[] cached = cache.get(cacheKey);
+ if (cached == null) {
+ byte[] hash = digestArtifactFile(file);
+ cached = cache.putIfAbsent(cacheKey, hash);
+ if (cached == null) {
+ cached = hash;
+ }
+ }
+ digester.update(cached);
+ }
+ return new BytesHash(digester.digest());
+ }
+
+ private String getArtifactKey(Artifact artifact) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(artifact.getGroupId());
+ sb.append(':');
+ sb.append(artifact.getArtifactId());
+ sb.append(':');
+ sb.append(artifact.getExtension());
+ sb.append(':');
+ sb.append(artifact.getVersion());
+ if (artifact.getClassifier() != null) {
+ sb.append(':');
+ sb.append(artifact.getClassifier());
+ }
+ return sb.toString();
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java
new file mode 100644
index 000000000000..9520d71f6f47
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/Digesters.java
@@ -0,0 +1,197 @@
+/*
+ * 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.internal.build.context.impl.maven.digest;
+
+import javax.xml.stream.XMLStreamException;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.lang.reflect.AnnotatedElement;
+import java.lang.reflect.Member;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Properties;
+import java.util.SortedMap;
+import java.util.TreeMap;
+
+import org.apache.maven.api.Project;
+import org.apache.maven.api.RemoteRepository;
+import org.apache.maven.api.Session;
+import org.apache.maven.api.build.context.Incremental;
+import org.apache.maven.model.v4.MavenStaxWriter;
+
+class Digesters {
+
+ private static final Map, Digester>> DIGESTERS;
+
+ static {
+ Map, Digester>> digesters = new LinkedHashMap<>();
+ // common Maven objects
+ digesters.put(RemoteRepository.class, (Digester) Digesters::digestRemoteRepository);
+ // digesters.put(Artifact.class, (Digester) Digesters::digestArtifact);
+ digesters.put(Project.class, (Digester) Digesters::digestProject);
+ digesters.put(Session.class, (Digester) Digesters::digestSession);
+ //
+ digesters.put(Collection.class, (Digester>) Digesters::digestCollection);
+ //
+ digesters.put(Serializable.class, (Digester) (member, value) -> value);
+ DIGESTERS = Collections.unmodifiableMap(digesters);
+ }
+
+ public static Serializable digest(Member member, Object value) {
+ // TODO: check on mojo as a default
+ Incremental configuration = getConfiguration(member);
+ if (configuration != null && !configuration.consider()) {
+ return null; // no digest, ignore
+ }
+
+ return rawtypesDigest(member, value);
+ }
+
+ static Incremental getConfiguration(Member member) {
+ if (member instanceof AnnotatedElement) {
+ return ((AnnotatedElement) member).getAnnotation(Incremental.class);
+ }
+ return null;
+ }
+
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static Serializable rawtypesDigest(Member member, Object value) {
+ return ((Digester) getDigester(value)).digest(member, value);
+ }
+
+ private static Digester> getDigester(Object value) {
+ Digester> digester = null;
+ for (Map.Entry, Digester>> entry : DIGESTERS.entrySet()) {
+ if (entry.getKey().isInstance(value)) {
+ digester = entry.getValue();
+ break;
+ }
+ }
+ if (digester == null) {
+ throw new UnsupportedParameterTypeException(value.getClass());
+ }
+ return digester;
+ }
+
+ private static Serializable digestProject(Member member, Project value) {
+ if (getConfiguration(member) == null) {
+ throw new IllegalArgumentException("Explicit @Incremental required: " + member);
+ }
+
+ final MessageDigest digester = SHA1Digester.newInstance();
+
+ // effective pom.xml defines project configuration, rebuild whenever project configuration
+ // changes we can't be more specific here because mojo can access entire project model, not
+ // just its own configuration
+ try {
+ new MavenStaxWriter()
+ .write(
+ new OutputStream() {
+ @Override
+ public void write(int b) throws IOException {
+ digester.update((byte) b);
+ }
+ },
+ value.getModel());
+ } catch (IOException | XMLStreamException e) {
+ // can't happen
+ }
+
+ return new BytesHash(digester.digest());
+ }
+
+ private static Serializable digestSession(Member member, Session session) {
+ if (getConfiguration(member) == null) {
+ throw new IllegalArgumentException("Explicit @Incremental required: " + member);
+ }
+
+ // execution properties define build parameters passed in from command line and jvm used
+ SortedMap executionProperties = new TreeMap<>();
+ Properties props = new Properties();
+ props.putAll(session.getSystemProperties());
+ props.putAll(session.getUserProperties());
+ for (Map.Entry property : props.entrySet()) {
+ // TODO unit test non-string keys do not cause problems at runtime
+ // TODO test if non-string values can or cannot be used
+ Object key = property.getKey();
+ Object value = property.getValue();
+ if (key instanceof String && value instanceof String) {
+ executionProperties.put(key.toString(), value.toString());
+ }
+ }
+
+ // m2e workspace launch
+ executionProperties.remove("classworlds.conf");
+
+ // Environment has PID of java process (env.JAVA_MAIN_CLASS_), SSH_AGENT_PID,
+ // unique TMPDIR (on OSX) and other volatile variables.
+ executionProperties.entrySet().removeIf(property -> property.getKey().startsWith("env."));
+
+ MessageDigest digester = SHA1Digester.newInstance();
+
+ for (Map.Entry property : executionProperties.entrySet()) {
+ digester.update(property.getKey().getBytes(StandardCharsets.UTF_8));
+ digester.update(property.getValue().getBytes(StandardCharsets.UTF_8));
+ }
+
+ return new BytesHash(digester.digest());
+ }
+
+ // private static Serializable digestArtifact(Member member, Artifact value) {
+ // return value.getPath().get().toFile();
+ // }
+
+ private static Serializable digestRemoteRepository(Member member, RemoteRepository value) {
+ return value.getUrl();
+ }
+
+ private static Serializable digestCollection(Member member, Collection> collection) {
+ // TODO consider collapsing to single SHA1 hash
+ ArrayList digest = new ArrayList<>();
+ for (Object element : collection) {
+ Serializable elementDigest = rawtypesDigest(member, element);
+ if (elementDigest != null) {
+ digest.add(elementDigest);
+ }
+ }
+ return digest;
+ }
+
+ interface Digester {
+ Serializable digest(Member member, T value);
+ }
+
+ public static class UnsupportedParameterTypeException extends IllegalArgumentException {
+
+ private static final long serialVersionUID = 1L;
+
+ final Class> type;
+
+ UnsupportedParameterTypeException(Class> type) {
+ this.type = type;
+ }
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java
new file mode 100644
index 000000000000..7e31524cac85
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/MojoConfigurationDigester.java
@@ -0,0 +1,176 @@
+/*
+ * 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.internal.build.context.impl.maven.digest;
+
+import javax.inject.Inject;
+import javax.inject.Named;
+import javax.xml.stream.XMLStreamException;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.io.StringWriter;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.maven.api.Artifact;
+import org.apache.maven.api.MojoExecution;
+import org.apache.maven.api.Project;
+import org.apache.maven.api.Session;
+import org.apache.maven.api.di.MojoExecutionScoped;
+import org.apache.maven.api.plugin.descriptor.MojoDescriptor;
+import org.apache.maven.api.xml.XmlNode;
+import org.apache.maven.internal.xml.XmlNodeWriter;
+import org.apache.maven.plugin.PluginParameterExpressionEvaluatorV4;
+import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException;
+import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluator;
+
+@Named
+@MojoExecutionScoped
+public class MojoConfigurationDigester {
+
+ private final ClasspathDigester classpathDigester;
+ private final Session session;
+ private final Project project;
+ private final MojoExecution execution;
+
+ @Inject
+ public MojoConfigurationDigester(Session session, Project project, MojoExecution execution) {
+ this.session = session;
+ this.project = project;
+ this.execution = execution;
+ this.classpathDigester = new ClasspathDigester(session);
+ }
+
+ public Map digest() throws IOException {
+ Map result = new LinkedHashMap<>();
+
+ MojoDescriptor mojoDescriptor = execution.getDescriptor();
+ List classpath = new ArrayList<>(execution.getPlugin().getDependencies());
+ result.put("mojo.classpath", classpathDigester.digest(classpath));
+
+ XmlNode node = execution.getConfiguration().orElse(null);
+ if (node != null) {
+ List errors = new ArrayList<>();
+ ExpressionEvaluator evaluator = new PluginParameterExpressionEvaluatorV4(session, project);
+ Class> mojoClass;
+ try {
+ mojoClass = execution.getPlugin().getClassLoader().loadClass(mojoDescriptor.getImplementation());
+ } catch (ClassNotFoundException e) {
+ errors.add("mojo class not found " + mojoDescriptor.getImplementation());
+ mojoClass = null;
+ }
+ if (mojoClass != null) {
+ for (XmlNode child : node.getChildren()) {
+ String name = fromXML(child.getName());
+ try {
+ Field field = getField(mojoClass, name);
+ if (field != null) {
+ String expression = child.getValue();
+ if (expression == null) {
+ expression = getChildrenXml(child);
+ }
+ if (expression == null) {
+ expression = child.getAttribute("default-value");
+ }
+ if (expression != null) {
+ Object value = evaluator.evaluate(expression);
+ if (value != null) {
+ Serializable digest = Digesters.digest(field, value);
+ if (digest != null) {
+ result.put("mojo.parameter." + name, digest);
+ }
+ }
+ }
+ }
+ } catch (Digesters.UnsupportedParameterTypeException e) {
+ errors.add("parameter " + name + " has unsupported type " + e.type.getName());
+ } catch (ExpressionEvaluationException | XMLStreamException e) {
+ errors.add("parameter " + name + " " + e.getMessage());
+ }
+ }
+ }
+ if (!errors.isEmpty()) {
+ StringBuilder sb = new StringBuilder();
+ sb.append(project.toString());
+ sb.append(" could not digest configuration of ").append(execution);
+ for (String error : errors) {
+ sb.append("\n ").append(error);
+ }
+ throw new IllegalArgumentException(sb.toString());
+ }
+ }
+ return result;
+ }
+
+ private String getChildrenXml(XmlNode node) throws XMLStreamException {
+ List children = node.getChildren();
+ if (children.isEmpty()) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (XmlNode child : children) {
+ append(sb, child);
+ }
+ return sb.toString();
+ }
+
+ private void append(StringBuilder sb, XmlNode node) throws XMLStreamException {
+ StringWriter sw = new StringWriter();
+ XmlNodeWriter.write(sw, node);
+ sb.append(sw.toString());
+ }
+
+ private Field getField(Class> clazz, String name) {
+ for (Field field : clazz.getDeclaredFields()) {
+ if (name.equals(field.getName())) {
+ return field;
+ }
+ }
+ if (clazz.getSuperclass() != null) {
+ return getField(clazz.getSuperclass(), name);
+ }
+ return null;
+ }
+
+ // first-name --> firstName
+ protected String fromXML(final String elementName) {
+ boolean firstToken = true;
+ boolean firstLetter = true;
+ int rindex = 0;
+ int windex = 0;
+ int[] codepoints = elementName.codePoints().toArray();
+ while (rindex < codepoints.length) {
+ int cp = codepoints[rindex++];
+ if (cp == '-') {
+ firstToken = false;
+ firstLetter = true;
+ } else {
+ if (firstLetter) {
+ cp = firstToken ? Character.toLowerCase(cp) : Character.toTitleCase(cp);
+ firstLetter = false;
+ }
+ codepoints[windex++] = cp;
+ }
+ }
+ return new String(codepoints, 0, windex);
+ }
+}
diff --git a/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/SHA1Digester.java b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/SHA1Digester.java
new file mode 100644
index 000000000000..144c77c1dccc
--- /dev/null
+++ b/impl/maven-core/src/main/java/org/apache/maven/internal/build/context/impl/maven/digest/SHA1Digester.java
@@ -0,0 +1,52 @@
+/*
+ * 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.internal.build.context.impl.maven.digest;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+
+// CHECKSTYLE_OFF: LineLength
+
+/**
+ * TODO: replace somehow with
+ * org.apache.maven.buildcache.hash.HashFactory
+ */
+public class SHA1Digester {
+
+ public static MessageDigest newInstance() {
+ try {
+ return MessageDigest.getInstance("SHA1");
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("Unsupported JVM", e);
+ }
+ }
+
+ //
+ // convenient helpers
+ //
+
+ public static BytesHash digest(String string) {
+ MessageDigest digest = newInstance();
+ if (string != null) {
+ digest.update(string.getBytes(StandardCharsets.UTF_8));
+ }
+ return new BytesHash(digest.digest());
+ }
+}
diff --git a/impl/maven-impl/pom.xml b/impl/maven-impl/pom.xml
index 693e1ac66b2f..11f719ff7889 100644
--- a/impl/maven-impl/pom.xml
+++ b/impl/maven-impl/pom.xml
@@ -130,6 +130,11 @@ under the License.
junit-jupiter-api
test
+
+ org.junit.jupiter
+ junit-jupiter-params
+ test
+
org.apache.maven
maven-logging
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java
new file mode 100644
index 000000000000..f3ee4718dff1
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContext.java
@@ -0,0 +1,813 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.util.ArrayList;
+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.Objects;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.annotations.Nullable;
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Input;
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Output;
+import org.apache.maven.api.build.context.Severity;
+import org.apache.maven.api.build.context.Status;
+import org.apache.maven.api.build.context.spi.BuildContextEnvironment;
+import org.apache.maven.api.build.context.spi.BuildContextFinalizer;
+import org.apache.maven.api.build.context.spi.CommitableBuildContext;
+import org.apache.maven.api.build.context.spi.Message;
+import org.apache.maven.api.build.context.spi.Sink;
+import org.apache.maven.api.build.context.spi.Workspace;
+
+public class DefaultBuildContext implements CommitableBuildContext {
+ final Workspace workspace;
+ final Path stateFile;
+ final DefaultBuildContextState state;
+ final DefaultBuildContextState oldState;
+ /**
+ * Previous build state does not exist, cannot be read or configuration has changed. When
+ * escalated, all input files are considered require processing.
+ */
+ private final boolean escalated;
+ /**
+ * Resources known to be deleted since previous build. Includes both resources reported as deleted
+ * by Workspace and resources explicitly delete through this build context.
+ */
+ private final Set deletedResources = new HashSet<>();
+ /**
+ * Resources selected for processing during this build. This includes resources created, changed
+ * and deleted through this build context.
+ */
+ private final Set processedResources = new HashSet<>();
+ /**
+ * Indicates that no further modifications to this build context are allowed.
+ */
+ private boolean closed;
+ /**
+ * Indicates whether the build will continue even if there are compilation errors.
+ */
+ private boolean failOnError = true;
+
+ public DefaultBuildContext(BuildContextEnvironment env) {
+ this(env.getWorkspace(), env.getStateFile(), env.getParameters(), env.getFinalizer());
+ }
+
+ protected DefaultBuildContext(
+ Workspace workspace,
+ Path stateFile,
+ Map configuration,
+ BuildContextFinalizer finalizer) {
+ // preconditions
+ if (workspace == null) {
+ throw new NullPointerException();
+ }
+ if (configuration == null) {
+ throw new NullPointerException();
+ }
+
+ this.stateFile = stateFile != null ? stateFile.toAbsolutePath() : null;
+ this.state = DefaultBuildContextState.withConfiguration(configuration);
+ this.oldState = DefaultBuildContextState.loadFrom(this.stateFile);
+
+ final boolean configurationChanged = getConfigurationChanged();
+ if (workspace.getMode() == Workspace.Mode.ESCALATED) {
+ this.escalated = true;
+ this.workspace = workspace;
+ } else if (workspace.getMode() == Workspace.Mode.SUPPRESSED) {
+ this.escalated = false;
+ this.workspace = workspace;
+ } else if (configurationChanged || !isPresent(oldState.getOutputs())) {
+ this.escalated = true;
+ this.workspace = workspace.escalate();
+ } else {
+ this.escalated = false;
+ this.workspace = workspace;
+ }
+
+ if (escalated && this.stateFile != null) {
+ if (!Files.isReadable(this.stateFile)) {
+ logInfo("Previous incremental build state does not exist, performing full build");
+ } else {
+ logInfo("Incremental build configuration change detected, performing full build");
+ }
+ } else {
+ logInfo("Performing incremental build");
+ }
+
+ if (finalizer != null) {
+ finalizer.registerContext(this);
+ }
+ }
+
+ private static boolean containsOnly(Collection collection, Path element) {
+ return collection.stream().allMatch(element::equals);
+ }
+
+ static Path normalize(Path input) {
+ if (input == null) {
+ throw new IllegalArgumentException();
+ }
+ return FileMatcher.getCanonicalPath(input);
+ }
+
+ public boolean getFailOnError() {
+ return failOnError;
+ }
+
+ @Override
+ public void setFailOnError(boolean failOnError) {
+ this.failOnError = failOnError;
+ }
+
+ protected void logInfo(String message) {
+ System.out.println(message);
+ }
+
+ private boolean isPresent(Collection outputs) {
+ // in some scenarios, notable classpath change caused by changes to pom.xml,
+ // jdt builder deletes all files from target/classes directory during incremental workspace
+ // build. this behaviour is not communicated to m2e (or any other workspace builder) and thus
+ // m2e does not recreate deleted outputs
+ // this workaround escalates the build if any of the old outputs were deleted
+ return outputs.stream().allMatch(Files::isRegularFile);
+ }
+
+ private boolean getConfigurationChanged() {
+ Map configuration = state.configuration;
+ Map oldConfiguration = oldState.configuration;
+ return Stream.concat(configuration.keySet().stream(), oldConfiguration.keySet().stream())
+ .distinct()
+ .anyMatch(k -> !Objects.equals(configuration.get(k), oldConfiguration.get(k)));
+ }
+
+ @Override
+ public boolean isProcessingRequired() {
+ return isEscalated()
+ || state.getResources().keySet().stream()
+ .anyMatch(resource ->
+ !state.isOutput(resource) && getResourceStatus(resource) != Status.UNMODIFIED)
+ || oldState.getResources().keySet().stream()
+ .anyMatch(resource -> !oldState.isOutput(resource) && !state.isResource(resource));
+ }
+
+ @Override
+ public DefaultOutput processOutput(Path outputFile) {
+ outputFile = normalize(outputFile);
+ DefaultOutputMetadata metadata = registerNormalizedOutput(outputFile);
+ return processOutput(metadata);
+ }
+
+ protected DefaultOutput processOutput(DefaultOutputMetadata metadata) {
+ processResource(metadata.getPath());
+ workspace.processOutput(metadata.getPath());
+ return newOutput(metadata);
+ }
+
+ @Override
+ public DefaultInputSet newInputSet() {
+ return new DefaultInputSet(this);
+ }
+
+ @Override
+ public DefaultInputMetadata registerInput(Path inputFile) {
+ inputFile = normalize(inputFile);
+ BasicFileAttributes attrs = readAttributes(inputFile);
+ return registerNormalizedInput(inputFile, attrs.lastModifiedTime(), attrs.size());
+ }
+
+ static BasicFileAttributes readAttributes(Path inputFile) {
+ try {
+ return Files.readAttributes(inputFile, BasicFileAttributes.class);
+ } catch (NoSuchFileException e) {
+ return new BasicFileAttributes() {
+ @Override
+ public FileTime lastModifiedTime() {
+ return null;
+ }
+
+ @Override
+ public FileTime lastAccessTime() {
+ return null;
+ }
+
+ @Override
+ public FileTime creationTime() {
+ return null;
+ }
+
+ @Override
+ public boolean isRegularFile() {
+ return false;
+ }
+
+ @Override
+ public boolean isDirectory() {
+ return false;
+ }
+
+ @Override
+ public boolean isSymbolicLink() {
+ return false;
+ }
+
+ @Override
+ public boolean isOther() {
+ return false;
+ }
+
+ @Override
+ public long size() {
+ return 0;
+ }
+
+ @Override
+ public Object fileKey() {
+ return null;
+ }
+ };
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public Collection extends DefaultInputMetadata> registerInputs(
+ Path basedir, Collection includes, Collection excludes) {
+ basedir = normalize(basedir);
+ Map matchers = FileMatcher.createMatchers(basedir, includes, excludes);
+ List result = matchers.entrySet().stream()
+ .flatMap(e -> workspace
+ .walk(e.getKey())
+ .filter(s ->
+ !Files.isDirectory(s.getPath()) && e.getValue().matches(s.getPath())))
+ .map(s -> {
+ if (s.getStatus() == Status.REMOVED) {
+ deletedResources.add(s.getPath());
+ } else {
+ registerInput(new FileState(s.getPath(), s.getLastModified(), s.getSize()));
+ }
+ return new DefaultInputMetadata(DefaultBuildContext.this, oldState, s.getPath());
+ })
+ .collect(Collectors.toList());
+ if (workspace.getMode() == Workspace.Mode.DELTA) {
+ // only NEW, MODIFIED and REMOVED resources are reported in DELTA mode
+ // need to find any UNMODIFIED
+ final FileMatcher absoluteMatcher = FileMatcher.createMatcher(basedir, includes, excludes);
+ for (FileState fileState : oldState.getResources().values()) {
+ Path path = fileState.getPath();
+ if (!state.isResource(path) && !deletedResources.contains(path) && absoluteMatcher.matches(path)) {
+ result.add(registerNormalizedInput(path, fileState.getLastModified(), fileState.getSize()));
+ }
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public Collection extends DefaultInput> registerAndProcessInputs(
+ Path basedir, Collection includes, Collection excludes) {
+ return registerInputs(basedir, includes, excludes).stream()
+ .map(m -> {
+ switch (m.getStatus()) {
+ case NEW:
+ case MODIFIED:
+ return processInput(m);
+ default:
+ return new DefaultInput(this, state, m.getPath());
+ }
+ })
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Marks skipped build execution. All inputs, outputs and their associated metadata are carried
+ * over to the next build as-is. No context modification operations (register* or process) are
+ * permitted after this call.
+ */
+ @Override
+ public void markSkipExecution() {
+ if (!processedResources.isEmpty()) {
+ throw new IllegalStateException();
+ }
+ closed = true;
+ }
+
+ protected DefaultInputMetadata registerNormalizedInput(Path resourceFile, FileTime lastModified, long length) {
+ assertOpen();
+ if (!state.isResource(resourceFile)) {
+ registerInput(newFileState(resourceFile, lastModified, length));
+ }
+ return new DefaultInputMetadata(this, oldState, resourceFile);
+ }
+
+ private FileState newFileState(Path path) {
+ BasicFileAttributes attrs = readAttributes(path);
+ return newFileState(path, attrs.lastModifiedTime(), attrs.size());
+ }
+
+ private FileState newFileState(Path file, FileTime lastModified, long size) {
+ if (!workspace.isPresent(file)) {
+ throw new IllegalArgumentException("File does not exist or cannot be read " + file);
+ }
+ return new FileState(file, lastModified, size);
+ }
+
+ protected DefaultOutputMetadata registerNormalizedOutput(Path outputFile) {
+ assertOpen();
+ if (!state.isResource(outputFile)) {
+ state.putResource(outputFile, null); // placeholder
+ state.addOutput(outputFile);
+ } else {
+ if (!state.isOutput(outputFile)) {
+ throw new IllegalStateException("Already registered as input " + outputFile);
+ }
+ }
+ return new DefaultOutputMetadata(this, oldState, outputFile);
+ }
+
+ public boolean aggregate(
+ Collection extends DefaultInputMetadata> inputs,
+ Path outputFile,
+ BiConsumer> creator) {
+ DefaultOutputMetadata output = registerOutput(outputFile);
+ return aggregate(inputs, output, creator);
+ }
+
+ public boolean aggregate(
+ Collection extends DefaultInputMetadata> inputs,
+ DefaultOutputMetadata output,
+ BiConsumer> creator) {
+ associate(inputs, output);
+ boolean processingRequired = isEscalated();
+ if (!processingRequired) {
+ processingRequired = isProcessingRequired(inputs, output);
+ }
+ if (processingRequired) {
+ DefaultOutput outputResource = processOutput(output);
+ List inputResources = inputs.stream().map(this::processInput).collect(Collectors.toList());
+ creator.accept(outputResource, inputResources);
+ } else {
+ markUptodateOutput(output.getPath());
+ }
+ return processingRequired;
+ }
+
+ public boolean aggregate(
+ Collection extends DefaultInputMetadata> inputs,
+ Path outputFile,
+ String stepId,
+ T identity,
+ Function mapper,
+ BinaryOperator accumulator,
+ BiConsumer writer) {
+ DefaultOutputMetadata output = registerOutput(outputFile);
+ associate(inputs, output);
+ boolean processingRequired = isEscalated() || isProcessingRequired(inputs, output);
+ if (processingRequired) {
+ T metadata = inputs.stream()
+ .map(input -> getMetadata(input, stepId, mapper))
+ .reduce(identity, accumulator);
+ T oldMetadata = getOutputInputs(oldState, outputFile).stream()
+ .map(inputFile -> oldState.getResourceAttribute(inputFile, stepId))
+ .reduce(identity, accumulator);
+ if (!Objects.equals(metadata, oldMetadata)) {
+ DefaultOutput outputResource = processOutput(output);
+ writer.accept(outputResource, metadata);
+ return true;
+ }
+ } else {
+ markUptodateOutput(output.getPath());
+ }
+ return false;
+ }
+
+ private T getMetadata(
+ DefaultMetadata input, String stepId, Function mapper) {
+ if (input.getStatus() != Status.UNMODIFIED) {
+ return mapper.apply(input.process());
+ } else {
+ return oldState.getResourceAttribute(input.getPath(), stepId);
+ }
+ }
+
+ protected void finalizeContext() {
+ // only supports simple input --> output associations
+ // outputs are carried over iff their input is carried over
+
+ // TODO harden the implementation
+ //
+ // things can get tricky even with such simple model. consider the following
+ // build-1: inputA --> outputA
+ // build-2: inputA unchanged. inputB --> outputA
+ // now outputA has multiple inputs, which is not supported by this context
+ //
+ // another tricky example
+ // build-1: inputA --> outputA
+ // build-2: inputA unchanged before the build, inputB --> inputA
+ // now inputA is both input and output, which is not supported by this context
+
+ // multi-pass implementation
+ // pass 1, carry-over up-to-date inputs and collect all up-to-date outputs
+ // pass 2, carry-over all up-to-date outputs
+ // pass 3, remove obsolete and orphaned outputs
+
+ Set uptodateOldOutputs = new HashSet<>();
+ Set uptodateOldInputs = new HashSet<>();
+ for (Path resource : oldState.getResources().keySet()) {
+ if (oldState.isOutput(resource)) {
+ continue;
+ }
+
+ if (isProcessedResource(resource) || isDeletedResource(resource) || !isRegisteredResource(resource)) {
+ // deleted or processed resource, nothing to carry over
+ continue;
+ }
+
+ if (state.isOutput(resource)) {
+ // resource flipped from input to output without going through delete
+ throw new BuildContextException(
+ new IllegalStateException("Inconsistent resource type change " + resource));
+ }
+
+ // carry over
+ state.putResource(resource, oldState.getResource(resource));
+ state.setResourceMessages(resource, oldState.getResourceMessages(resource));
+ state.setResourceAttributes(resource, oldState.getResourceAttributes(resource));
+ state.setResourceOutputs(resource, oldState.getResourceOutputs(resource));
+ uptodateOldInputs.add(resource);
+ }
+
+ for (Path oldOutput : oldState.getOutputs()) {
+ Collection outputInputs = oldState.getOutputInputs(oldOutput);
+ if (outputInputs != null && uptodateOldInputs.containsAll(outputInputs)) {
+ uptodateOldOutputs.add(oldOutput);
+ }
+ }
+
+ for (Path output : uptodateOldOutputs) {
+ if (state.isResource(output)) {
+ // can't carry-over registered resources
+ // throw new IllegalStateException( "Can't carry over " + output );
+ }
+
+ state.putResource(output, oldState.getResource(output));
+ state.addOutput(output);
+ state.setResourceMessages(output, oldState.getResourceMessages(output));
+ state.setResourceAttributes(output, oldState.getResourceAttributes(output));
+ }
+
+ for (Path output : oldState.getOutputs()) {
+ if (!state.isOutput(output)) {
+ deleteOutput(output);
+ }
+ }
+ }
+
+ protected void deleteOutput(Path resource) {
+ if (!oldState.isOutput(resource) && !state.isOutput(resource)) {
+ // not an output known to this build context
+ throw new IllegalArgumentException();
+ }
+
+ workspace.deleteFile(resource);
+
+ deletedResources.add(resource);
+ processedResources.add(resource);
+
+ state.removeResource(resource);
+ state.removeOutput(resource);
+
+ state.removeResourceAttributes(resource);
+ state.removeResourceMessages(resource);
+ state.removeResourceOutputs(resource);
+ }
+
+ protected boolean isEscalated() {
+ return escalated;
+ }
+
+ // re-create output if any its inputs were added, changed or deleted since previous build
+ private boolean isProcessingRequired(
+ Collection extends DefaultInputMetadata> inputs, DefaultOutputMetadata output) {
+ if (getResourceStatus(output.getPath()) == Status.MODIFIED) {
+ return true;
+ }
+ if (inputs.stream().anyMatch(r -> r.getStatus() != Status.UNMODIFIED)) {
+ return true;
+ }
+ List inputFiles = inputs.stream().map(Metadata::getPath).collect(Collectors.toList());
+ return getOutputInputs(oldState, output.getPath()).stream().anyMatch(r -> !inputFiles.contains(r));
+ }
+
+ protected boolean isProcessedResource(Path resource) {
+ return processedResources.contains(resource);
+ }
+
+ protected Set getProcessedResources() {
+ return processedResources;
+ }
+
+ protected boolean isProcessed() {
+ return !processedResources.isEmpty();
+ }
+
+ protected void markProcessedResource(Path resource) {
+ processedResources.add(resource);
+ }
+
+ private DefaultOutputMetadata registerOutput(Path outputFile) {
+ outputFile = normalize(outputFile);
+ if (isRegisteredResource(outputFile)) {
+ // only allow single registration of the same output. not sure why/if multiple will be needed
+ throw new BuildContextException(new IllegalStateException("Output already registered " + outputFile));
+ }
+ return registerNormalizedOutput(outputFile);
+ }
+
+ private Collection getOutputInputs(DefaultBuildContextState contextState, Path outputFile) {
+ Collection inputs = contextState.getOutputInputs(outputFile);
+ return inputs != null && !inputs.isEmpty() ? inputs : Collections.emptyList();
+ }
+
+ protected boolean isRegisteredResource(Path resource) {
+ return state.isResource(resource);
+ }
+
+ protected boolean isDeletedResource(Path resource) {
+ return deletedResources.contains(resource);
+ }
+
+ protected void markUptodateOutput(Path outputFile) {
+ if (!oldState.isOutput(outputFile)) {
+ throw new IllegalArgumentException();
+ }
+ state.putResource(outputFile, oldState.getResource(outputFile));
+ state.addOutput(outputFile);
+ }
+
+ /**
+ * Adds the resource to this build's resource set. The resource must exist, i.e. it's status must
+ * not be REMOVED.
+ *
+ * @param holder the file state of the resource to register
+ * @return the normalized path of the registered resource
+ */
+ protected Path registerInput(FileState holder) {
+ Path resource = holder.getPath();
+ FileState other = state.getResource(resource);
+ if (other == null) {
+ if (getResourceStatus(holder) == Status.REMOVED) {
+ throw new BuildContextException(new IllegalArgumentException("Resource does not exist " + resource));
+ }
+ state.putResource(resource, holder);
+ } else {
+ if (state.isOutput(resource)) {
+ throw new BuildContextException(new IllegalStateException("Already registered as output " + resource));
+ }
+ if (!holder.equals(other)) {
+ throw new BuildContextException(
+ new IllegalArgumentException("Inconsistent resource state " + resource));
+ }
+ state.putResource(resource, holder);
+ }
+ return resource;
+ }
+
+ private Status getResourceStatus(FileState fileState) {
+ return workspace.getResourceStatus(fileState.getPath(), fileState.getLastModified(), fileState.getSize());
+ }
+
+ private void assertOpen() {
+ if (closed) {
+ throw new IllegalStateException();
+ }
+ }
+
+ protected DefaultInput processInput(DefaultInputMetadata metadata) {
+ final Path resource = metadata.getPath();
+ if (metadata.context != this || !state.isResource(resource)) {
+ throw new IllegalArgumentException();
+ }
+ processResource(resource);
+ return new DefaultInput(this, state, resource);
+ }
+
+ private void processResource(final Path resource) {
+ processedResources.add(resource);
+
+ // reset all metadata associated with the resource during this build
+ // state.removeResourceAttributes( resource );
+ // state.removeResourceMessages( resource );
+ // state.removeResourceOutputs( resource );
+ }
+
+ protected Status getResourceStatus(Path resource) {
+ if (deletedResources.contains(resource)) {
+ return Status.REMOVED;
+ }
+
+ FileState oldResourceState = oldState.getResource(resource);
+ if (oldResourceState == null) {
+ return Status.NEW;
+ }
+
+ Status status = getResourceStatus(oldResourceState);
+
+ if (status == Status.UNMODIFIED && escalated) {
+ status = Status.MODIFIED;
+ }
+
+ return status;
+ }
+
+ protected DefaultOutput associate(DefaultInput input, DefaultOutput output) {
+ if (input.context != this) {
+ throw new BuildContextException(new IllegalArgumentException());
+ }
+ if (output.context != this) {
+ throw new BuildContextException(new IllegalArgumentException());
+ }
+
+ assertAssociation(input, output);
+
+ state.putResourceOutput(input.getPath(), output.getPath());
+ return output;
+ }
+
+ private void associate(Iterable extends DefaultInputMetadata> inputs, DefaultOutputMetadata output) {
+ inputs.forEach(r -> state.putResourceOutput(r.getPath(), output.getPath()));
+ }
+
+ protected Collection extends DefaultOutputMetadata> getAssociatedOutputs(
+ DefaultBuildContextState contextState, Path resource) {
+ Collection outputFiles = contextState.getResourceOutputs(resource);
+ if (outputFiles == null || outputFiles.isEmpty()) {
+ return Collections.emptyList();
+ }
+ List outputs = new ArrayList<>();
+ for (Path outputFile : outputFiles) {
+ outputs.add(new DefaultOutputMetadata(this, contextState, outputFile));
+ }
+ return outputs;
+ }
+
+ protected void assertAssociation(DefaultInput resource, DefaultOutput output) {
+ Path input = resource.getPath();
+ Path outputFile = output.getPath();
+
+ // input --> output --> output2 is not supported (until somebody provides a usecase)
+ if (state.isOutput(input)) {
+ throw new BuildContextException(new UnsupportedOperationException());
+ }
+
+ // each output can only be associated with a single input
+ Collection inputs = state.getOutputInputs(outputFile);
+ if (inputs != null && !inputs.isEmpty() && !containsOnly(inputs, input)) {
+ throw new BuildContextException(new UnsupportedOperationException());
+ }
+ }
+
+ protected Serializable setResourceAttribute(Path resource, String key, T value) {
+ state.putResourceAttribute(resource, key, value);
+ // TODO odd this always returns previous build state. need to think about it
+ return oldState.getResourceAttribute(resource, key);
+ }
+
+ protected T getResourceAttribute(
+ DefaultBuildContextState contextState, Path resource, String key, Class clazz) {
+ Map attributes = contextState.getResourceAttributes(resource);
+ return attributes != null ? clazz.cast(attributes.get(key)) : null;
+ }
+
+ void addMessage(Path resource, int line, int column, String message, Severity severity, Throwable cause) {
+ // this is likely called as part of builder error handling logic.
+ // to make IAE easier to troubleshoot, link cause to the exception thrown
+ if (resource == null) {
+ throw new IllegalArgumentException("resource cannot be null", cause);
+ }
+ if (severity == null) {
+ throw new IllegalArgumentException("severity cannot be null", cause);
+ }
+ state.addResourceMessage(resource, new Message(line, column, message, severity, cause));
+ log(resource, line, column, message, severity, cause);
+ }
+
+ OutputStream newOutputStream(DefaultOutput output) {
+ return workspace.newOutputStream(output.getPath());
+ }
+
+ DefaultOutput newOutput(DefaultOutputMetadata resource) {
+ return new DefaultOutput(this, state, resource.resource);
+ }
+
+ public void commit(@Nullable Sink sink) {
+ if (closed) {
+ return;
+ }
+ this.closed = true;
+
+ // messages recorded during this build
+ Map> newMessages = new HashMap<>(state.getResourceMessages());
+
+ finalizeContext();
+
+ // assert inputs didn't change
+ for (Map.Entry entry : state.getResources().entrySet()) {
+ Path resource = entry.getKey();
+ FileState holder = entry.getValue();
+ if (!state.isOutput(resource) && holder.getStatus() != Status.UNMODIFIED) {
+ throw new BuildContextException(new IllegalStateException("Unexpected input change " + resource));
+ }
+ }
+
+ // timestamp new outputs
+ state.getOutputs().forEach(outputFile -> state.computeResourceIfAbsent(outputFile, this::newFileState));
+
+ if (stateFile != null) {
+ try (OutputStream os = workspace.newOutputStream(stateFile)) {
+ state.storeTo(os);
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ // new messages are logged as soon as they are reported during the build
+ // replay old messages so the user can still see them
+ Map> allMessages = new HashMap<>(state.getResourceMessages());
+
+ if (!allMessages.keySet().equals(newMessages.keySet())) {
+ for (Map.Entry> entry : allMessages.entrySet()) {
+ Path resource = entry.getKey();
+ if (!newMessages.containsKey(resource)) {
+ for (Message message : entry.getValue()) {
+ log(
+ resource,
+ message.getLine(),
+ message.getColumn(),
+ message.getMessage(),
+ message.getSeverity(),
+ message.getCause());
+ }
+ }
+ }
+ }
+
+ // processedResources includes resources added, changed and deleted during this build
+ // clear all old messages associated with the processed resources during previous builds
+ if (sink != null) {
+ for (Path resource : processedResources) {
+ sink.clear(resource);
+ }
+ for (Path resource : oldState.getResources().keySet()) {
+ if (!state.isResource(resource)) {
+ sink.clear(resource);
+ }
+ }
+ for (Map.Entry> entry : allMessages.entrySet()) {
+ Path resource = entry.getKey();
+ boolean isNew = newMessages.containsKey(resource);
+ sink.messages(resource, isNew, entry.getValue());
+ }
+ }
+ }
+
+ private void log(Path resource, int line, int column, String message, Severity severity, Throwable cause) {
+ // TODO
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java
new file mode 100644
index 000000000000..40a62b4bad05
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextState.java
@@ -0,0 +1,520 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.BufferedInputStream;
+import java.io.BufferedOutputStream;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.io.ObjectStreamClass;
+import java.io.OutputStream;
+import java.io.Serializable;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.apache.maven.api.build.context.spi.Message;
+
+public class DefaultBuildContextState implements Serializable {
+
+ private static final long serialVersionUID = 6195150574931820441L;
+
+ final Map configuration;
+
+ private final Set outputs;
+
+ private final Map resources;
+
+ private final Map> resourceOutputs;
+
+ // pure in-memory performance optimization, always reflects contents of resourceOutputs
+ private final Map> outputInputs;
+
+ private final Map> resourceAttributes;
+
+ private final Map> resourceMessages;
+
+ private DefaultBuildContextState(
+ Map configuration,
+ Map inputs,
+ Set outputs,
+ Map> resourceOutputs,
+ Map> outputInputs,
+ Map> resourceAttributes,
+ Map> resourceMessages) {
+ this.configuration = configuration;
+ this.resources = inputs;
+ this.outputs = outputs;
+ this.resourceOutputs = resourceOutputs;
+ this.outputInputs = outputInputs;
+ this.resourceAttributes = resourceAttributes;
+ this.resourceMessages = resourceMessages;
+ }
+
+ public static DefaultBuildContextState withConfiguration(Map configuration) {
+ HashMap copy = new HashMap<>(configuration);
+ // configuration marker used to distinguish between empty and new state
+ copy.put("incremental", Boolean.TRUE);
+ return new DefaultBuildContextState(
+ Collections.unmodifiableMap(copy), // configuration
+ new HashMap<>(), // inputs
+ new HashSet<>(), // outputs
+ new HashMap<>(), // inputOutputs
+ new HashMap<>(), // outputInputs
+ new HashMap<>(), // resourceAttributes
+ new HashMap<>() // messages
+ );
+ }
+
+ public static DefaultBuildContextState emptyState() {
+ return new DefaultBuildContextState(
+ Collections.emptyMap(), // configuration
+ Collections.emptyMap(), // inputs
+ Collections.emptySet(), // outputs
+ Collections.emptyMap(), // inputOutputs
+ Collections.emptyMap(), // outputInputs
+ Collections.emptyMap(), // resourceAttributes
+ Collections.emptyMap() // messages
+ );
+ }
+
+ private static void writeMap(ObjectOutputStream oos, Map, ?> map) throws IOException {
+ oos.writeInt(map.size());
+ for (Map.Entry, ?> entry : map.entrySet()) {
+ oos.writeObject(entry.getKey());
+ oos.writeObject(entry.getValue());
+ }
+ }
+
+ private static void writeMultimap(ObjectOutputStream oos, Map, ? extends Collection>> mmap) throws IOException {
+ oos.writeInt(mmap.size());
+ for (Map.Entry, ? extends Collection>> entry : mmap.entrySet()) {
+ oos.writeObject(entry.getKey());
+ writeCollection(oos, entry.getValue());
+ }
+ }
+
+ private static void writeCollection(ObjectOutputStream oos, Collection> collection) throws IOException {
+ if (collection == null || collection.isEmpty()) {
+ oos.writeInt(0);
+ } else {
+ oos.writeInt(collection.size());
+ for (Object element : collection) {
+ oos.writeObject(element);
+ }
+ }
+ }
+
+ private static void writeDoublemap(ObjectOutputStream oos, Map, ? extends Map, ?>> dmap) throws IOException {
+ oos.writeInt(dmap.size());
+ for (Map.Entry, ? extends Map, ?>> entry : dmap.entrySet()) {
+ oos.writeObject(entry.getKey());
+ writeMap(oos, entry.getValue());
+ }
+ }
+
+ public static DefaultBuildContextState loadFrom(Path stateFile) {
+ // TODO verify stateFile location has not changed since last build
+ // TODO wrap collections in corresponding immutable collections
+
+ if (stateFile == null) {
+ // transient build context
+ return DefaultBuildContextState.emptyState();
+ }
+
+ try {
+ // TODO does it matter if TCCL or super is called first?
+ try (ObjectInputStream is =
+ new PathAwareInputStream(
+ stateFile.getFileSystem(), new BufferedInputStream(Files.newInputStream(stateFile))) {
+ @Override
+ protected Class> resolveClass(ObjectStreamClass desc)
+ throws IOException, ClassNotFoundException {
+ // TODO does it matter if TCCL or super is called first?
+ try {
+ ClassLoader tccl = Thread.currentThread().getContextClassLoader();
+ return tccl.loadClass(desc.getName());
+ } catch (ClassNotFoundException e) {
+ return super.resolveClass(desc);
+ }
+ }
+ }) {
+ Map configuration = readMap(is);
+ Set outputs = readSet(is);
+ Map resources = readMap(is);
+ Map> resourceOutputs = readMultimap(is);
+ Map> outputInputs = invertMultimap(resourceOutputs);
+ Map> resourceAttributes = readDoublemap(is);
+ Map> messages = readMultimap(is);
+
+ DefaultBuildContextState state = new DefaultBuildContextState(
+ configuration, resources, outputs, resourceOutputs, outputInputs, resourceAttributes, messages);
+ return state;
+ }
+ // ignore secondary exceptions
+ } catch (FileNotFoundException e) {
+ // this is expected, silently ignore
+ } catch (RuntimeException e) {
+ // this is a bug in our code, let it bubble up as build failure
+ throw e;
+ } catch (Exception e) {
+ // this is almost certainly caused by incompatible state file, log and continue
+ }
+ return DefaultBuildContextState.emptyState();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map readMap(ObjectInputStream ois) throws IOException, ClassNotFoundException {
+ Map map = new HashMap<>();
+ int size = ois.readInt();
+ for (int i = 0; i < size; i++) {
+ K key = (K) ois.readObject();
+ V value = (V) ois.readObject();
+ map.put(key, value);
+ }
+ return Collections.unmodifiableMap(map);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map> readMultimap(ObjectInputStream ois)
+ throws IOException, ClassNotFoundException {
+ Map> mmap = new HashMap<>();
+ int size = ois.readInt();
+ for (int i = 0; i < size; i++) {
+ K key = (K) ois.readObject();
+ Collection value = readCollection(ois);
+ mmap.put(key, value);
+ }
+ return Collections.unmodifiableMap(mmap);
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Collection readCollection(ObjectInputStream ois) throws IOException, ClassNotFoundException {
+ int size = ois.readInt();
+ if (size == 0) {
+ return null;
+ }
+ Collection collection = new ArrayList();
+ for (int i = 0; i < size; i++) {
+ collection.add((V) ois.readObject());
+ }
+ return Collections.unmodifiableCollection(collection);
+ }
+
+ private static Set readSet(ObjectInputStream ois) throws IOException, ClassNotFoundException {
+ Collection collection = readCollection(ois);
+ return collection != null ? Collections.unmodifiableSet(new HashSet(collection)) : Collections.emptySet();
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map> readDoublemap(ObjectInputStream ois)
+ throws IOException, ClassNotFoundException {
+ int size = ois.readInt();
+ Map> dmap = new HashMap<>();
+ for (int i = 0; i < size; i++) {
+ K key = (K) ois.readObject();
+ Map value = readMap(ois);
+ dmap.put(key, value);
+ }
+ return Collections.unmodifiableMap(dmap);
+ }
+
+ private static Map> invertMultimap(Map> mmap) {
+ Map> inverted = new HashMap<>();
+ for (Map.Entry> entry : mmap.entrySet()) {
+ for (V value : entry.getValue()) {
+ Collection keys = inverted.computeIfAbsent(value, k -> new ArrayList<>());
+ keys.add(entry.getKey());
+ }
+ }
+ return Collections.unmodifiableMap(inverted);
+ }
+
+ private static boolean put(Map> multimap, K key, V value) {
+ Collection values = multimap.computeIfAbsent(key, k -> new LinkedHashSet());
+ return values.add(value);
+ }
+
+ public String getStats() {
+ return String.valueOf(configuration.size())
+ + ' '
+ + resources.size()
+ + ' '
+ + outputs.size()
+ + ' '
+ + resourceOutputs.size()
+ + ' '
+ + outputInputs.size()
+ + ' '
+ + resourceAttributes.size()
+ + ' '
+ + resourceMessages.size()
+ + ' ';
+ }
+
+ //
+ // getters and settings
+ //
+
+ // resources
+
+ public void storeTo(OutputStream os) throws IOException {
+ ObjectOutputStream oos = new PathAwareOutputStream(os);
+ try {
+ writeMap(oos, this.configuration);
+ writeCollection(oos, this.outputs);
+ writeMap(oos, this.resources);
+
+ writeMultimap(oos, resourceOutputs);
+ writeDoublemap(oos, resourceAttributes);
+ writeMultimap(oos, resourceMessages);
+
+ } finally {
+ oos.flush();
+ }
+ }
+
+ public void putResource(Path resource, FileState holder) {
+ resources.put(resource, holder);
+ }
+
+ public FileState getResource(Path resource) {
+ return resources.get(resource);
+ }
+
+ public void computeResourceIfAbsent(Path resource, Function supplier) {
+ resources.computeIfAbsent(resource, supplier);
+ }
+
+ public boolean isResource(Path resource) {
+ return resources.containsKey(resource);
+ }
+
+ public FileState removeResource(Path resource) {
+ return resources.remove(resource);
+ }
+
+ // outputInputs
+
+ public Map getResources() {
+ return Collections.unmodifiableMap(resources);
+ }
+
+ // outputs
+
+ public Collection getOutputInputs(Path outputFile) {
+ return outputInputs.get(outputFile);
+ }
+
+ public Collection getOutputs() {
+ return Collections.unmodifiableCollection(outputs);
+ }
+
+ public boolean isOutput(Path outputFile) {
+ return outputs.contains(outputFile);
+ }
+
+ public boolean addOutput(Path output) {
+ return outputs.add(output);
+ }
+
+ // resourceOutputs
+
+ public boolean removeOutput(Path output) {
+ return outputs.remove(output);
+ }
+
+ public boolean putResourceOutput(Path resource, Path output) {
+ put(outputInputs, output, resource);
+ return put(resourceOutputs, resource, output);
+ }
+
+ public Collection getResourceOutputs(Path resource) {
+ return resourceOutputs.get(resource);
+ }
+
+ public Collection setResourceOutputs(Path resource, Collection newOutputs) {
+ if (newOutputs == null || newOutputs.isEmpty()) {
+ return resourceOutputs.remove(resource);
+ }
+ return resourceOutputs.put(resource, newOutputs);
+ }
+
+ public Collection removeResourceOutputs(Path resource) {
+ Collection removedOutputs = resourceOutputs.remove(resource);
+ removeOutputInputs(removedOutputs, resource);
+ return removedOutputs;
+ }
+
+ // resourceAttributes
+
+ private void removeOutputInputs(Collection outputPaths, Path resource) {
+ if (outputPaths == null) {
+ return;
+ }
+ for (Path output : outputPaths) {
+ Collection inputs = outputInputs.get(output);
+ if (inputs == null || !inputs.remove(resource)) {
+ throw new IllegalStateException();
+ }
+ if (inputs.isEmpty()) {
+ outputInputs.remove(output);
+ }
+ }
+ }
+
+ public Map removeResourceAttributes(Path resource) {
+ return resourceAttributes.remove(resource);
+ }
+
+ public Map getResourceAttributes(Path resource) {
+ return resourceAttributes.get(resource);
+ }
+
+ public Serializable putResourceAttribute(Path resource, String key, Serializable value) {
+ return resourceAttributes
+ .computeIfAbsent(resource, k -> new LinkedHashMap<>())
+ .put(key, value);
+ }
+
+ @SuppressWarnings("unchecked")
+ public T getResourceAttribute(Path resource, String key) {
+ return (T) resourceAttributes
+ .getOrDefault(resource, Collections.emptyMap())
+ .get(key);
+ }
+
+ // resourceMessages
+
+ public Map setResourceAttributes(Path resource, Map attributes) {
+ if (attributes == null || attributes.isEmpty()) {
+ return resourceAttributes.remove(resource);
+ }
+ return resourceAttributes.put(resource, attributes);
+ }
+
+ public Collection removeResourceMessages(Path resource) {
+ return resourceMessages.remove(resource);
+ }
+
+ public Collection getResourceMessages(Path resource) {
+ return resourceMessages.get(resource);
+ }
+
+ public Collection setResourceMessages(Path resource, Collection messages) {
+ if (messages == null || messages.isEmpty()) {
+ return resourceMessages.remove(resource);
+ }
+ return resourceMessages.put(resource, messages);
+ }
+
+ public boolean addResourceMessage(Path resource, Message message) {
+ return put(resourceMessages, resource, message);
+ }
+
+ public Map> getResourceMessages() {
+ return Collections.unmodifiableMap(resourceMessages);
+ }
+
+ /**
+ * Path is not serializable
+ * so this class is used as a workaround during serialization.
+ */
+ static class StoredPath implements Serializable {
+ private final String path;
+
+ StoredPath(Path path) {
+ this.path = path.toString();
+ }
+
+ public Path toPath(FileSystem fileSystem) {
+ return fileSystem.getPath(path);
+ }
+ }
+
+ /**
+ * FileTime is not serializable and Instant can not be used as a replacement,
+ * so this class is used as a workaround during serialization.
+ */
+ static class StoredInstant implements Serializable {
+ private final long seconds;
+ private final int nanos;
+
+ StoredInstant(FileTime time) {
+ Instant instant = time.toInstant();
+ this.seconds = instant.getEpochSecond();
+ this.nanos = instant.getNano();
+ }
+
+ public FileTime toFileTime() {
+ return FileTime.from(Instant.ofEpochSecond(seconds, nanos));
+ }
+ }
+
+ static class PathAwareInputStream extends ObjectInputStream {
+ private final FileSystem fileSystem;
+
+ PathAwareInputStream(FileSystem fileSystem, InputStream in) throws IOException {
+ super(in);
+ this.fileSystem = fileSystem;
+ enableResolveObject(true);
+ }
+
+ @Override
+ protected Object resolveObject(Object obj) {
+ return obj instanceof StoredPath
+ ? ((StoredPath) obj).toPath(fileSystem)
+ : obj instanceof StoredInstant ? ((StoredInstant) obj).toFileTime() : obj;
+ }
+ }
+
+ static class PathAwareOutputStream extends ObjectOutputStream {
+ PathAwareOutputStream(OutputStream os) throws IOException {
+ super(new BufferedOutputStream(os));
+ enableReplaceObject(true);
+ }
+
+ @Override
+ protected Object replaceObject(Object obj) {
+ return obj instanceof Path
+ ? new StoredPath((Path) obj)
+ : obj instanceof FileTime ? new StoredInstant((FileTime) obj) : obj;
+ }
+
+ @Override
+ protected void writeObjectOverride(Object obj) throws IOException {
+ super.writeObjectOverride(obj);
+ }
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInput.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInput.java
new file mode 100644
index 000000000000..bf89ebfdb4f5
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInput.java
@@ -0,0 +1,34 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.build.context.Input;
+
+public class DefaultInput extends DefaultResource implements Input {
+ public DefaultInput(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ super(context, state, resource);
+ }
+
+ @Override
+ public DefaultOutput associateOutput(Path outputFile) {
+ return context.associate(this, context.processOutput(outputFile));
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputMetadata.java
new file mode 100644
index 000000000000..0e60b0d8ad22
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputMetadata.java
@@ -0,0 +1,34 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.build.context.Input;
+
+public class DefaultInputMetadata extends DefaultMetadata {
+ public DefaultInputMetadata(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ super(context, state, resource);
+ }
+
+ @Override
+ public DefaultInput process() {
+ return context.processInput(this);
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputSet.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputSet.java
new file mode 100644
index 000000000000..c61c651f13e6
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultInputSet.java
@@ -0,0 +1,83 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.Serializable;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.function.BiConsumer;
+import java.util.function.BinaryOperator;
+import java.util.function.Function;
+
+import org.apache.maven.api.build.context.Input;
+import org.apache.maven.api.build.context.InputSet;
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Output;
+
+public class DefaultInputSet implements InputSet {
+
+ private final DefaultBuildContext context;
+
+ private final Set inputs = new LinkedHashSet<>();
+
+ DefaultInputSet(DefaultBuildContext context) {
+ this.context = context;
+ }
+
+ @Override
+ public void addInput(Metadata inputMetadata) {
+ if (!(inputMetadata instanceof DefaultInputMetadata)) {
+ throw new IllegalArgumentException("inputMetadata is not an instance of " + DefaultInputMetadata.class);
+ }
+ inputs.add((DefaultInputMetadata) inputMetadata);
+ }
+
+ @Override
+ public Metadata registerInput(Path inputFile) {
+ DefaultInputMetadata input = context.registerInput(inputFile);
+ inputs.add(input);
+ return input;
+ }
+
+ @Override
+ public Collection extends Metadata > registerInputs(
+ Path basedir, Collection includes, Collection excludes) {
+ Collection extends DefaultInputMetadata> newInputs = context.registerInputs(basedir, includes, excludes);
+ this.inputs.addAll(newInputs);
+ return newInputs;
+ }
+
+ @Override
+ public boolean aggregate(Path outputFile, BiConsumer> aggregator) {
+ return context.aggregate(inputs, outputFile, aggregator);
+ }
+
+ @Override
+ public boolean aggregate(
+ Path outputFile,
+ String stepId,
+ T identity,
+ Function mapper,
+ BinaryOperator accumulator,
+ BiConsumer writer) {
+ return context.aggregate(inputs, outputFile, stepId, identity, mapper, accumulator, writer);
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultMetadata.java
new file mode 100644
index 000000000000..23de290eb653
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultMetadata.java
@@ -0,0 +1,73 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Resource;
+import org.apache.maven.api.build.context.Status;
+
+public abstract class DefaultMetadata implements Metadata {
+ /** The build context that owns this metadata. */
+ protected final DefaultBuildContext context;
+ /** The build context state associated with this metadata. */
+ protected final DefaultBuildContextState state;
+ /** The path of the resource described by this metadata. */
+ protected final Path resource;
+
+ public DefaultMetadata(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ this.context = context;
+ this.state = state;
+ this.resource = resource;
+ }
+
+ @Override
+ public Path getPath() {
+ return resource;
+ }
+
+ @Override
+ public Status getStatus() {
+ return context.getResourceStatus(resource);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ DefaultMetadata> that = (DefaultMetadata>) o;
+ return context == that.context && state == that.state && resource.equals(that.resource);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(resource);
+ }
+
+ @Override
+ public String toString() {
+ return resource.toString();
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutput.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutput.java
new file mode 100644
index 000000000000..6b0807b7957f
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutput.java
@@ -0,0 +1,35 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.OutputStream;
+import java.nio.file.Path;
+
+import org.apache.maven.api.build.context.Output;
+
+public class DefaultOutput extends DefaultResource implements Output {
+ public DefaultOutput(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ super(context, state, resource);
+ }
+
+ @Override
+ public OutputStream newOutputStream() {
+ return context.newOutputStream(this);
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutputMetadata.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutputMetadata.java
new file mode 100644
index 000000000000..2068ecb3b45e
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultOutputMetadata.java
@@ -0,0 +1,34 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Path;
+
+import org.apache.maven.api.build.context.Output;
+
+public class DefaultOutputMetadata extends DefaultMetadata {
+ public DefaultOutputMetadata(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ super(context, state, resource);
+ }
+
+ @Override
+ public DefaultOutput process() {
+ return context.processOutput(this);
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java
new file mode 100644
index 000000000000..94e0f3d8fdf6
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/DefaultResource.java
@@ -0,0 +1,78 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Path;
+import java.util.Objects;
+
+import org.apache.maven.api.build.context.Resource;
+import org.apache.maven.api.build.context.Severity;
+import org.apache.maven.api.build.context.Status;
+
+public abstract class DefaultResource implements Resource {
+ /** The build context that owns this resource. */
+ protected final DefaultBuildContext context;
+ /** The build context state associated with this resource. */
+ protected final DefaultBuildContextState state;
+ /** The path of this resource. */
+ protected final Path resource;
+
+ public DefaultResource(DefaultBuildContext context, DefaultBuildContextState state, Path resource) {
+ this.context = context;
+ this.state = state;
+ this.resource = resource;
+ }
+
+ @Override
+ public Path getPath() {
+ return resource;
+ }
+
+ @Override
+ public Status getStatus() {
+ return context.getResourceStatus(resource);
+ }
+
+ @Override
+ public void addMessage(int line, int column, String message, Severity severity, Throwable cause) {
+ context.addMessage(getPath(), line, column, message, severity, cause);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ DefaultMetadata> that = (DefaultMetadata>) o;
+ return context == that.context && state == that.state && resource.equals(that.resource);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(resource);
+ }
+
+ @Override
+ public String toString() {
+ return resource.toString();
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java
new file mode 100644
index 000000000000..ca9bdad2113c
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileMatcher.java
@@ -0,0 +1,364 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.StringTokenizer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.regex.Pattern;
+
+public class FileMatcher {
+ private static final Matcher MATCH_EVERYTHING = p -> true;
+ final Matcher includesMatcher;
+ final Matcher excludesMatcher;
+ private final String basedir;
+
+ private FileMatcher(String basedir, Matcher includesMatcher, Matcher excludesMatcher) {
+ this.basedir = basedir;
+ this.includesMatcher = includesMatcher;
+ this.excludesMatcher = excludesMatcher;
+ }
+
+ private static Matcher fromStrings(String basepath, Collection globs, Matcher everything) {
+ if (globs == null || globs.isEmpty()) {
+ return null; // default behaviour appropriate for includes/excludes pattern
+ }
+ final ArrayList normalized = new ArrayList<>();
+ for (String glob : globs) {
+ if ("*".equals(glob) || "**".equals(glob) || "**/*".equals(glob)) {
+ return everything; // matches everything
+ }
+
+ StringBuilder gb = new StringBuilder();
+ if (!basepath.endsWith("/")) {
+ gb.append(basepath).append('/');
+ }
+ gb.append(glob.startsWith("/") ? glob.substring(1) : glob);
+
+ // from https://ant.apache.org/manual/dirtasks.html#patterns
+ // There is one "shorthand": if a pattern ends with / or \, then ** is appended
+ if (glob.endsWith("/")) {
+ gb.append("**");
+ }
+ normalized.add(gb.toString());
+ }
+ final List patterns =
+ normalized.stream().map(FileMatcher::antPatternToRegex).collect(java.util.stream.Collectors.toList());
+ return path -> patterns.stream().anyMatch(p -> p.matcher(path).matches());
+ }
+
+ /**
+ * Converts an Ant-style glob pattern to a compiled {@link Pattern regex}.
+ * Supports {@code *} (any chars within a path segment), {@code **} (zero or more path segments),
+ * and {@code ?} (single char within a segment).
+ */
+ private static Pattern antPatternToRegex(String antPattern) {
+ StringBuilder regex = new StringBuilder("^");
+ int i = 0;
+ int len = antPattern.length();
+ while (i < len) {
+ char c = antPattern.charAt(i);
+ if (c == '*') {
+ if (i + 1 < len && antPattern.charAt(i + 1) == '*') {
+ // ** matches zero or more path segments
+ i += 2;
+ if (i < len && antPattern.charAt(i) == '/') {
+ // **/ → zero or more directory segments followed by /
+ regex.append("(?:.+/)?");
+ i++;
+ } else {
+ // ** at end → match anything remaining
+ regex.append(".*");
+ }
+ } else {
+ // * matches within a single segment
+ regex.append("[^/]*");
+ i++;
+ }
+ } else if (c == '?') {
+ regex.append("[^/]");
+ i++;
+ } else {
+ if (".+^$|(){}[]\\".indexOf(c) >= 0) {
+ regex.append('\\');
+ }
+ regex.append(c);
+ i++;
+ }
+ }
+ regex.append("$");
+ return Pattern.compile(regex.toString());
+ }
+
+ /**
+ * Given a directory, returns a map of location to FileMatcher that will optimize the lookup. The
+ * key can either be a file (if it is a single path matcher) or a directory (if it is an
+ * open-ended matcher). The associated matcher will still be relative to the basedir, not to the
+ * key, but will only match paths that start with the key.
+ *
+ * @param basedir the base directory for resolving paths
+ * @param includes the include patterns, may be {@code null}
+ * @param excludes the exclude patterns, may be {@code null}
+ * @return a map of paths to their corresponding file matchers
+ */
+ public static Map createMatchers(
+ final Path basedir, Collection includes, Collection excludes) {
+ String basepath = normalize0(basedir);
+ if (includes == null || includes.isEmpty()) {
+ return Collections.singletonMap(basedir, createMatcher(basepath, includes, excludes));
+ }
+ return createMatchers(basepath, includes, excludes, file -> {
+ String sep = basedir.getFileSystem().getSeparator();
+ if (!"/".equals(sep)) {
+ file = file.substring(1).replace("/", sep);
+ }
+ return basedir.getFileSystem().getPath(file);
+ });
+ }
+
+ /**
+ * Returns a map of location to FileMatcher that will optimize the lookup. The key is a path of a
+ * file or directory in a logical filesystem that uses '/' as file separator. The associated
+ * matcher is relative to the root of the logical filesystem, not to the ley, but will only match
+ * paths that start with the key.
+ *
+ * @param includes the include patterns, may be {@code null}
+ * @param excludes the exclude patterns, may be {@code null}
+ * @return a map of string paths to their corresponding file matchers
+ */
+ public static Map createMatchers(Collection includes, Collection excludes) {
+ String basepath = "";
+ if (includes == null || includes.isEmpty()) {
+ return Collections.singletonMap(basepath, createMatcher(basepath, includes, excludes));
+ }
+ return createMatchers(basepath, includes, excludes, Function.identity());
+ }
+
+ private static Map createMatchers(
+ String basepath, Collection includes, Collection excludes, Function fromString) {
+ final Matcher excludesMatcher = fromStrings(basepath, excludes, MATCH_EVERYTHING);
+ Map matchers = new HashMap<>();
+ newIncludesTrie(includes).subdirs().forEach((relpath, globs) -> {
+ String path = relpath != null ? basepath + "/" + relpath : basepath;
+ FileMatcher matcher = globs != null //
+ ? createMatcher(path, globs, excludesMatcher) //
+ : createSinglePathMatcher(path);
+ matchers.put(fromString.apply(path), matcher);
+ });
+ return matchers;
+ }
+
+ private static Trie newIncludesTrie(Collection includes) {
+ Trie root = new Trie();
+ for (String include : includes) {
+ // ant shorthand syntax
+ if (include.endsWith("/")) {
+ include = include + "**"; // chuck norris should approve
+ }
+ Trie trie = root;
+ StringTokenizer st = new StringTokenizer(include, "/");
+ while (st.hasMoreTokens()) {
+ String name = st.nextToken();
+ if (name.contains("*") || name.contains("?")) {
+ trie.addIncludes(subglob(name, st));
+ break;
+ }
+ trie = trie.child(name);
+ }
+ }
+ return root;
+ }
+
+ private static FileMatcher createMatcher(String basedir, Collection includes, Matcher excludesMatcher) {
+ final Matcher includesMatcher = fromStrings(basedir, includes, null);
+ return new FileMatcher(toDirectoryPath(basedir), includesMatcher, excludesMatcher);
+ }
+
+ private static FileMatcher createSinglePathMatcher(String path) {
+ return new FileMatcher(null, new SinglePathMatcher(path) /* includesMatcher */, null /* excludesMatcher */);
+ }
+
+ private static String subglob(String name, StringTokenizer st) {
+ StringBuilder glob = new StringBuilder(name);
+ while (st.hasMoreTokens()) {
+ glob.append('/').append(st.nextToken());
+ }
+ return glob.toString();
+ }
+
+ /**
+ * Creates and returns new matcher for files under specified {@code basedir} that satisfy
+ * specified includes/excludes patterns.
+ *
+ * @param basedir the base directory for resolving paths
+ * @param includes the include patterns, may be {@code null}
+ * @param excludes the exclude patterns, may be {@code null}
+ * @return the file matcher for the given patterns
+ */
+ public static FileMatcher createMatcher(
+ final Path basedir, Collection includes, Collection excludes) {
+ return createMatcher(normalize0(basedir), includes, excludes);
+ }
+
+ public static FileMatcher createMatcher(Collection includes, Collection excludes) {
+ return createMatcher("", includes, excludes);
+ }
+
+ private static FileMatcher createMatcher(
+ final String basepath, Collection includes, Collection excludes) {
+ final Matcher includesMatcher = fromStrings(basepath, includes, null);
+ final Matcher excludesMatcher = fromStrings(basepath, excludes, MATCH_EVERYTHING);
+ return new FileMatcher(toDirectoryPath(basepath), includesMatcher, excludesMatcher);
+ }
+
+ protected static String toDirectoryPath(final String basepath) {
+ return basepath.endsWith("/") ? basepath : basepath + "/";
+ }
+
+ static Path getCanonicalPath(Path path) {
+ try {
+ return path.toRealPath();
+ } catch (IOException e) {
+ return getCanonicalPath(path.getParent()).resolve(path.getFileName());
+ }
+ }
+
+ private static String normalize0(Path basedir) {
+ String separator = basedir.getFileSystem().getSeparator();
+ String path = getCanonicalPath(basedir).toString();
+ if (!"/".equals(separator)) {
+ path = "/" + path.replace(separator, "/");
+ }
+ return path;
+ }
+
+ /**
+ * Returns {@code true} if provided path is under this matcher's basedir and satisfies
+ * includes/excludes patterns. The provided path is assumed to be normalized according to
+ * {@link #normalize0(Path)}.
+ *
+ * @param path the normalized path to test
+ * @return {@code true} if the path matches the include/exclude patterns
+ */
+ public boolean matches(String path) {
+ if (basedir != null && !path.startsWith(basedir)) {
+ return false;
+ }
+ if (excludesMatcher != null && excludesMatcher.matches(path)) {
+ return false;
+ }
+ if (includesMatcher != null) {
+ return includesMatcher.matches(path);
+ }
+ return true;
+ }
+
+ public boolean matches(Path file) {
+ return matches(normalize0(file));
+ }
+
+ private interface Matcher extends Predicate {
+ boolean matches(String path);
+
+ @Override
+ default boolean test(String s) {
+ return matches(s);
+ }
+ }
+
+ static class SinglePathMatcher implements Matcher {
+
+ final String path;
+
+ SinglePathMatcher(String path) {
+ this.path = path;
+ }
+
+ @Override
+ public boolean matches(String pathToMatch) {
+ return this.path.equals(pathToMatch);
+ }
+ }
+
+ static class Trie {
+ Map children;
+ Collection includes;
+
+ private static String childname(String basedir, String name) {
+ return basedir != null ? basedir + "/" + name : name;
+ }
+
+ public void addIncludes(String glob) {
+ if (includes == null) {
+ includes = new LinkedHashSet<>();
+ }
+ includes.add(glob);
+ if (children != null) {
+ children.values().forEach(child -> child.addIncludesTo(includes));
+ children = null;
+ }
+ }
+
+ private void addIncludesTo(Collection other) {
+ if (children != null) {
+ children.values().forEach(child -> child.addIncludesTo(other));
+ }
+ if (includes != null) {
+ other.addAll(includes);
+ }
+ }
+
+ public Trie child(String name) {
+ if (includes != null) {
+ return this;
+ }
+ if (children == null) {
+ children = new HashMap<>();
+ }
+ Trie child = children.get(name);
+ if (child == null) {
+ child = new Trie();
+ children.put(name, child);
+ }
+ return child;
+ }
+
+ public Map> subdirs() {
+ return addSubdirs(null, new HashMap<>());
+ }
+
+ private Map> addSubdirs(String path, Map> subdirs) {
+ if (children != null) {
+ children.forEach((name, child) -> child.addSubdirs(childname(path, name), subdirs));
+ } else {
+ subdirs.put(path, includes);
+ }
+ return subdirs;
+ }
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java
new file mode 100644
index 000000000000..bdcb96580e83
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FileState.java
@@ -0,0 +1,100 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.IOException;
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.util.Objects;
+
+import org.apache.maven.api.annotations.Nonnull;
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Status;
+
+public class FileState implements Serializable {
+
+ private static final long serialVersionUID = 1;
+
+ @Nonnull
+ private final Path path;
+
+ private final FileTime lastModified;
+ private final long size;
+
+ public FileState(@Nonnull Path path, FileTime lastModified, long size) {
+ this.path = Objects.requireNonNull(path, "path can not be null");
+ this.lastModified = lastModified;
+ this.size = size;
+ }
+
+ @Nonnull
+ public Path getPath() {
+ return path;
+ }
+
+ public FileTime getLastModified() {
+ return lastModified;
+ }
+
+ public long getSize() {
+ return size;
+ }
+
+ public Status getStatus() {
+ try {
+ if (!Files.isRegularFile(path) || !Files.isReadable(path)) {
+ return Status.REMOVED;
+ }
+ BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
+ if (size == attrs.size() && Objects.equals(lastModified, attrs.lastModifiedTime())) {
+ return Status.UNMODIFIED;
+ }
+ return Status.MODIFIED;
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(path, lastModified, size);
+ }
+
+ @Override
+ public boolean equals(Object obj) {
+ if (this == obj) {
+ return true;
+ }
+ if (!(obj instanceof FileState)) {
+ return false;
+ }
+ FileState other = (FileState) obj;
+ return Objects.equals(path, other.path)
+ && Objects.equals(lastModified, other.lastModified)
+ && size == other.size;
+ }
+
+ @Override
+ public String toString() {
+ return "FileState[path=" + path + ", lastModified=" + lastModified + ", size=" + size + "]";
+ }
+}
diff --git a/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java
new file mode 100644
index 000000000000..5a8361e08fcb
--- /dev/null
+++ b/impl/maven-impl/src/main/java/org/apache/maven/internal/build/context/impl/FilesystemWorkspace.java
@@ -0,0 +1,114 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.BufferedOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.util.Objects;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Status;
+import org.apache.maven.api.build.context.spi.FileState;
+import org.apache.maven.api.build.context.spi.Workspace;
+import org.apache.maven.api.di.Named;
+
+@Named
+public class FilesystemWorkspace implements Workspace {
+
+ @Override
+ public Mode getMode() {
+ return Mode.NORMAL;
+ }
+
+ @Override
+ public Workspace escalate() {
+ return this;
+ }
+
+ @Override
+ public void deleteFile(Path file) {
+ try {
+ Files.deleteIfExists(file);
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public void processOutput(Path path) {}
+
+ @Override
+ public OutputStream newOutputStream(Path path) {
+ try {
+ Files.createDirectories(path.getParent());
+ return new BufferedOutputStream(Files.newOutputStream(path));
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public Status getResourceStatus(Path file, FileTime lastModified, long length) {
+ if (!isRegularFile(file) && !isDirectory(file)) {
+ return Status.REMOVED;
+ }
+ try {
+ BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);
+ return Objects.equals(length, attrs.size()) && Objects.equals(lastModified, attrs.lastModifiedTime())
+ ? Status.UNMODIFIED
+ : Status.MODIFIED;
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public boolean isPresent(Path file) {
+ return isRegularFile(file) && Files.isReadable(file);
+ }
+
+ @Override
+ public boolean isRegularFile(Path file) {
+ return Files.isRegularFile(file);
+ }
+
+ @Override
+ public boolean isDirectory(Path file) {
+ return Files.isDirectory(file);
+ }
+
+ @Override
+ public Stream walk(Path basedir) {
+ if (Files.isDirectory(basedir)) {
+ try {
+ return Files.walk(basedir).map(path -> new FileState(path, Status.NEW));
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ } else {
+ return Stream.empty();
+ }
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java
new file mode 100644
index 000000000000..79550042d632
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/AbstractBuildContextTest.java
@@ -0,0 +1,60 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.Serializable;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import org.apache.maven.api.build.context.spi.Workspace;
+import org.junit.jupiter.api.io.TempDir;
+
+public abstract class AbstractBuildContextTest {
+ /** Temporary directory for tests. */
+ @TempDir
+ @SuppressWarnings("checkstyle:VisibilityModifier")
+ protected Path temp;
+
+ protected static List toList(Iterable iterable) {
+ if (iterable == null) {
+ return null;
+ }
+
+ List result = new ArrayList();
+ for (T t : iterable) {
+ result.add(t);
+ }
+ return result;
+ }
+
+ protected TestBuildContext newBuildContext() {
+ return newBuildContext(Collections.emptyMap());
+ }
+
+ protected TestBuildContext newBuildContext(Map config) {
+ return new TestBuildContext(temp.resolve("buildstate.ctx"), config);
+ }
+
+ protected TestBuildContext newBuildContext(Workspace workspace) {
+ return new TestBuildContext(workspace, temp.resolve("buildstate.ctx"), Collections.emptyMap());
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java
new file mode 100644
index 000000000000..16f477f6a3b4
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultAggregatorBuildContextTest.java
@@ -0,0 +1,181 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.BufferedWriter;
+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.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.function.BiConsumer;
+
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Input;
+import org.apache.maven.api.build.context.Output;
+import org.apache.maven.api.build.context.Resource;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class DefaultAggregatorBuildContextTest extends AbstractBuildContextTest {
+
+ @Test
+ public void testBasic() throws Exception {
+ FileMatcher.getCanonicalPath(Paths.get("/oo/bar"));
+
+ Path outputFile = temp.resolve("output");
+
+ Path basedir = Files.createTempDirectory(temp, "").toRealPath();
+ Path a = basedir.resolve("a");
+ Files.createFile(a);
+
+ // initial build
+ FileIndexer indexer = new FileIndexer();
+ DefaultBuildContext actx = newContext();
+ DefaultInputSet inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(1, indexer.outputs.size());
+ assertEquals(1, indexer.inputs.size());
+
+ // no-change rebuild
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(0, indexer.outputs.size());
+
+ // no-change rebuild
+ indexer = new FileIndexer();
+ actx = newContext();
+
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(0, indexer.outputs.size());
+
+ // new input
+ Path b = basedir.resolve("b");
+ Files.createFile(b);
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(1, indexer.outputs.size());
+
+ // removed input
+ Files.delete(a);
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(1, indexer.outputs.size());
+
+ // no-change rebuild
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(0, indexer.outputs.size());
+
+ // removed output
+ Files.delete(outputFile);
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(1, indexer.outputs.size());
+
+ // no-change rebuild
+ indexer = new FileIndexer();
+ actx = newContext();
+ inputSet = actx.newInputSet();
+ inputSet.registerInputs(basedir, null, null);
+ inputSet.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(0, indexer.outputs.size());
+ }
+
+ private DefaultBuildContext newContext() {
+ Path stateFile = temp.resolve("buildstate.ctx");
+ return new DefaultBuildContext(new FilesystemWorkspace(), stateFile, new HashMap<>(), null);
+ }
+
+ @Test
+ public void testEmpty() throws Exception {
+ Path outputFile = temp.resolve("output");
+ Path basedir = Files.createTempDirectory(temp, "");
+
+ FileIndexer indexer = new FileIndexer();
+ DefaultBuildContext actx = newContext();
+ DefaultInputSet output = actx.newInputSet();
+ output.registerInputs(basedir, null, null);
+ output.aggregate(outputFile, indexer);
+ actx.commit(null);
+ assertTrue(Files.isReadable(outputFile));
+ assertEquals(1, indexer.outputs.size());
+ }
+
+ @SuppressWarnings("checkstyle:VisibilityModifier")
+ private static class FileIndexer implements BiConsumer> {
+ final List inputs = new ArrayList<>();
+ final List outputs = new ArrayList<>();
+
+ @Override
+ public void accept(Output output, Collection inputCollection) {
+ outputs.add(output.getPath());
+ try (BufferedWriter w = output.newBufferedWriter(StandardCharsets.UTF_8)) {
+ for (Resource input : inputCollection) {
+ Path path = input.getPath();
+ this.inputs.add(path);
+ w.write(path.toAbsolutePath().toString());
+ w.newLine();
+ }
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java
new file mode 100644
index 000000000000..a7df8c3c1d38
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBasicBuildContextTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.Serializable;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+public class DefaultBasicBuildContextTest {
+ /** Temporary directory for tests. */
+ @TempDir
+ private Path temp;
+
+ private DefaultBuildContext newContext() {
+ Path stateFile = temp.resolve("buildstate.ctx");
+ return new DefaultBuildContext(new FilesystemWorkspace(), stateFile, new HashMap(), null);
+ }
+
+ @Test
+ public void testDeletedOutput() throws Exception {
+ Path input = Files.createFile(temp.resolve("input"));
+ Path output = Files.createFile(temp.resolve("output"));
+
+ DefaultBuildContext ctx;
+
+ ctx = newContext();
+ ctx.registerInput(input);
+ ctx.processOutput(output);
+ ctx.commit(null);
+
+ Files.delete(output);
+
+ ctx = newContext();
+ ctx.registerInput(input);
+ ctx.commit(null);
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java
new file mode 100644
index 000000000000..6a8f39bce929
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextStateTest.java
@@ -0,0 +1,85 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.ObjectOutputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Collections;
+import java.util.HashMap;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class DefaultBuildContextStateTest {
+ /** Temporary directory for tests. */
+ @TempDir
+ private Path temp;
+
+ @Test
+ public void testRoundtrip() throws Exception {
+ Path file = Files.createTempFile(temp, "", "");
+ DefaultBuildContextState state = DefaultBuildContextState.withConfiguration(new HashMap<>());
+ BasicFileAttributes attrs = DefaultBuildContext.readAttributes(file);
+ state.putResource(file, new FileState(file, attrs.lastModifiedTime(), attrs.size()));
+
+ Path stateFile = Files.createTempFile(temp, "", "");
+ try (OutputStream os = Files.newOutputStream(stateFile)) {
+ state.storeTo(os);
+ }
+
+ state = DefaultBuildContextState.loadFrom(stateFile);
+
+ assertNotNull(state.getResource(file));
+ }
+
+ @Test
+ public void testStateDoesNotExist() throws Exception {
+ DefaultBuildContextState state = DefaultBuildContextState.loadFrom(temp.resolve("does-not-exist"));
+ assertTrue(state.configuration.isEmpty());
+ }
+
+ @Test
+ public void testEmptyState() throws Exception {
+ Path stateFile = Files.createTempFile(temp, "", "");
+ assertTrue(DefaultBuildContextState.loadFrom(stateFile).configuration.isEmpty());
+ }
+
+ @Test
+ public void testCorruptedState() throws Exception {
+ Path corrupted = Files.createTempFile(temp, "", "");
+ Files.write(corrupted, Collections.singletonList("test"), StandardOpenOption.APPEND);
+ assertTrue(DefaultBuildContextState.loadFrom(corrupted).configuration.isEmpty());
+ }
+
+ @Test
+ public void testIncompatibleState() throws Exception {
+ Path incompatible = Files.createTempFile(temp, "", "");
+ ObjectOutputStream oos = new ObjectOutputStream(Files.newOutputStream(incompatible));
+ oos.writeUTF("incompatible");
+ oos.close();
+ assertTrue(DefaultBuildContextState.loadFrom(incompatible).configuration.isEmpty());
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java
new file mode 100644
index 000000000000..f409e921a3f3
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultBuildContextTest.java
@@ -0,0 +1,605 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.Serializable;
+import java.net.URL;
+import java.net.URLClassLoader;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import com.google.common.jimfs.Configuration;
+import com.google.common.jimfs.Jimfs;
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Input;
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Status;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import static org.apache.maven.api.build.context.Status.MODIFIED;
+import static org.apache.maven.api.build.context.Status.NEW;
+import static org.apache.maven.api.build.context.Status.UNMODIFIED;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class DefaultBuildContextTest extends AbstractBuildContextTest {
+
+ private static void assertIncludedPaths(Collection expected, Collection actual) throws IOException {
+ assertEquals(toString(expected), toString(actual));
+ }
+
+ private static String toString(Collection files) throws IOException {
+ return files.stream()
+ .map(DefaultBuildContext::normalize)
+ .map(Path::toString)
+ .sorted()
+ .collect(Collectors.joining("\n", "", "\n"));
+ }
+
+ @Test
+ public void testRegisterInputInputFileDoesNotExist() throws Exception {
+ Path file = Paths.get("target/does_not_exist");
+ assertTrue(!Files.exists(file) && !Files.isReadable(file));
+ assertThrows(IllegalArgumentException.class, () -> newBuildContext().registerInput(file));
+ }
+
+ @Test
+ public void testRegisterInput() throws Exception {
+ // this is NOT part of API but rather currently implemented behaviour
+ // API allows #registerInput return different instances
+
+ Path file = Paths.get("src/test/resources/simplelogger.properties");
+ assertTrue(Files.exists(file) && Files.isReadable(file));
+ TestBuildContext context = newBuildContext();
+ assertNotNull(context.registerInput(file));
+ assertNotNull(context.registerInput(file));
+ }
+
+ @Test
+ public void testOutputWithoutInputs() throws Exception {
+ TestBuildContext context = newBuildContext();
+
+ Path outputFile = Files.createFile(temp.resolve("output_without_inputs"));
+ context.processOutput(outputFile);
+
+ // is not deleted by commit
+ context.commit();
+ assertTrue(Files.isReadable(outputFile));
+
+ // is not deleted after rebuild with re-registration
+ context = newBuildContext();
+ context.processOutput(outputFile);
+ context.commit();
+ assertTrue(Files.isReadable(outputFile));
+
+ // deleted after rebuild without re-registration
+ context = newBuildContext();
+ context.commit();
+ assertFalse(Files.isReadable(outputFile));
+ }
+
+ @Test
+ public void testGetInputStatus() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+
+ // initial build
+ TestBuildContext context = newBuildContext();
+ // first time invocation returns Input for processing
+ assertEquals(NEW, context.registerInput(inputFile).getStatus());
+ // second invocation still returns NEW
+ assertEquals(NEW, context.registerInput(inputFile).getStatus());
+ context.commit();
+
+ // new build
+ context = newBuildContext();
+ // input file was not modified since last build
+ assertEquals(UNMODIFIED, context.registerInput(inputFile).getStatus());
+ context.commit();
+
+ // new build
+ Files.write(inputFile, Collections.singletonList("test"), StandardOpenOption.APPEND);
+ context = newBuildContext();
+ // input file was modified since last build
+ assertEquals(MODIFIED, context.registerInput(inputFile).getStatus());
+ }
+
+ @Test
+ public void testInputModifiedAfterRegistration() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInput input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile);
+ context.commit();
+
+ TestBuildContext context2 = newBuildContext();
+ context2.registerInput(inputFile);
+ // this is incorrect use of build-avoidance API
+ // input has changed after it was registered for processing
+ // IllegalStateException is raised to prevent unexpected process/not-process flip-flop
+ Files.write(inputFile, Collections.singletonList("test"), StandardOpenOption.APPEND);
+ assertThrows(BuildContextException.class, context2::commit);
+ assertTrue(Files.isReadable(outputFile));
+ }
+
+ @Test
+ public void testCommitOrphanedOutputsCleanup() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInput input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile);
+ context.commit();
+
+ // input is not part of input set any more
+ // associated output must be cleaned up
+ context = newBuildContext();
+ context.commit();
+ assertFalse(Files.isReadable(outputFile));
+ }
+
+ @Test
+ public void testCommitStaleOutputCleanup() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile1 = Files.createFile(temp.resolve("outputFile1"));
+ Path outputFile2 = Files.createFile(temp.resolve("outputFile2"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInput input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile1);
+ input.associateOutput(outputFile2);
+ context.commit();
+
+ context = newBuildContext();
+ input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile1);
+ context.commit();
+ assertFalse(Files.isReadable(outputFile2));
+
+ context = newBuildContext();
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ assertEquals(1, toList(context.getAssociatedOutputs(metadata)).size());
+ context.commit();
+ }
+
+ @Test
+ public void testCreateStateParentDirectory() throws Exception {
+ Path stateFile = temp.resolve("sub/dir/buildstate.ctx");
+ TestBuildContext context = new TestBuildContext(stateFile, Collections.emptyMap());
+ context.commit();
+ assertTrue(Files.isReadable(stateFile));
+ }
+
+ @Test
+ public void testRegisterInputNullInput() throws Exception {
+ assertThrows(IllegalArgumentException.class, () -> newBuildContext().registerInput((Path) null));
+ }
+
+ @ParameterizedTest
+ @MethodSource("fileSystems")
+ public void testRegisterAndProcessInputs(String type, Supplier fs) throws Exception {
+ Path target = fs.get().getPath("target");
+ Files.createDirectories(target);
+ temp = Files.createTempDirectory(target, "tmp");
+
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+ List includes = Collections.singletonList("**/" + inputFile.getFileName());
+
+ TestBuildContext context = newBuildContext();
+ List extends DefaultInput> inputs = toList(context.registerAndProcessInputs(temp, includes, null));
+ assertEquals(1, inputs.size());
+ assertEquals(NEW, inputs.get(0).getStatus());
+ inputs.get(0).associateOutput(outputFile);
+ context.commit();
+
+ // no change rebuild
+ context = newBuildContext();
+ inputs = toList(context.registerAndProcessInputs(temp, includes, null));
+ assertEquals(1, inputs.size());
+ assertEquals(UNMODIFIED, inputs.get(0).getStatus());
+ context.commit();
+ }
+
+ @Test
+ void testInputDeleted() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ context.registerInput(inputFile).process().associateOutput(outputFile);
+ context.commit();
+
+ //
+ Files.delete(inputFile);
+ context = newBuildContext();
+ context.commit();
+ assertFalse(Files.exists(outputFile));
+ }
+
+ @Test
+ void testInputInListDeleted() throws Exception {
+ Path input = temp.resolve("input");
+ Path output = temp.resolve("output");
+ Files.createDirectories(input);
+ Files.createDirectories(output);
+
+ Path inputFile = Files.createFile(input.resolve("file"));
+ Path outputFile = Files.createFile(output.resolve("file"));
+
+ TestBuildContext context = newBuildContext();
+ Collection extends Input> inputs = context.registerAndProcessInputs(input, null, null);
+ assertNotNull(inputs);
+ assertFalse(inputs.isEmpty());
+ Input i = inputs.iterator().next();
+ i.associateOutput(outputFile);
+ context.commit();
+
+ //
+ Files.delete(inputFile);
+ context = newBuildContext();
+ context.commit();
+ assertFalse(Files.exists(outputFile));
+ }
+
+ @Test
+ public void testGetAssociatedOutputs() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ context.registerInput(inputFile).process().associateOutput(outputFile);
+ context.commit();
+
+ //
+ context = newBuildContext();
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ List extends Metadata> outputs = toList(context.getAssociatedOutputs(metadata));
+ assertEquals(1, outputs.size());
+ assertEquals(Status.UNMODIFIED, outputs.get(0).getStatus());
+ context.commit();
+
+ //
+ Files.write(outputFile, Collections.singletonList("test"), StandardOpenOption.APPEND);
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ outputs = toList(context.getAssociatedOutputs(metadata));
+ assertEquals(1, outputs.size());
+ assertEquals(Status.MODIFIED, outputs.get(0).getStatus());
+ context.commit();
+
+ //
+ Files.delete(outputFile);
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ outputs = toList(context.getAssociatedOutputs(metadata));
+ assertEquals(1, outputs.size());
+ assertEquals(Status.REMOVED, outputs.get(0).getStatus());
+ context.commit();
+ }
+
+ @Test
+ public void testGetRegisteredInputs() throws Exception {
+ Path inputFile1 = Files.createFile(temp.resolve("inputFile1"));
+ Path inputFile2 = Files.createFile(temp.resolve("inputFile2"));
+ Path inputFile3 = Files.createFile(temp.resolve("inputFile3"));
+ Path inputFile4 = Files.createFile(temp.resolve("inputFile4"));
+
+ TestBuildContext context = newBuildContext();
+ inputFile1 = context.registerInput(inputFile1).getPath();
+ inputFile2 = context.registerInput(inputFile2).getPath();
+ inputFile3 = context.registerInput(inputFile3).getPath();
+ context.commit();
+
+ Files.write(inputFile3, Collections.singletonList("test"), StandardOpenOption.APPEND);
+
+ context = newBuildContext();
+
+ // context.registerInput(inputFile1); DELETED
+ context.registerInput(inputFile2); // UNMODIFIED
+ context.registerInput(inputFile3); // MODIFIED
+ inputFile4 = context.registerInput(inputFile4).getPath(); // NEW
+
+ Map inputs = new TreeMap<>();
+ for (Metadata input : context.getRegisteredInputs()) {
+ inputs.put(input.getPath(), input);
+ }
+
+ assertEquals(4, inputs.size());
+ // assertEquals(ResourceStatus.REMOVED, inputs.get(inputFile1).getStatus());
+ assertEquals(Status.UNMODIFIED, inputs.get(inputFile2).getStatus());
+ assertEquals(Status.MODIFIED, inputs.get(inputFile3).getStatus());
+ assertEquals(Status.NEW, inputs.get(inputFile4).getStatus());
+ }
+
+ @Test
+ public void testInputAttributes() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ assertNull(context.getAttribute(metadata, "key", String.class));
+ DefaultInput input = metadata.process();
+ assertNull(context.setAttribute(input, "key", "value"));
+ context.commit();
+
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ assertEquals("value", context.getAttribute(metadata, "key", String.class));
+ context.commit();
+
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ assertEquals("value", context.getAttribute(metadata, "key", String.class));
+ input = metadata.process();
+ assertNull(context.getAttribute(input, "key", String.class));
+ assertEquals("value", context.setAttribute(input, "key", "newValue"));
+ assertEquals("value", context.setAttribute(input, "key", "newValue"));
+ assertEquals("newValue", context.getAttribute(input, "key", String.class));
+ context.commit();
+
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ assertEquals("newValue", context.getAttribute(metadata, "key", String.class));
+ context.commit();
+ }
+
+ @Test
+ public void testOutputStatus() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = temp.resolve("outputFile");
+
+ assertFalse(Files.isReadable(outputFile));
+
+ TestBuildContext context = newBuildContext();
+ DefaultOutput output = context.registerInput(inputFile).process().associateOutput(outputFile);
+ assertEquals(Status.NEW, output.getStatus());
+ output.newOutputStream().close();
+ context.commit();
+
+ // no-change rebuild
+ context = newBuildContext();
+ output = context.registerInput(inputFile).process().associateOutput(outputFile);
+ assertEquals(Status.UNMODIFIED, output.getStatus());
+ context.commit();
+
+ // modified output
+ Files.write(outputFile, Collections.singletonList("test"));
+ context = newBuildContext();
+ output = context.registerInput(inputFile).process().associateOutput(outputFile);
+ assertEquals(Status.MODIFIED, output.getStatus());
+ context.commit();
+
+ // no-change rebuild
+ context = newBuildContext();
+ output = context.registerInput(inputFile).process().associateOutput(outputFile);
+ assertEquals(Status.UNMODIFIED, output.getStatus());
+ context.commit();
+
+ // deleted output
+ Files.delete(outputFile);
+ context = newBuildContext();
+ output = context.registerInput(inputFile).process().associateOutput(outputFile);
+ assertEquals(Status.REMOVED, output.getStatus());
+ output.newOutputStream().close(); // processed outputs must exit or commit fails
+ context.commit();
+ }
+
+ @Test
+ public void testStateSerializationUseTCCL() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+
+ TestBuildContext context = newBuildContext();
+
+ URL dummyJar = new File("src/test/projects/dummy/dummy-1.0.jar").toURI().toURL();
+ ClassLoader tccl = new URLClassLoader(new URL[] {dummyJar});
+ ClassLoader origTCCL = Thread.currentThread().getContextClassLoader();
+ try {
+ Thread.currentThread().setContextClassLoader(tccl);
+
+ Object dummy = tccl.loadClass("dummy.Dummy").newInstance();
+
+ DefaultResource input = context.registerInput(inputFile).process();
+ context.setAttribute(input, "dummy", (Serializable) dummy);
+ context.commit();
+
+ context = newBuildContext();
+ assertFalse(context.isEscalated());
+ assertNotNull(context.getAttribute(context.registerInput(inputFile), "dummy", Serializable.class));
+ // no commit
+ } finally {
+ Thread.currentThread().setContextClassLoader(origTCCL);
+ }
+
+ // sanity check, make sure empty state is loaded without proper TCCL
+ context = newBuildContext();
+ assertTrue(context.isEscalated());
+ }
+
+ @Test
+ public void testConfigurationChange() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("input"));
+ Path outputFile = Files.createFile(temp.resolve("output"));
+ Path looseOutputFile = Files.createFile(temp.resolve("looseOutputFile"));
+
+ TestBuildContext context = newBuildContext();
+ context.registerInput(inputFile).process().associateOutput(outputFile);
+ context.processOutput(looseOutputFile);
+ context.commit();
+
+ context = newBuildContext(Collections.singletonMap("config", "parameter"));
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ assertEquals(Status.MODIFIED, metadata.getStatus());
+ DefaultInput input = metadata.process();
+ assertEquals(Status.MODIFIED, input.getStatus());
+ DefaultOutput output = input.associateOutput(outputFile);
+ assertEquals(Status.MODIFIED, output.getStatus());
+ DefaultOutput looseOutput = context.processOutput(looseOutputFile);
+ assertEquals(Status.MODIFIED, looseOutput.getStatus());
+ }
+
+ @Test
+ public void testRegisterInputsIncludesExcludes() throws Exception {
+ Files.createDirectory(temp.resolve("folder"));
+ Path f1 = Files.createFile(temp.resolve("input1.txt"));
+ Path f2 = Files.createFile(temp.resolve("folder/input2.txt"));
+ Path f3 = Files.createFile(temp.resolve("folder/input3.log"));
+
+ TestBuildContext context = newBuildContext();
+ List actual;
+
+ actual = toFileList(context.registerInputs(temp, null, Collections.singletonList("**")));
+ assertIncludedPaths(Collections.emptyList(), actual);
+
+ actual = toFileList(context.registerInputs(temp, null, null));
+ assertIncludedPaths(Arrays.asList(f1, f2, f3), actual);
+
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("**/*.txt"), null));
+ assertIncludedPaths(Arrays.asList(f1, f2), actual);
+
+ actual = toFileList(
+ context.registerInputs(temp, Collections.singletonList("**"), Collections.singletonList("**/*.log")));
+ assertIncludedPaths(Arrays.asList(f1, f2), actual);
+ }
+
+ @Test
+ public void testRegisterInputsDirectoryMatching() throws Exception {
+ Files.createDirectory(temp.resolve("folder"));
+ Files.createDirectory(temp.resolve("folder/subfolder"));
+ Path f1 = Files.createFile(temp.resolve("input1.txt"));
+ Path f2 = Files.createFile(temp.resolve("folder/input2.txt"));
+ Path f3 = Files.createFile(temp.resolve("folder/subfolder/input3.txt"));
+
+ TestBuildContext context = newBuildContext();
+ List actual;
+
+ // from http://ant.apache.org/manual/dirtasks.html#patterns
+ // When ** is used as the name of a directory in the pattern, it matches zero or more
+ // directories.
+
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("**/*.txt"), null));
+ assertIncludedPaths(Arrays.asList(f1, f2, f3), actual);
+
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("folder/**/*.txt"), null));
+ assertIncludedPaths(Arrays.asList(f2, f3), actual);
+
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("folder/*.txt"), null));
+ assertIncludedPaths(Collections.singletonList(f2), actual);
+
+ // / is a shortcut for /**
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("/"), null));
+ assertIncludedPaths(Arrays.asList(f1, f2, f3), actual);
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("folder/"), null));
+ assertIncludedPaths(Arrays.asList(f2, f3), actual);
+
+ // leading / does not matter
+ actual = toFileList(context.registerInputs(temp, Collections.singletonList("/folder/"), null));
+ assertIncludedPaths(Arrays.asList(f2, f3), actual);
+ }
+
+ private List toFileList(Iterable extends DefaultMetadata> inputs) {
+ List files = new ArrayList<>();
+ for (DefaultMetadata input : inputs) {
+ files.add(input.getPath());
+ }
+ return files;
+ }
+
+ @Test
+ public void testClosedContext() throws Exception {
+ TestBuildContext context = newBuildContext();
+
+ context.commit();
+ assertThrows(IllegalStateException.class, () -> context.registerInput(Files.createTempFile(temp, "", "")));
+ assertThrows(IllegalStateException.class, () -> context.processOutput(Files.createTempFile(temp, "", "")));
+ }
+
+ @Test
+ public void testSkipExecution() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInput input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile);
+ context.commit();
+
+ // make a change
+ Files.write(inputFile, Collections.singletonList("test"), StandardOpenOption.APPEND);
+
+ // skip execution
+ context = newBuildContext();
+ context.markSkipExecution();
+ context.commit();
+ assertTrue(Files.isReadable(outputFile));
+
+ //
+ context = newBuildContext();
+ DefaultInputMetadata inputMetadata = context.registerInput(inputFile);
+ assertEquals(Status.MODIFIED, inputMetadata.getStatus());
+ inputMetadata.process();
+ context.commit();
+ assertFalse(Files.isReadable(outputFile));
+ }
+
+ @Test
+ public void testSkipExecutionModifiedContext() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+ Path outputFile = Files.createFile(temp.resolve("outputFile"));
+
+ TestBuildContext context = newBuildContext();
+ DefaultInput input = context.registerInput(inputFile).process();
+ input.associateOutput(outputFile);
+
+ assertThrows(IllegalStateException.class, context::markSkipExecution);
+ }
+
+ static Stream fileSystems() {
+ return Stream.of(
+ Arguments.of("Windows", (Supplier) () -> Jimfs.newFileSystem(Configuration.windows())),
+ Arguments.of("Unix", (Supplier) () -> Jimfs.newFileSystem(Configuration.unix())),
+ Arguments.of("MacOS", (Supplier) () -> Jimfs.newFileSystem(Configuration.osX())),
+ Arguments.of("Native", (Supplier) FileSystems::getDefault));
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java
new file mode 100644
index 000000000000..d59e54a8e42a
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DefaultOutputTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+
+import org.apache.maven.api.build.context.Output;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class DefaultOutputTest {
+
+ /** Temporary directory for tests. */
+ @TempDir
+ private Path temp;
+
+ private TestBuildContext newBuildContext() {
+ Path stateFile = temp.resolve("buildstate.ctx");
+ return new TestBuildContext(stateFile, Collections.emptyMap());
+ }
+
+ @Test
+ public void testOutputStreamCreateParentDirectories() throws Exception {
+ Path outputFile = temp.resolve("sub/dir/outputFile");
+
+ TestBuildContext context = newBuildContext();
+ Output output = context.processOutput(outputFile);
+ output.newOutputStream().close();
+
+ assertTrue(Files.isReadable(outputFile));
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java
new file mode 100644
index 000000000000..965ece5ef361
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/DeltaWorkspaceTest.java
@@ -0,0 +1,289 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.BufferedOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.attribute.FileTime;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+
+import org.apache.maven.api.build.context.BuildContextException;
+import org.apache.maven.api.build.context.Metadata;
+import org.apache.maven.api.build.context.Status;
+import org.apache.maven.api.build.context.spi.FileState;
+import org.apache.maven.api.build.context.spi.Workspace;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class DeltaWorkspaceTest extends AbstractBuildContextTest {
+
+ public static void touch(Path path) throws InterruptedException {
+ if (!Files.isRegularFile(path)) {
+ throw new IllegalArgumentException("Not a file " + path);
+ } else {
+ File file = path.toFile();
+ long lastModified = file.lastModified();
+ file.setLastModified(System.currentTimeMillis());
+ if (lastModified == file.lastModified()) {
+ Thread.sleep(1000L);
+ file.setLastModified(System.currentTimeMillis());
+ }
+ }
+ }
+
+ @Test
+ public void testGetRegisteredInputs() throws Exception {
+ DeltaWorkspace workspace;
+ TestBuildContext ctx;
+
+ // initial build
+ ctx = newBuildContext();
+ Path basedir = Files.createDirectory(temp.resolve("basedir"));
+ Path a = Files.createFile(temp.resolve("basedir/a"));
+ assertEquals(
+ 1, toList(ctx.registerAndProcessInputs(basedir, null, null)).size());
+ assertEquals(1, toList(ctx.getRegisteredInputs()).size());
+ ctx.commit();
+
+ // no change rebuild
+ workspace = new DeltaWorkspace();
+ ctx = newBuildContext(workspace);
+ assertEquals(
+ 1, toList(ctx.registerAndProcessInputs(basedir, null, null)).size());
+ assertEquals(1, toList(ctx.getRegisteredInputs()).size());
+ assertEquals(0, toList(ctx.getProcessedResources()).size());
+ ctx.commit();
+
+ // add input
+ workspace = new DeltaWorkspace();
+ Path b = Files.createFile(temp.resolve("basedir/b"));
+ workspace.added.add(DefaultBuildContext.normalize(b));
+ ctx = newBuildContext(workspace);
+ assertEquals(
+ 2, toList(ctx.registerAndProcessInputs(basedir, null, null)).size());
+ assertEquals(2, toList(ctx.getRegisteredInputs()).size());
+ assertEquals(1, toList(ctx.getProcessedResources()).size());
+ ctx.commit();
+
+ // modify input
+ workspace = new DeltaWorkspace();
+ workspace.modified.add(DefaultBuildContext.normalize(a));
+ ctx = newBuildContext(workspace);
+ assertEquals(
+ 2, toList(ctx.registerAndProcessInputs(basedir, null, null)).size());
+ assertEquals(2, toList(ctx.getRegisteredInputs()).size());
+ assertEquals(1, toList(ctx.getProcessedResources()).size());
+ ctx.commit();
+
+ // remove input
+ workspace = new DeltaWorkspace();
+ Files.delete(a);
+ workspace.removed.add(DefaultBuildContext.normalize(a));
+ ctx = newBuildContext(workspace);
+ assertEquals(
+ 2, toList(ctx.registerAndProcessInputs(basedir, null, null)).size());
+ assertEquals(2, toList(ctx.getRegisteredInputs()).size());
+ assertEquals(0, toList(ctx.getProcessedResources()).size());
+ ctx.commit();
+ }
+
+ @Test
+ public void testResourceStatus() throws Exception {
+ Path basedir = Files.createDirectory(temp.resolve("basedir"));
+
+ DeltaWorkspace workspace;
+ TestBuildContext ctx;
+
+ // initial build
+ newBuildContext().commit();
+
+ // new input
+ workspace = new DeltaWorkspace();
+ Path a = DefaultBuildContext.normalize(Files.createFile(temp.resolve("basedir/a")));
+ workspace.added.add(a);
+ ctx = newBuildContext(workspace);
+ Metadata input = only(ctx.registerInputs(basedir, null, null));
+ assertEquals(Status.NEW, input.getStatus());
+ input.process();
+ ctx.commit();
+
+ // no-change rebuild
+ workspace = new DeltaWorkspace();
+ ctx = newBuildContext(workspace);
+ input = only(ctx.registerInputs(basedir, null, null));
+ assertEquals(Status.UNMODIFIED, input.getStatus());
+ ctx.commit();
+
+ // modified input
+ workspace = new DeltaWorkspace();
+ touch(a);
+ workspace.modified.add(a);
+ ctx = newBuildContext(workspace);
+ input = only(ctx.registerInputs(basedir, null, null));
+ assertEquals(Status.MODIFIED, input.getStatus());
+ input.process();
+ ctx.commit();
+
+ // removed input
+ workspace = new DeltaWorkspace();
+ Files.delete(a);
+ workspace.removed.add(a);
+ ctx = newBuildContext(workspace);
+ assertEquals(1, toList(ctx.registerInputs(basedir, null, null)).size());
+ assertEquals(Status.REMOVED, ctx.getResourceStatus(a));
+ input.process();
+ ctx.commit();
+ }
+
+ @Test
+ public void testCarryOverAndCleanup() throws Exception {
+ Files.createDirectory(temp.resolve("basedir"));
+ Path inputdir = Files.createDirectory(temp.resolve("basedir/inputdir"));
+ Path outputdir = Files.createDirectory(temp.resolve("basedir/outputdir"));
+ Path file = Files.createFile(temp.resolve("basedir/inputdir/file.txt"));
+
+ DeltaWorkspace workspace;
+ TestBuildContext ctx;
+ Collection extends DefaultInput> inputs;
+
+ // initial build
+ ctx = newBuildContext();
+ inputs = ctx.registerAndProcessInputs(inputdir, null, null);
+ assertEquals(1, inputs.size());
+ inputs.iterator().next().associateOutput(Files.createFile(temp.resolve("basedir/outputdir/file.out")));
+ ctx.commit();
+
+ // no-change rebuild
+ workspace = new DeltaWorkspace();
+ ctx = newBuildContext(workspace);
+ ctx.registerAndProcessInputs(inputdir, null, null);
+ ctx.commit();
+ assertTrue(Files.exists(outputdir.resolve("file.out")));
+
+ // "delete" input
+ workspace = new DeltaWorkspace();
+ workspace.removed.add(DefaultBuildContext.normalize(file));
+ ctx = newBuildContext(workspace);
+ ctx.registerAndProcessInputs(inputdir, null, null);
+ ctx.commit();
+ assertFalse(Files.exists(outputdir.resolve("file.out")));
+ }
+
+ private T only(Iterable values) {
+ List list = toList(values);
+ assertEquals(1, list.size());
+ return list.get(0);
+ }
+
+ @SuppressWarnings("checkstyle:VisibilityModifier")
+ private static class DeltaWorkspace implements Workspace {
+
+ final Set added = new HashSet<>();
+ final Set modified = new HashSet<>();
+ final Set removed = new HashSet<>();
+
+ @Override
+ public Mode getMode() {
+ return Mode.DELTA;
+ }
+
+ @Override
+ public Workspace escalate() {
+ return new FilesystemWorkspace() {
+ @Override
+ public Mode getMode() {
+ return Mode.ESCALATED;
+ }
+ };
+ }
+
+ @Override
+ public boolean isPresent(Path file) {
+ return Files.exists(file);
+ }
+
+ @Override
+ public boolean isRegularFile(Path file) {
+ return Files.isRegularFile(file);
+ }
+
+ @Override
+ public boolean isDirectory(Path file) {
+ return Files.isDirectory(file);
+ }
+
+ @Override
+ public void deleteFile(Path file) {
+ try {
+ if (Files.exists(file)) {
+ Files.delete(file);
+ }
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public void processOutput(Path path) {}
+
+ @Override
+ public OutputStream newOutputStream(Path path) {
+ try {
+ return new BufferedOutputStream(Files.newOutputStream(path));
+ } catch (IOException e) {
+ throw new BuildContextException(e);
+ }
+ }
+
+ @Override
+ public Status getResourceStatus(Path file, FileTime lastModified, long size) {
+ // delta workspace returns resource status compared to the previous build
+ if (added.contains(file)) {
+ return Status.NEW;
+ }
+ if (modified.contains(file)) {
+ return Status.MODIFIED;
+ }
+ if (removed.contains(file)) {
+ return Status.REMOVED;
+ }
+ return Status.UNMODIFIED;
+ }
+
+ @Override
+ public Stream walk(Path basedir) {
+ return Stream.concat(
+ Stream.concat(
+ added.stream().map(p -> new FileState(p, Status.NEW)),
+ modified.stream().map(p -> new FileState(p, Status.MODIFIED))),
+ removed.stream().map(p -> new FileState(p, Status.REMOVED)));
+ }
+ }
+}
diff --git a/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java
new file mode 100644
index 000000000000..a8b14ebc2213
--- /dev/null
+++ b/impl/maven-impl/src/test/java/org/apache/maven/internal/build/context/impl/ResourceMessagesTest.java
@@ -0,0 +1,167 @@
+/*
+ * 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.internal.build.context.impl;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.maven.api.build.context.Severity;
+import org.apache.maven.api.build.context.spi.Message;
+import org.apache.maven.api.build.context.spi.Sink;
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ResourceMessagesTest {
+ /** Temporary directory for tests. */
+ @TempDir
+ private Path temp;
+
+ private TestBuildContext newBuildContext() throws IOException {
+ return new TestBuildContext();
+ }
+
+ @Test
+ public void testInputMessages() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+
+ // initial message
+ TestBuildContext context = newBuildContext();
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ inputFile = metadata.getPath();
+ DefaultInput input = metadata.process();
+ input.addMessage(0, 0, "message", Severity.WARNING, null);
+ context.commit();
+
+ // the message is retained during no-change rebuild
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ List messages = context.getMessages(inputFile);
+ assertEquals(1, messages.size());
+ assertEquals("message", messages.get(0).getMessage());
+ context.commit();
+
+ // the message is retained during second no-change rebuild
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ messages = context.getMessages(inputFile);
+ assertEquals(1, messages.size());
+ assertEquals("message", messages.get(0).getMessage());
+ context.commit();
+
+ // new message
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ input = metadata.process();
+ input.addMessage(0, 0, "newMessage", Severity.WARNING, null);
+ context.commit();
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ messages = context.getMessages(inputFile);
+ assertEquals(1, messages.size());
+ assertEquals("newMessage", messages.get(0).getMessage());
+ context.commit();
+
+ // removed message
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ input = metadata.process();
+ context.commit();
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ assertNull(context.getMessages(inputFile));
+ context.commit();
+ }
+
+ @Test
+ public void testInputMessagesNullMessageText() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+
+ // initial message
+ TestBuildContext context = newBuildContext();
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ inputFile = metadata.getPath();
+ DefaultInput input = metadata.process();
+ input.addMessage(0, 0, null, Severity.WARNING, null);
+ context.commit();
+
+ context = newBuildContext();
+ metadata = context.registerInput(inputFile);
+ List messages = context.getMessages(inputFile);
+ assertEquals(1, messages.size());
+ assertNull(messages.get(0).getMessage());
+ context.commit();
+ }
+
+ @Test
+ public void testExcludedInputMessageCleanup() throws Exception {
+ Path inputFile = Files.createFile(temp.resolve("inputFile"));
+
+ // initial message
+ TestBuildContext context = newBuildContext();
+ DefaultInputMetadata metadata = context.registerInput(inputFile);
+ inputFile = metadata.getPath();
+ DefaultInput input = metadata.process();
+ input.addMessage(0, 0, "message", Severity.WARNING, null);
+ context.commit();
+
+ // input is removed from input set, make sure input messages are cleaned up
+ final List cleared = new ArrayList<>();
+ newBuildContext().commit(new Sink() {
+ @Override
+ public void messages(Path resource, boolean isNew, Collection messages) {
+ assertTrue(messages.isEmpty());
+ }
+
+ public void clear(Path resource) {
+ cleared.add(resource);
+ }
+ });
+
+ assertEquals(1, cleared.size());
+ assertEquals(inputFile, cleared.get(0));
+ }
+
+ private class TestBuildContext extends DefaultBuildContext {
+ protected TestBuildContext() throws IOException {
+ super(new FilesystemWorkspace(), temp.resolve("buildstate.ctx"), Collections.emptyMap(), null);
+ }
+
+ public void commit() throws IOException {
+ super.commit(null);
+ }
+
+ public List