From 80bea1dca4f695dc4ab7757f7897b61a19f2cefa Mon Sep 17 00:00:00 2001
From: Vaclav Haisman
Date: Sun, 2 Aug 2026 21:51:05 +0200
Subject: [PATCH] [MDEP-927] Make dependency:tree excludes prune subtrees
Fixes #1427
Fixes #1334
---
.../expected-included-survives.txt | 4 +
src/it/projects/tree-excluded/expected-v4.txt | 5 +-
src/it/projects/tree-excluded/expected.txt | 5 +-
.../projects/tree-excluded/invoker.properties | 9 +-
.../test-included-excluded.properties | 20 +++++
.../test-included-survives.properties | 20 +++++
src/it/projects/tree-excluded/verify.groovy | 5 ++
.../tree/PruningDependencyNodeVisitor.java | 54 ++++++++++++
.../plugins/dependency/tree/TreeMojo.java | 39 +++++----
.../filtering-the-dependency-tree.apt | 9 +-
.../PruningDependencyNodeVisitorTest.java | 85 +++++++++++++++++++
11 files changed, 230 insertions(+), 25 deletions(-)
create mode 100644 src/it/projects/tree-excluded/expected-included-survives.txt
create mode 100644 src/it/projects/tree-excluded/test-included-excluded.properties
create mode 100644 src/it/projects/tree-excluded/test-included-survives.properties
create mode 100644 src/main/java/org/apache/maven/plugins/dependency/tree/PruningDependencyNodeVisitor.java
create mode 100644 src/test/java/org/apache/maven/plugins/dependency/tree/PruningDependencyNodeVisitorTest.java
diff --git a/src/it/projects/tree-excluded/expected-included-survives.txt b/src/it/projects/tree-excluded/expected-included-survives.txt
new file mode 100644
index 000000000..b61e0a3ba
--- /dev/null
+++ b/src/it/projects/tree-excluded/expected-included-survives.txt
@@ -0,0 +1,4 @@
+org.apache.maven.its.dependency:test:jar:1.0-SNAPSHOT
+\- org.apache.maven:maven-project:jar:2.0.6:compile
+ \- org.apache.maven:maven-artifact-manager:jar:2.0.6:compile
+ \- org.apache.maven.wagon:wagon-provider-api:jar:1.0-beta-2:compile
diff --git a/src/it/projects/tree-excluded/expected-v4.txt b/src/it/projects/tree-excluded/expected-v4.txt
index 82cd95252..ff6840584 100644
--- a/src/it/projects/tree-excluded/expected-v4.txt
+++ b/src/it/projects/tree-excluded/expected-v4.txt
@@ -7,7 +7,4 @@ org.apache.maven.its.dependency:test:jar:1.0-SNAPSHOT
| +- org.apache.maven:maven-repository-metadata:jar:2.0.6:compile
| \- org.apache.maven.wagon:wagon-provider-api:jar:1.0-beta-2:compile
+- org.apache.maven:maven-plugin-registry:jar:2.0.6:compile
- +- org.apache.maven:maven-artifact:jar:2.0.6:compile
- \- org.codehaus.plexus:plexus-container-default:jar:1.0-alpha-9-stable-1:compile
- +- junit:junit:jar:3.8.1:compile
- \- classworlds:classworlds:jar:1.1:compile
+ \- org.apache.maven:maven-artifact:jar:2.0.6:compile
diff --git a/src/it/projects/tree-excluded/expected.txt b/src/it/projects/tree-excluded/expected.txt
index 38be1dd0d..ff6840584 100644
--- a/src/it/projects/tree-excluded/expected.txt
+++ b/src/it/projects/tree-excluded/expected.txt
@@ -7,7 +7,4 @@ org.apache.maven.its.dependency:test:jar:1.0-SNAPSHOT
| +- org.apache.maven:maven-repository-metadata:jar:2.0.6:compile
| \- org.apache.maven.wagon:wagon-provider-api:jar:1.0-beta-2:compile
+- org.apache.maven:maven-plugin-registry:jar:2.0.6:compile
- +- org.apache.maven:maven-artifact:jar:2.0.6:compile
- \- org.codehaus.plexus:plexus-container-default:jar:1.0-alpha-9-stable-1:compile
- +- junit:junit:jar:3.8.1:compile
- \- classworlds:classworlds:jar:1.1-alpha-2:compile
+ \- org.apache.maven:maven-artifact:jar:2.0.6:compile
diff --git a/src/it/projects/tree-excluded/invoker.properties b/src/it/projects/tree-excluded/invoker.properties
index afb73d253..476a9d5ed 100644
--- a/src/it/projects/tree-excluded/invoker.properties
+++ b/src/it/projects/tree-excluded/invoker.properties
@@ -15,4 +15,11 @@
# specific language governing permissions and limitations
# under the License.
-invoker.goals = ${project.groupId}:${project.artifactId}:${project.version}:tree
+invoker.goals.1 = ${project.groupId}:${project.artifactId}:${project.version}:tree
+invoker.userPropertiesFile.1 = test.properties
+
+invoker.goals.2 = ${project.groupId}:${project.artifactId}:${project.version}:tree
+invoker.userPropertiesFile.2 = test-included-excluded.properties
+
+invoker.goals.3 = ${project.groupId}:${project.artifactId}:${project.version}:tree
+invoker.userPropertiesFile.3 = test-included-survives.properties
diff --git a/src/it/projects/tree-excluded/test-included-excluded.properties b/src/it/projects/tree-excluded/test-included-excluded.properties
new file mode 100644
index 000000000..fad197e38
--- /dev/null
+++ b/src/it/projects/tree-excluded/test-included-excluded.properties
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+outputFile = target/included-excluded.txt
+includes = junit:junit
+excludes = org.codehaus.plexus:*
diff --git a/src/it/projects/tree-excluded/test-included-survives.properties b/src/it/projects/tree-excluded/test-included-survives.properties
new file mode 100644
index 000000000..63e2b17dc
--- /dev/null
+++ b/src/it/projects/tree-excluded/test-included-survives.properties
@@ -0,0 +1,20 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+outputFile = target/included-survives.txt
+includes = :wagon*
+excludes = org.codehaus.plexus:*
diff --git a/src/it/projects/tree-excluded/verify.groovy b/src/it/projects/tree-excluded/verify.groovy
index e9d8d8135..6908ce3fd 100644
--- a/src/it/projects/tree-excluded/verify.groovy
+++ b/src/it/projects/tree-excluded/verify.groovy
@@ -25,4 +25,9 @@ def expected = mavenVersion.startsWith('4.') ? "expected-v4.txt" : "expected.txt
assertThat(new File(basedir, "target/tree.txt"))
.hasSameTextualContentAs(new File(basedir, expected))
+assertThat(new File(basedir, "target/included-excluded.txt").text).isEmpty()
+
+assertThat(new File(basedir, "target/included-survives.txt"))
+ .hasSameTextualContentAs(new File(basedir, "expected-included-survives.txt"))
+
return true
diff --git a/src/main/java/org/apache/maven/plugins/dependency/tree/PruningDependencyNodeVisitor.java b/src/main/java/org/apache/maven/plugins/dependency/tree/PruningDependencyNodeVisitor.java
new file mode 100644
index 000000000..380d2873e
--- /dev/null
+++ b/src/main/java/org/apache/maven/plugins/dependency/tree/PruningDependencyNodeVisitor.java
@@ -0,0 +1,54 @@
+/*
+ * 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.plugins.dependency.tree;
+
+import java.util.ArrayDeque;
+import java.util.Deque;
+
+import org.apache.maven.shared.dependency.graph.DependencyNode;
+import org.apache.maven.shared.dependency.graph.filter.DependencyNodeFilter;
+import org.apache.maven.shared.dependency.graph.traversal.DependencyNodeVisitor;
+
+/**
+ * A dependency node visitor that delegates accepted nodes and prunes rejected nodes and their descendants.
+ */
+final class PruningDependencyNodeVisitor implements DependencyNodeVisitor {
+ private final DependencyNodeVisitor visitor;
+
+ private final DependencyNodeFilter filter;
+
+ private final Deque acceptedNodes = new ArrayDeque<>();
+
+ PruningDependencyNodeVisitor(DependencyNodeVisitor visitor, DependencyNodeFilter filter) {
+ this.visitor = visitor;
+ this.filter = filter;
+ }
+
+ @Override
+ public boolean visit(DependencyNode node) {
+ boolean accepted = filter.accept(node);
+ acceptedNodes.push(accepted);
+ return accepted && visitor.visit(node);
+ }
+
+ @Override
+ public boolean endVisit(DependencyNode node) {
+ return !acceptedNodes.pop() || visitor.endVisit(node);
+ }
+}
diff --git a/src/main/java/org/apache/maven/plugins/dependency/tree/TreeMojo.java b/src/main/java/org/apache/maven/plugins/dependency/tree/TreeMojo.java
index a2e7d8e84..cbb0af336 100644
--- a/src/main/java/org/apache/maven/plugins/dependency/tree/TreeMojo.java
+++ b/src/main/java/org/apache/maven/plugins/dependency/tree/TreeMojo.java
@@ -24,7 +24,6 @@
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
-import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
@@ -49,7 +48,6 @@
import org.apache.maven.shared.dependency.graph.DependencyGraphBuilderException;
import org.apache.maven.shared.dependency.graph.DependencyNode;
import org.apache.maven.shared.dependency.graph.filter.AncestorOrSelfDependencyNodeFilter;
-import org.apache.maven.shared.dependency.graph.filter.AndDependencyNodeFilter;
import org.apache.maven.shared.dependency.graph.filter.ArtifactDependencyNodeFilter;
import org.apache.maven.shared.dependency.graph.filter.DependencyNodeFilter;
import org.apache.maven.shared.dependency.graph.traversal.CollectingDependencyNodeVisitor;
@@ -153,6 +151,8 @@ public class TreeMojo extends AbstractMojo {
* For example, org.apache.* will match all artifacts whose group id starts with
* org.apache., and :::*-SNAPSHOT will match all snapshot artifacts.
*
+ * Paths leading to included artifacts are retained. If an artifact is also beneath a subtree matched by
+ * {@link #excludes}, the exclusion takes precedence and the artifact is not included.
*
* @see StrictPatternIncludesArtifactFilter
* @since 2.0-alpha-6
@@ -174,6 +174,8 @@ public class TreeMojo extends AbstractMojo {
* For example, org.apache.* will match all artifacts whose group id starts with
* org.apache., and :::*-SNAPSHOT will match all snapshot artifacts.
*
+ * A matching artifact and its entire dependency subtree are removed from the serialized dependency tree.
+ * Exclusions are applied before {@link #includes} and take precedence.
*
* @see StrictPatternExcludesArtifactFilter
* @since 2.0-alpha-6
@@ -333,11 +335,16 @@ private String serializeDependencyTree(DependencyNode theRootNode) {
// TODO: remove the need for this when the serializer can calculate last nodes from visitor calls only
visitor = new BuildingDependencyNodeVisitor(visitor);
- DependencyNodeFilter filter = createDependencyNodeFilter();
+ DependencyNodeFilter includesFilter = createIncludesDependencyNodeFilter();
+ DependencyNodeFilter excludesFilter = createExcludesDependencyNodeFilter();
- if (filter != null) {
+ if (includesFilter != null) {
CollectingDependencyNodeVisitor collectingVisitor = new CollectingDependencyNodeVisitor();
- DependencyNodeVisitor firstPassVisitor = new FilteringDependencyNodeVisitor(collectingVisitor, filter);
+ DependencyNodeVisitor firstPassVisitor =
+ new FilteringDependencyNodeVisitor(collectingVisitor, includesFilter);
+ if (excludesFilter != null) {
+ firstPassVisitor = new PruningDependencyNodeVisitor(firstPassVisitor, excludesFilter);
+ }
theRootNode.accept(firstPassVisitor);
DependencyNodeFilter secondPassFilter =
@@ -345,6 +352,10 @@ private String serializeDependencyTree(DependencyNode theRootNode) {
visitor = new FilteringDependencyNodeVisitor(visitor, secondPassFilter);
}
+ if (excludesFilter != null) {
+ visitor = new PruningDependencyNodeVisitor(visitor, excludesFilter);
+ }
+
theRootNode.accept(visitor);
return writer.toString();
@@ -397,27 +408,25 @@ private GraphTokens toGraphTokens(String theTokens) {
*
* @return the dependency node filter, or null if none required
*/
- private DependencyNodeFilter createDependencyNodeFilter() {
- List filters = new ArrayList<>();
-
- // filter includes
+ private DependencyNodeFilter createIncludesDependencyNodeFilter() {
if (includes != null && !includes.isEmpty()) {
-
getLog().debug("+ Filtering dependency tree by artifact include patterns: " + includes);
ArtifactFilter artifactFilter = new StrictPatternIncludesArtifactFilter(includes);
- filters.add(new ArtifactDependencyNodeFilter(artifactFilter));
+ return new ArtifactDependencyNodeFilter(artifactFilter);
}
- // filter excludes
- if (excludes != null && !excludes.isEmpty()) {
+ return null;
+ }
+ private DependencyNodeFilter createExcludesDependencyNodeFilter() {
+ if (excludes != null && !excludes.isEmpty()) {
getLog().debug("+ Filtering dependency tree by artifact exclude patterns: " + excludes);
ArtifactFilter artifactFilter = new StrictPatternExcludesArtifactFilter(excludes);
- filters.add(new ArtifactDependencyNodeFilter(artifactFilter));
+ return new ArtifactDependencyNodeFilter(artifactFilter);
}
- return filters.isEmpty() ? null : new AndDependencyNodeFilter(filters);
+ return null;
}
}
diff --git a/src/site/apt/examples/filtering-the-dependency-tree.apt b/src/site/apt/examples/filtering-the-dependency-tree.apt
index ee138763e..139d514fe 100644
--- a/src/site/apt/examples/filtering-the-dependency-tree.apt
+++ b/src/site/apt/examples/filtering-the-dependency-tree.apt
@@ -68,6 +68,9 @@ mvn dependency:tree -Dincludes=velocity:velocity
mvn dependency:tree -Dexcludes=org.codehaus.plexus
+---+
+ A dependency matching an exclude pattern and its entire dependency subtree are removed from the serialized tree.
+ This affects only the displayed tree; it does not change the project's dependency resolution.
+
* Specifying multiple patterns
Multiple patterns can be specified when filtering the dependency tree by separating the patterns with commas. For
@@ -79,9 +82,13 @@ mvn dependency:tree -Dexcludes=org.apache.maven*,org.codehaus.plexus
* Including and excluding dependencies from the tree
- Both include and exclude patterns and be specified together to filter the dependency tree. For example, to locate
+ Both include and exclude patterns can be specified together to filter the dependency tree. For example, to locate
all non-snapshot Plexus dependencies in the tree, we can execute the following:
+---+
mvn dependency:tree -Dincludes=org.codehaus.plexus -Dexcludes=:::*-SNAPSHOT
+---+
+
+ Excludes are applied first and take precedence over includes. Includes then select matching dependencies from the
+ remaining tree and retain the paths leading to those dependencies. An include therefore cannot restore a dependency
+ beneath an excluded subtree.
diff --git a/src/test/java/org/apache/maven/plugins/dependency/tree/PruningDependencyNodeVisitorTest.java b/src/test/java/org/apache/maven/plugins/dependency/tree/PruningDependencyNodeVisitorTest.java
new file mode 100644
index 000000000..2231d7c5f
--- /dev/null
+++ b/src/test/java/org/apache/maven/plugins/dependency/tree/PruningDependencyNodeVisitorTest.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.plugins.dependency.tree;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.shared.dependency.graph.DependencyNode;
+import org.apache.maven.shared.dependency.graph.filter.DependencyNodeFilter;
+import org.apache.maven.shared.dependency.graph.internal.DefaultDependencyNode;
+import org.apache.maven.shared.dependency.graph.traversal.CollectingDependencyNodeVisitor;
+import org.apache.maven.shared.dependency.graph.traversal.DependencyNodeVisitor;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+
+class PruningDependencyNodeVisitorTest {
+ @Test
+ void evaluatesFilterOnlyWhenStartingNodeVisit() {
+ DefaultDependencyNode root = newNode(null);
+ root.setChildren(Collections.emptyList());
+ AtomicInteger filterInvocations = new AtomicInteger();
+ AtomicInteger endVisits = new AtomicInteger();
+ DependencyNodeVisitor visitor = new DependencyNodeVisitor() {
+ @Override
+ public boolean visit(DependencyNode node) {
+ return true;
+ }
+
+ @Override
+ public boolean endVisit(DependencyNode node) {
+ endVisits.incrementAndGet();
+ return true;
+ }
+ };
+
+ root.accept(new PruningDependencyNodeVisitor(visitor, node -> filterInvocations.incrementAndGet() == 1));
+
+ assertEquals(1, filterInvocations.get());
+ assertEquals(1, endVisits.get());
+ }
+
+ @Test
+ void prunesRejectedSubtreeAndContinuesWithSiblings() {
+ DefaultDependencyNode root = newNode(null);
+ DefaultDependencyNode rejected = newNode(root);
+ DefaultDependencyNode rejectedChild = newNode(rejected);
+ DefaultDependencyNode sibling = newNode(root);
+
+ root.setChildren(Arrays.asList(rejected, sibling));
+ rejected.setChildren(Collections.singletonList(rejectedChild));
+ rejectedChild.setChildren(Collections.emptyList());
+ sibling.setChildren(Collections.emptyList());
+
+ CollectingDependencyNodeVisitor collectingVisitor = new CollectingDependencyNodeVisitor();
+ DependencyNodeFilter filter = node -> node != rejected;
+
+ root.accept(new PruningDependencyNodeVisitor(collectingVisitor, filter));
+
+ assertEquals(Arrays.asList(root, sibling), collectingVisitor.getNodes());
+ }
+
+ private DefaultDependencyNode newNode(DependencyNode parent) {
+ return new DefaultDependencyNode(parent, mock(Artifact.class), null, null, null);
+ }
+}