From 5fbd25528bbf16c33e536272c314623300aa0d4f 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 +++++----
.../examples/filtering-the-dependency-tree.md | 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/markdown/examples/filtering-the-dependency-tree.md b/src/site/markdown/examples/filtering-the-dependency-tree.md
index 73be34d7d..91b14441f 100644
--- a/src/site/markdown/examples/filtering-the-dependency-tree.md
+++ b/src/site/markdown/examples/filtering-the-dependency-tree.md
@@ -64,6 +64,9 @@ The dependency tree can also be filtered to remove specific dependencies. For ex
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 example, to exclude Maven and Plexus dependencies from the tree, we can execute the following:
@@ -74,8 +77,12 @@ 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 all non-snapshot Plexus dependencies in the tree, we can execute the following:
+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:
```shell
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);
+ }
+}