Repository: vackosar/gitflow-incremental-builder (java)

Issue:
Support BOM incremental changes
This may be a crazy idea, but I'd love to see it implemented: 

If a BOM is used (a managed dependency with `<type>pom</type>` and `<scope>import</scope>`) in a project and a dependency version included in this BOM is changed, it would be nice if GIB could build only the modules that depend on the changed dependency.

Pull request description:
Basic BOM support
Resolves #241 (basic part without any semantic analysis _which_ dependency versions changed).

template.zip now contains a "quarkus-scenario" (in branch `quarkus`) with some setup aspects of [Quarkus](https://github.com/quarkusio/quarkus).

/cc @mickroll @gastaldi 

Diff for `template.zip` (some minor adjustments in `build-parent/*` omitted):
```diff
diff --git a/quarkus-scenario/README.txt b/quarkus-scenario/README.txt
new file mode 100644
index 0000000..3e94c44
--- /dev/null
+++ b/quarkus-scenario/README.txt
@@ -0,0 +1,7 @@
+This branch/tree tries to mimic some repo setup aspects of Quarkus (https://github.com/quarkusio/quarkus), e.g.:
+- some modules do not inherit from the root parent ("independent-projects" in the real Quarkus repo)
+- usage of a BOM module that is imported by many other modules
+
+References:
+- https://github.com/vackosar/gitflow-incremental-builder/pull/239
+- https://github.com/vackosar/gitflow-incremental-builder/issues/241
\ No newline at end of file
diff --git a/quarkus-scenario/bom/pom.xml b/quarkus-scenario/bom/pom.xml
new file mode 100644
index 0000000..2ce1ef3
--- /dev/null
+++ b/quarkus-scenario/bom/pom.xml
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.acme</groupId>
+        <artifactId>parent</artifactId>
+        <version>999-SNAPSHOT</version>
+    </parent>
+
+    <groupId>org.acme</groupId>
+    <artifactId>bom</artifactId>
+    <packaging>pom</packaging>
+    <dependencyManagement>
+        <dependencies>
+            <dependency>    <!-- just some example -->
+                <groupId>org.eclipse.jgit</groupId>
+                <artifactId>org.eclipse.jgit</artifactId>
+                <version>${version.jgit}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+</project>
diff --git a/quarkus-scenario/child1/pom.xml b/quarkus-scenario/child1/pom.xml
new file mode 100644
index 0000000..048a9da
--- /dev/null
+++ b/quarkus-scenario/child1/pom.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>build-parent</groupId>
+        <artifactId>build-parent</artifactId>
+        <version>1.0-SNAPSHOT</version>
+        <relativePath>../../build-parent/pom.xml</relativePath>
+    </parent>
+
+    <groupId>org.acme</groupId>
+    <artifactId>child1</artifactId>
+    <version>999-SNAPSHOT</version>
+    <packaging>jar</packaging>
+</project>
diff --git a/quarkus-scenario/child2/pom.xml b/quarkus-scenario/child2/pom.xml
new file mode 100644
index 0000000..69e45fb
--- /dev/null
+++ b/quarkus-scenario/child2/pom.xml
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>org.acme</groupId>
+        <artifactId>parent</artifactId>
+        <version>999-SNAPSHOT</version>
+    </parent>
+
+    <groupId>org.acme</groupId>
+    <artifactId>child2</artifactId>
+    <packaging>jar</packaging>
+
+    <dependencyManagement>
+        <dependencies>
+            <dependency>
+                <groupId>org.acme</groupId>
+                <artifactId>bom</artifactId>
+                <version>${project.version}</version>
+                <scope>import</scope>
+                <type>pom</type>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.acme</groupId>
+            <artifactId>child1</artifactId>
+            <version>999-SNAPSHOT</version>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/quarkus-scenario/pom.xml b/quarkus-scenario/pom.xml
new file mode 100644
index 0000000..37f7d93
--- /dev/null
+++ b/quarkus-scenario/pom.xml
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>build-parent</groupId>
+        <artifactId>build-parent</artifactId>
+        <version>1.0-SNAPSHOT</version>
+        <relativePath>../build-parent/pom.xml</relativePath>
+    </parent>
+
+    <groupId>org.acme</groupId>
+    <artifactId>parent</artifactId>
+    <version>999-SNAPSHOT</version>
+    <packaging>pom</packaging>
+
+    <modules>
+        <module>bom</module>
+        <module>child1</module>
+        <module>child2</module>
+    </modules>
+</project>
```

Patch:
diff --git a/src/main/java/com/vackosar/gitflowincrementalbuild/boundary/UnchangedProjectsRemover.java b/src/main/java/com/vackosar/gitflowincrementalbuild/boundary/UnchangedProjectsRemover.java
index 1a6b317..3092666 100644
--- a/src/main/java/com/vackosar/gitflowincrementalbuild/boundary/UnchangedProjectsRemover.java
+++ b/src/main/java/com/vackosar/gitflowincrementalbuild/boundary/UnchangedProjectsRemover.java
@@ -3,9 +3,17 @@ package com.vackosar.gitflowincrementalbuild.boundary;
 import com.vackosar.gitflowincrementalbuild.boundary.Configuration.BuildUpstreamMode;
 import com.vackosar.gitflowincrementalbuild.control.ChangedProjects;
 
+import org.apache.maven.artifact.Artifact;
 import org.apache.maven.execution.MavenExecutionRequest;
 import org.apache.maven.execution.MavenSession;
+import org.apache.maven.model.Dependency;
+import org.apache.maven.model.Model;
+import org.apache.maven.plugin.MojoExecution;
+import org.apache.maven.plugin.PluginParameterExpressionEvaluator;
+import org.apache.maven.plugin.descriptor.MojoDescriptor;
 import org.apache.maven.project.MavenProject;
+import org.codehaus.plexus.component.configurator.expression.ExpressionEvaluationException;
+import org.codehaus.plexus.component.configurator.expression.TypeAwareExpressionEvaluator;
 import org.eclipse.jgit.api.errors.GitAPIException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -33,12 +41,24 @@ class UnchangedProjectsRemover {
     private static final String MAVEN_TEST_SKIP_EXEC = "skipTests";
     private static final String TEST_JAR_DETECTED = "Dependency with test-jar goal detected. Will compile test sources.";
     private static final String GOAL_TEST_JAR = "test-jar";
+    private static final String PCKG_POM = "pom";
 
     private Logger logger = LoggerFactory.getLogger(UnchangedProjectsRemover.class);
 
     @Inject private ChangedProjects changedProjects;
 
+    private final Map<MavenProject, Set<MavenProject>> downstreamCache = new HashMap<>();
+
     void act(Configuration config) throws GitAPIException, IOException {
+        try {
+            doAct(config);
+        } finally {
+            // don't be a memory hog
+            downstreamCache.clear();
+        }
+    }
+
+    private void doAct(Configuration config) throws GitAPIException, IOException {
         // ensure to write logfile for impaced (even if just empty)
         config.logImpactedTo.ifPresent(logFilePath -> writeImpactedLogFile(Collections.emptySet(), logFilePath, config.mavenSession));
 
@@ -50,6 +70,7 @@ class UnchangedProjectsRemover {
                     config.mavenSession.getProjects().stream().map(MavenProject::getArtifactId).collect(Collectors.joining(", ")));
             return;
         }
+
         // do nothing if:
         // - building non-recursively (-N)
         // - or only one "leaf" project/module is present (cases: mvn -f ... or cd ... or unusual case of non-multi-module project)
@@ -258,10 +279,45 @@ class UnchangedProjectsRemover {
     }
 
     private Stream<MavenProject> streamProjectWithDownstreamProjects(MavenProject project, Configuration config) {
-        return Stream.concat(
-            Stream.of(project),
-            config.mavenSession.getProjectDependencyGraph().getDownstreamProjects(project, true).stream()
-                    .filter(proj -> !config.excludeDownstreamModulesPackagedAs.contains(proj.getPackaging())));
+        // note: not using computeIfAbsent() here since it would break recursive invocations with ConcurrentModificationException
+        Set<MavenProject> downstream = downstreamCache.get(project);
+        if (downstream == null) {
+            downstream = Stream
+                    .concat(Stream.of(project),
+                            config.mavenSession.getProjectDependencyGraph().getDownstreamProjects(project, true)
+                                    .stream().filter(proj -> isDownstreamModuleNotExcluded(proj, config)))
+                    .collect(Collectors.toCollection(LinkedHashSet::new));
+            if (PCKG_POM.equals(project.getPackaging())) {    // performance hint: bomArtifactIdRegex or similar could speed things up
+                downstream.addAll(findBOMDownstreamProjects(project, downstream, config));
+            }
+            downstreamCache.put(project, downstream);
+        }
+        return downstream.stream();
+    }
+
+    private Set<MavenProject> findBOMDownstreamProjects(MavenProject potentialBOMProject, Set<MavenProject> downstream, Configuration config) {
+        return config.mavenSession.getProjects().stream()
+                .filter(proj -> !downstream.contains(proj)) // optimization
+                .filter(proj -> isDownstreamModuleNotExcluded(proj, config))
+                .filter(proj -> Optional.ofNullable(proj.getOriginalModel())    // >original< model is crucial since BOM deps are gone in effective model
+                        .map(Model::getDependencyManagement)
+                        .map(depMgtm -> depMgtm.getDependencies().stream().anyMatch(dep -> isBOMImport(dep, potentialBOMProject, proj, config)))
+                        .orElse(false))
+                .flatMap(proj -> streamProjectWithDownstreamProjects(proj, config)) // (indirect) recursion!
+                .collect(Collectors.toCollection(LinkedHashSet::new));
+    }
+
+    private boolean isDownstreamModuleNotExcluded(MavenProject proj, Configuration config) {
+        return !config.excludeDownstreamModulesPackagedAs.contains(proj.getPackaging());
+    }
+
+    private boolean isBOMImport(Dependency dependency, MavenProject potentialBOMProject, MavenProject depDefiningProject, Configuration config) {
+        LazyExpressionEvaluator evaluator = new LazyExpressionEvaluator(config.mavenSession, depDefiningProject);
+        return Objects.equals(evaluator.evaluate(dependency.getType()), PCKG_POM)
+                && Objects.equals(evaluator.evaluate(dependency.getScope()), Artifact.SCOPE_IMPORT)
+                && Objects.equals(evaluator.evaluate(dependency.getArtifactId()), potentialBOMProject.getArtifactId())
+                && Objects.equals(evaluator.evaluate(dependency.getGroupId()), potentialBOMProject.getGroupId())
+                && Objects.equals(evaluator.evaluate(dependency.getVersion()), potentialBOMProject.getVersion());
     }
 
     private Stream<MavenProject> streamUpstreamProjects(MavenProject project, MavenSession mavenSession) {
@@ -309,4 +365,39 @@ class UnchangedProjectsRemover {
             return false;
         }
     }
+
+    private static class LazyExpressionEvaluator {
+
+        private static final Logger LOGGER = LoggerFactory.getLogger(LazyExpressionEvaluator.class);
+
+        private final MavenSession session;
+        private final MavenProject project;
+
+        private TypeAwareExpressionEvaluator evaluator;
+
+        public LazyExpressionEvaluator(MavenSession session, MavenProject project) {
+            this.session = session;
+            this.project = project;
+        }
+
+        public String evaluate(String expression) {
+            if (expression == null || !expression.contains("${")) {
+                return expression;
+            }
+            if (evaluator == null) {
+                // set project on cloned session otherwise properties might be resolved from a more or less unrelated project
+                MavenSession clonedSession = session.clone();
+                clonedSession.setCurrentProject(project);
+                // there is also a ctor without MojoExecution parameter but evaluate() will then fail with a NPE (sic!)
+                // see also: https://issues.apache.org/jira/browse/MNG-6982
+                evaluator = new PluginParameterExpressionEvaluator(clonedSession, new MojoExecution(new MojoDescriptor()));
+            }
+            try {
+                return evaluator.evaluate(expression, String.class).toString();
+            } catch (ExpressionEvaluationException e) {
+                LOGGER.warn("Failed to evaluate: " + expression, e);
+                return expression;
+            }
+        }
+    }
 }


Test patch:
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/boundary/BaseUnchangedProjectsRemoverTest.java b/src/test/java/com/vackosar/gitflowincrementalbuild/boundary/BaseUnchangedProjectsRemoverTest.java
index 7be2347..aa71c5b 100644
--- a/src/test/java/com/vackosar/gitflowincrementalbuild/boundary/BaseUnchangedProjectsRemoverTest.java
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/boundary/BaseUnchangedProjectsRemoverTest.java
@@ -105,7 +105,9 @@ public abstract class BaseUnchangedProjectsRemoverTest {
     protected MavenProject addModuleMock(String moduleArtifactId, boolean addToChanged, final String packaging) {
         MavenProject newModuleMock = mock(MavenProject.class, withSettings().name(moduleArtifactId).lenient());
         allModuleMocks.add(newModuleMock);
+        when(newModuleMock.getGroupId()).thenReturn("com.vackosar.gitflowincrementalbuild");
         when(newModuleMock.getArtifactId()).thenReturn(moduleArtifactId);
+        when(newModuleMock.getVersion()).thenReturn("0.0.1");
         when(newModuleMock.getBasedir()).thenReturn(new File(moduleArtifactId));
         when(newModuleMock.getPackaging()).thenReturn(packaging);
         if (addToChanged) {
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/boundary/UnchangedProjectsRemoverBOMSupportTest.java b/src/test/java/com/vackosar/gitflowincrementalbuild/boundary/UnchangedProjectsRemoverBOMSupportTest.java
new file mode 100644
index 0000000..7f7aa96
--- /dev/null
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/boundary/UnchangedProjectsRemoverBOMSupportTest.java
@@ -0,0 +1,187 @@
+package com.vackosar.gitflowincrementalbuild.boundary;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Properties;
+
+import org.apache.commons.lang3.mutable.MutableObject;
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.execution.MavenSession;
+import org.apache.maven.model.Dependency;
+import org.apache.maven.model.DependencyManagement;
+import org.apache.maven.model.Model;
+import org.apache.maven.project.MavenProject;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.junit.jupiter.api.Test;
+import org.mockito.AdditionalAnswers;
+import org.mockito.Mockito;
+
+/**
+ * Tests {@link UnchangedProjectsRemover} with Mockito mocks in context of BOMs (bill of material).
+ *
+ * @author famod
+ */
+public class UnchangedProjectsRemoverBOMSupportTest extends BaseUnchangedProjectsRemoverTest {
+
+    @Test
+    public void bomChanged_noImport() throws GitAPIException, IOException {
+        MavenProject changedBOMModuleMock = addModuleMock(AID_MODULE_B, true, "pom");
+
+        addModuleMock(AID_MODULE_C, false);
+
+        underTest.act(config());
+
+        verify(mavenSessionMock).setProjects(Collections.singletonList(changedBOMModuleMock));
+    }
+
+    @Test
+    public void bomChanged_noImport_otherBOMVersion() throws GitAPIException, IOException {
+        MavenProject changedBOMModuleMock = addModuleMock(AID_MODULE_B, true, "pom");
+
+        MavenProject unchangedModuleMock = addModuleMock(AID_MODULE_C, false);
+
+        installOrigModelWithDepMgmt(unchangedModuleMock)
+                .addDependency(buildBOMDependency(changedBOMModuleMock, "9.9.9"));
+
+        underTest.act(config());
+
+        verify(mavenSessionMock).setProjects(Collections.singletonList(changedBOMModuleMock));
+    }
+
+    @Test
+    public void bomChanged_noImport_nonBOMModuleChangedAsWell() throws GitAPIException, IOException {
+        MavenProject changedBOMModuleMock = addModuleMock(AID_MODULE_B, true, "pom");
+
+        MavenProject changedModuleMock= addModuleMock(AID_MODULE_C, true);
+
+        underTest.act(config());
+
+        verify(mavenSessionMock).setProjects(Arrays.asList(changedBOMModuleMock, changedModuleMock));
+    }
+
+    @Test
+    public void bomChanged_oneImport() throws GitAPIException, IOException {
+        MavenProject changedBOMModuleMock = addModuleMock(AID_MODULE_B, true, "pom");
+
+        MavenProject unchangedModuleMock = addModuleMock(AID_MODULE_C, false);
+
+        installOrigModelWithDepMgmt(unchangedModuleMock)
+                .addDependency(buildBOMDependency(changedBOMModuleMock));
+
+        underTest.act(config());
+
+        verify(mavenSessionMock).setProjects(Arrays.asList(changedBOMModuleMock, unchangedModuleMock));
+    }
+
+    @Test
+    public void bomChanged_oneImport_versionProperty() throws GitAPIException, IOException {
+        MavenProject changedBOMModuleMock = addModuleMock(AID_MODULE_B, true, "pom");
+
+        MavenProject unchangedModuleMock = addModuleMock(AID_MODULE_C, false);
+
+        installOrigModelWithDepMgmt(unchangedModuleMock)
+                .addDependency(buildBOMDependency(changedBOMModuleMock, "${project.version}"));
+
+        // mock MavenSession.clone() (for LazyExpressionEvaluator) and have the clone delegate to the original session + mimic setCurrentProject()
+        MavenSession sessionCloneMock = mock(MavenSession.class, AdditionalAnswers.delegatesTo(mavenSessionMock));
+        MutableObject<MavenProject> currentProjectSetForClone = new MutableObject<>();
+        doAnswer(invocation -> {
+            currentProjectSetForClone.setValue(invocation.getArgument(0));
+            return null;
+        }).when(sessionCloneMock).setCurrentProject(any(MavenProject.class));
+        doAnswer(i -> currentProjectSetForClone.getValue()).when(sessionCloneMock).getCurrentProject();
+        when(mavenSessionMock.clone()).thenReturn(sessionCloneMock);
+
+        // see constructor PluginParameterExpressionEvaluator
+        when(mavenSessionMock.getSystemProperties()).thenReturn(new Properties());
+        when(mavenSessionMock.getUserProperties()).thenReturn(new Properties());
+
+        underTest.act(config());
+
+        verify(mavenSessionMock).setProjects(Arrays.asList(changedBOMModuleMock, unchangedModuleMock));
+        verify(sessionCloneMock).getCurrentProject();
+    }
+
+    @Test
+    public void bomChanged_oneImport_oneWithout() throws GitAPIException, IOException {
+        MavenProject changedBOMModuleMock = addModuleMock(AID_MODULE_B, true, "pom");
+
+        addModuleMock(AID_MODULE_C, false);
+
+        MavenProject unchangedModuleMock2 = addModuleMock(AID_MODULE_D, false);
+
+        installOrigModelWithDepMgmt(unchangedModuleMock2)
+                .addDependency(buildBOMDependency(changedBOMModuleMock));
+
+        underTest.act(config());
+
+        verify(mavenSessionMock).setProjects(Arrays.asList(changedBOMModuleMock, unchangedModuleMock2));
+    }
+
+    @Test
+    public void bomChanged_twoImports() throws GitAPIException, IOException {
+        MavenProject changedBOMModuleMock = addModuleMock(AID_MODULE_B, true, "pom");
+
+        MavenProject unchangedModuleMock1 = addModuleMock(AID_MODULE_C, false);
+
+        installOrigModelWithDepMgmt(unchangedModuleMock1)
+                .addDependency(buildBOMDependency(changedBOMModuleMock));
+
+        MavenProject unchangedModuleMock2 = addModuleMock(AID_MODULE_D, false);
+
+        installOrigModelWithDepMgmt(unchangedModuleMock2)
+                .addDependency(buildBOMDependency(changedBOMModuleMock));
+
+        underTest.act(config());
+
+        verify(mavenSessionMock).setProjects(Arrays.asList(changedBOMModuleMock, unchangedModuleMock1, unchangedModuleMock2));
+    }
+
+    @Test
+    public void bomChanged_twoImports_twoBOMs() throws GitAPIException, IOException {
+        MavenProject unchangedBOMModuleMock = addModuleMock(AID_MODULE_B, false, "pom");
+        MavenProject changedBOMModuleMock = addModuleMock(AID_MODULE_C, true, "pom");
+
+        MavenProject unchangedModuleMock1 = addModuleMock(AID_MODULE_D, false);
+
+        installOrigModelWithDepMgmt(unchangedModuleMock1)
+                .addDependency(buildBOMDependency(unchangedBOMModuleMock));
+
+        MavenProject unchangedModuleMock2 = addModuleMock(AID_MODULE_E, false);
+
+        installOrigModelWithDepMgmt(unchangedModuleMock2)
+                .addDependency(buildBOMDependency(changedBOMModuleMock));
+
+        underTest.act(config());
+
+        verify(mavenSessionMock).setProjects(Arrays.asList(changedBOMModuleMock, unchangedModuleMock2));
+    }
+
+    private DependencyManagement installOrigModelWithDepMgmt(MavenProject moduleMock) {
+        Model origModel = new Model();
+        DependencyManagement depMgmt = new DependencyManagement();
+        origModel.setDependencyManagement(depMgmt);
+        Mockito.when(moduleMock.getOriginalModel()).thenReturn(origModel);
+        return depMgmt;
+    }
+
+    private Dependency buildBOMDependency(MavenProject bomModuleMock) {
+        return buildBOMDependency(bomModuleMock, bomModuleMock.getVersion());
+    }
+
+    private Dependency buildBOMDependency(MavenProject bomModuleMock, String version) {
+        Dependency dep = new Dependency();
+        dep.setGroupId(bomModuleMock.getGroupId());
+        dep.setArtifactId(bomModuleMock.getArtifactId());
+        dep.setVersion(version);
+        dep.setScope(Artifact.SCOPE_IMPORT);
+        dep.setType("pom");
+        return dep;
+    }
+}
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/integration/MavenIntegrationTestBase.java b/src/test/java/com/vackosar/gitflowincrementalbuild/integration/MavenIntegrationTestBase.java
index d126205..d9aa89e 100644
--- a/src/test/java/com/vackosar/gitflowincrementalbuild/integration/MavenIntegrationTestBase.java
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/integration/MavenIntegrationTestBase.java
@@ -29,6 +29,7 @@ import java.net.URL;
 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.Collections;
@@ -55,9 +56,10 @@ public abstract class MavenIntegrationTestBase extends BaseRepoTest {
 
     private static final Logger LOGGER = LoggerFactory.getLogger(MavenIntegrationTestBase.class);
 
-    private static final Set<Class<?>> INITIAL_INSTALL_DONE = new HashSet<>();
+    private static final Set<String> INITIAL_INSTALL_DONE = new HashSet<>();
 
     private static final String DEFAULT_POMFILE_ARG = "--file=parent/pom.xml";
+    private static final String QUARKUS_POMFILE_ARG = "--file=quarkus-scenario/pom.xml";
 
     private static final Pattern LOG_LINE_FILTER_PATTERN = Pattern.compile("^\\[.*INFO.*\\] Download(ing|ed) from local.central: .*");
 
@@ -106,18 +108,26 @@ public abstract class MavenIntegrationTestBase extends BaseRepoTest {
      * @throws URISyntaxException on classptah lookup problems
      */
     @BeforeEach
-    void initialInstall(TestInfo testInfo) throws IOException, InterruptedException, URISyntaxException {
+    void initialInstall(TestInfo testInfo) throws Exception {
         testDisplayName = testInfo.getDisplayName();
+        String disableGib = prop(Property.disable, "true");
         Class<?> testClass = testInfo.getTestClass().get();
-        if (INITIAL_INSTALL_DONE.contains(testClass)) {
-            return;
+        String cacheKey = testClass.getName();
+        if (!INITIAL_INSTALL_DONE.contains(cacheKey)) {
+            copyCommonBuildParentPom();
+            executeBuild(true, false, "--file=build-parent/pom-common.xml", disableGib);
+            executeBuild(true, false, "--file=build-parent/pom.xml", disableGib);
+            executeBuild(true, false, DEFAULT_POMFILE_ARG, disableGib);
+            INITIAL_INSTALL_DONE.add(cacheKey);
+        }
+        if (testDisplayName.startsWith("quarkusScenario_")) {
+            checkout(Branch.QUARKUS);
+            cacheKey = cacheKey + "_quarkusScenario";
+            if (!INITIAL_INSTALL_DONE.contains(cacheKey)) {
+                executeBuild(true, false, QUARKUS_POMFILE_ARG, disableGib);
+                INITIAL_INSTALL_DONE.add(cacheKey);
+            }
         }
-        copyCommonBuildParentPom();
-        String disableGib = prop(Property.disable, "true");
-        executeBuild(true, false, "--file=build-parent/pom-common.xml", disableGib);
-        executeBuild(true, false, "--file=build-parent/pom.xml", disableGib);
-        executeBuild(true, false, DEFAULT_POMFILE_ARG, disableGib);
-        INITIAL_INSTALL_DONE.add(testClass);
     }
 
     private void copyCommonBuildParentPom() throws URISyntaxException, IOException {
@@ -191,7 +201,7 @@ public abstract class MavenIntegrationTestBase extends BaseRepoTest {
 
     @Test
     public void buildNoChanged() throws Exception {
-        checkoutDevelop();
+        checkout(Branch.DEVELOP);
 
         final String output = executeBuild(prop(Property.baseBranch, "refs/heads/develop"));
 
@@ -257,7 +267,7 @@ public abstract class MavenIntegrationTestBase extends BaseRepoTest {
 
     @Test
     public void buildWithSingleSelectedModule() throws Exception {
-        checkoutDevelop();
+        checkout(Branch.DEVELOP);
 
         final String output = executeBuild("-pl", "child2", prop(Property.disableBranchComparison, "true"));
 
@@ -277,7 +287,7 @@ public abstract class MavenIntegrationTestBase extends BaseRepoTest {
 
     @Test
     public void buildWithSingleLeafModule() throws Exception {
-        checkoutDevelop();
+        checkout(Branch.DEVELOP);
 
         final String output = executeBuild("-f", "parent/child3", prop(Property.disableBranchComparison, "true"));
 
@@ -297,7 +307,7 @@ public abstract class MavenIntegrationTestBase extends BaseRepoTest {
 
     @Test
     public void buildWithSingleSelectedModule_alsoMake() throws Exception {
-        checkoutDevelop();
+        checkout(Branch.DEVELOP);
 
         // tests that child6 upstream of child3 is built
         Files.write(repoPath.resolve("parent").resolve("child6").resolve("changed.xml"), new byte[0]);
@@ -321,7 +331,7 @@ public abstract class MavenIntegrationTestBase extends BaseRepoTest {
 
     @Test
     public void buildWithSingleSelectedModule_alsoMakeDependends() throws Exception {
-        checkoutDevelop();
+        checkout(Branch.DEVELOP);
 
         final String output = executeBuild("-pl", "child6", "-amd", prop(Property.disableBranchComparison, "true"));
 
@@ -338,11 +348,35 @@ public abstract class MavenIntegrationTestBase extends BaseRepoTest {
                 .doesNotContain(" testJarDependent");
     }
 
-    private void checkoutDevelop() throws GitAPIException, CheckoutConflictException, RefAlreadyExistsException,
+    @Test
+    public void quarkusScenario_noChanges() throws Exception {
+        final String output = executeBuild(quarkusScenarioProps());
+
+        assertThat(output).contains("Executing validate goal on current project only")
+                .contains(" parent")
+                .doesNotContain(" bom")
+                .doesNotContain(" child1")
+                .doesNotContain(" child2");
+    }
+
+    @Test
+    public void quarkusScenario_bomChanged() throws Exception {
+        Files.write(repoPath.resolve("quarkus-scenario").resolve("bom").resolve("pom.xml"), Arrays.asList("<!-- changed -->"), StandardOpenOption.APPEND);
+
+        final String output = executeBuild(quarkusScenarioProps());
+
+        assertThat(output)
+                .doesNotContain(" parent")
+                .contains(" bom")
+                .doesNotContain(" child1")
+                .contains(" child2");
+    }
+
+    private void checkout(Branch branch) throws GitAPIException, CheckoutConflictException, RefAlreadyExistsException,
             RefNotFoundException, InvalidRefNameException {
         Git git = localRepoMock.getGit();
         git.reset().setMode(ResetCommand.ResetType.HARD).setRef("HEAD").call();
-        git.checkout().setName("develop").call();
+        git.checkout().setName(branch.branchName).call();
     }
 
     protected String executeBuild(String... args) throws IOException, InterruptedException {
@@ -380,4 +414,25 @@ public abstract class MavenIntegrationTestBase extends BaseRepoTest {
         }
         return propString;
     }
+
+    protected static String[] quarkusScenarioProps(/* String... additional */) {
+        return new String[] {
+                QUARKUS_POMFILE_ARG,
+                prop(Property.baseBranch, Branch.QUARKUS.branchName),
+                prop(Property.disableBranchComparison, "true"),
+                prop(Property.uncommitted, "true")
+        };
+    }
+
+    private enum Branch {
+
+        DEVELOP("develop"),
+        QUARKUS("quarkus");
+
+        private final String branchName;
+
+        Branch(String branchName) {
+            this.branchName = branchName;
+        }
+    }
 }
diff --git a/src/test/java/com/vackosar/gitflowincrementalbuild/integration/MavenPluginIntegrationTest.java b/src/test/java/com/vackosar/gitflowincrementalbuild/integration/MavenPluginIntegrationTest.java
index 545db07..d3ea072 100644
--- a/src/test/java/com/vackosar/gitflowincrementalbuild/integration/MavenPluginIntegrationTest.java
+++ b/src/test/java/com/vackosar/gitflowincrementalbuild/integration/MavenPluginIntegrationTest.java
@@ -3,7 +3,6 @@ package com.vackosar.gitflowincrementalbuild.integration;
 import static org.assertj.core.api.Assertions.assertThat;
 
 import java.io.IOException;
-import java.net.URISyntaxException;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.StandardCopyOption;
@@ -24,7 +23,7 @@ public class MavenPluginIntegrationTest extends MavenIntegrationTestBase {
 
     @Override
     @BeforeEach
-    void initialInstall(TestInfo testInfo) throws IOException, InterruptedException, URISyntaxException {
+    void initialInstall(TestInfo testInfo) throws Exception {
         Path projectRoot = localRepoMock.getBaseCanonicalBaseFolder().toPath();
         Files.move(projectRoot.resolve("build-parent/pom-plugin.xml"), projectRoot.resolve("build-parent/pom.xml"), StandardCopyOption.REPLACE_EXISTING);
         super.initialInstall(testInfo);
diff --git a/src/test/resources/build-parent/pom-common.xml b/src/test/resources/build-parent/pom-common.xml
index dc90601..0a94255 100644
--- a/src/test/resources/build-parent/pom-common.xml
+++ b/src/test/resources/build-parent/pom-common.xml
@@ -9,6 +9,13 @@
     <version>1.0-SNAPSHOT</version>
     <packaging>pom</packaging>
 
+    <!-- This file is the root parent of the projects in template.zip and contains properties that are replaced at build time. -->
+
+    <properties>
+        <!-- These properties are needed by pom.xml files within template.zip: -->
+        <version.jgit>${version.jgit}</version.jgit>
+    </properties>
+
     <build>
         <pluginManagement>
             <plugins>