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