This is an automated email from the ASF dual-hosted git repository.

jdaugherty pushed a commit to branch 8.0.x
in repository https://gitbox.apache.org/repos/asf/grails-core.git


The following commit(s) were added to refs/heads/8.0.x by this push:
     new 6d1acaddb6 Fix help resolution & speed up dependency resolution
6d1acaddb6 is described below

commit 6d1acaddb6bf52db63370ade4e4ee8ec89d54bd3
Author: James Daugherty <[email protected]>
AuthorDate: Mon Jul 20 22:23:02 2026 -0500

    Fix help resolution & speed up dependency resolution
---
 .../main/groovy/org/grails/cli/GrailsCli.groovy    | 40 ++++++---
 ...ltRepositorySystemSessionAutoConfiguration.java | 55 ++++++++++++
 .../repository/MavenProfileRepository.groovy       | 35 +++++++-
 .../cli/GrailsCliProjectDetectionSpec.groovy       | 57 +++++++++++++
 ...sitorySystemSessionAutoConfigurationSpec.groovy | 97 ++++++++++++++++++++++
 .../MavenProfileRepositorySnapshotSpec.groovy      | 53 ++++++++++++
 6 files changed, 323 insertions(+), 14 deletions(-)

diff --git a/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy 
b/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy
index 1d4ce96fc1..a73f246f6e 100644
--- a/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy
+++ b/grails-shell-cli/src/main/groovy/org/grails/cli/GrailsCli.groovy
@@ -128,7 +128,7 @@ class GrailsCli {
     CodeGenConfig applicationConfig
     ProjectContext projectContext
     Profile profile = null
-    List<GrailsRepositoryConfiguration> profileRepositories = 
[MavenProfileRepository.APACHE_REPO, MavenProfileRepository.GRAILS_REPO]
+    List<GrailsRepositoryConfiguration> profileRepositories = 
[MavenProfileRepository.apacheRepo(), MavenProfileRepository.grailsRepo()]
 
     /**
      * Obtains a value from .grails/settings.yml
@@ -223,9 +223,20 @@ class GrailsCli {
         }
 
         if (mainCommandLine.hasOption(CommandLine.HELP_ARGUMENT) || 
mainCommandLine.hasOption('h')) {
-            profileRepository = createMavenProfileRepository()
-            def cmd = CommandRegistry.instance.getCommand('help', 
profileRepository)
-            cmd.handle(createExecutionContext(mainCommandLine))
+            if (mainCommandLine.environmentSet) {
+                System.setProperty(Environment.KEY, 
mainCommandLine.environment)
+                Environment.reset()
+            }
+            if (isGrailsProject()) {
+                // Inside a project, resolve the profile so the help listing 
includes the
+                // application (project) commands, matching what interactive 
mode offers.
+                initializeApplication(mainCommandLine)
+                handleCommand(cliParser.parse('help'))
+            } else {
+                profileRepository = createMavenProfileRepository()
+                def cmd = CommandRegistry.instance.getCommand('help', 
profileRepository)
+                cmd.handle(createExecutionContext(mainCommandLine))
+            }
             exit(0)
         }
 
@@ -234,10 +245,7 @@ class GrailsCli {
             Environment.reset()
         }
 
-        File grailsAppDir = new File('grails-app')
-        File applicationGroovy = new File('Application.groovy')
-        File profileYml = new File('profile.yml')
-        if (!grailsAppDir.isDirectory() && !applicationGroovy.exists() && 
!profileYml.exists()) {
+        if (!isGrailsProject()) {
             profileRepository = createMavenProfileRepository()
             if (!mainCommandLine || !mainCommandLine.commandName) {
                 integrateGradle = false
@@ -333,6 +341,17 @@ class GrailsCli {
         new ExecutionContextImpl(commandLine, projectContext)
     }
 
+    /**
+     * Whether the given directory (the current working directory by default) 
is a Grails project
+     * (application, plugin, or profile), in which case the project profile — 
and its application
+     * commands — can be resolved.
+     */
+    protected static boolean isGrailsProject(File baseDir = new File('.')) {
+        new File(baseDir, 'grails-app').isDirectory() ||
+                new File(baseDir, 'Application.groovy').exists() ||
+                new File(baseDir, 'profile.yml').exists()
+    }
+
     Boolean handleCommand(CommandLine commandLine) {
 
         handleCommand(createExecutionContext(commandLine))
@@ -500,8 +519,7 @@ class GrailsCli {
         if (!new File(BuildSettings.BASE_DIR, 'profile.yml').exists()) {
             // must be inside of a grails app, so share the classpath from the 
grails app to find all of the necessary commands, scripts, etc
             populateContextLoader()
-        }
-        else {
+        } else {
             this.profileRepository = createMavenProfileRepository()
         }
 
@@ -666,6 +684,7 @@ class GrailsCli {
     }
 
     static class ExecutionContextImpl implements ExecutionContext {
+
         CommandLine commandLine
 
         @Delegate(excludes = ['getConsole', 'getBaseDir'])
@@ -711,6 +730,7 @@ class GrailsCli {
 
     @Canonical
     private static class ProjectContextImpl implements ProjectContext {
+
         GrailsConsole console = GrailsConsole.getInstance()
         File baseDir
         CodeGenConfig grailsConfig
diff --git 
a/grails-shell-cli/src/main/groovy/org/grails/cli/compiler/grape/DefaultRepositorySystemSessionAutoConfiguration.java
 
b/grails-shell-cli/src/main/groovy/org/grails/cli/compiler/grape/DefaultRepositorySystemSessionAutoConfiguration.java
index f7a7c968ab..df405e758b 100644
--- 
a/grails-shell-cli/src/main/groovy/org/grails/cli/compiler/grape/DefaultRepositorySystemSessionAutoConfiguration.java
+++ 
b/grails-shell-cli/src/main/groovy/org/grails/cli/compiler/grape/DefaultRepositorySystemSessionAutoConfiguration.java
@@ -39,9 +39,18 @@ import org.springframework.util.StringUtils;
  */
 public class DefaultRepositorySystemSessionAutoConfiguration implements 
RepositorySystemSessionAutoConfiguration {
 
+    /**
+     * System property that sets the number of threads Maven Resolver uses to 
collect the dependency
+     * graph and to transfer metadata and artifacts in parallel. When unset 
(or not a positive
+     * integer) it defaults to {@code max(8, availableProcessors * 2)}.
+     */
+    public static final String RESOLUTION_THREADS_PROPERTY = 
"grails.dependency.resolution.threads";
+
     @Override
     public void apply(DefaultRepositorySystemSession session, RepositorySystem 
repositorySystem) {
 
+        applyParallelResolution(session);
+
         if (session.getLocalRepositoryManager() == null) {
             LocalRepository localRepository = new 
LocalRepository(getM2RepoDirectory());
             LocalRepositoryManager localRepositoryManager = 
repositorySystem.newLocalRepositoryManager(session,
@@ -58,6 +67,52 @@ public class DefaultRepositorySystemSessionAutoConfiguration 
implements Reposito
         }
     }
 
+    /**
+     * Speed up profile/dependency resolution by letting Maven Resolver 
collect the dependency graph
+     * breadth-first (in parallel) and by widening the thread pools used for 
metadata and artifact
+     * transfers. This is what turns the long serial "Resolving 
dependencies......" wait into a set of
+     * concurrent requests. Each property is only applied when it has not 
already been configured, so
+     * an explicit user/system-property override always wins.
+     */
+    private void applyParallelResolution(DefaultRepositorySystemSession 
session) {
+        int threads = resolveThreadCount();
+        // Breadth-first collector resolves sibling dependencies concurrently 
(vs. the serial
+        // depth-first default), which is the dominant win against 
high-latency remote repositories.
+        setConfigPropertyIfAbsent(session, "aether.dependencyCollector.impl", 
"bf");
+        setConfigPropertyIfAbsent(session, 
"aether.dependencyCollector.bf.threads", String.valueOf(threads));
+        // Parallelise the maven-metadata.xml lookups performed while 
collecting the graph.
+        setConfigPropertyIfAbsent(session, "aether.metadataResolver.threads", 
String.valueOf(threads));
+        // Parallelise the actual artifact (jar/pom) downloads within a single 
resolve request.
+        setConfigPropertyIfAbsent(session, "aether.connector.basic.threads", 
String.valueOf(threads));
+    }
+
+    /**
+     * The configured thread count from {@link #RESOLUTION_THREADS_PROPERTY}, 
or the computed default
+     * of {@code max(8, availableProcessors * 2)} when the property is unset 
or not a positive integer.
+     */
+    private int resolveThreadCount() {
+        int defaultThreads = Math.max(8, 
Runtime.getRuntime().availableProcessors() * 2);
+        String configured = System.getProperty(RESOLUTION_THREADS_PROPERTY);
+        if (configured != null && !configured.isBlank()) {
+            try {
+                int value = Integer.parseInt(configured.trim());
+                if (value > 0) {
+                    return value;
+                }
+            }
+            catch (NumberFormatException ignored) {
+                // fall through to the computed default
+            }
+        }
+        return defaultThreads;
+    }
+
+    private void setConfigPropertyIfAbsent(DefaultRepositorySystemSession 
session, String key, String value) {
+        if (!session.getConfigProperties().containsKey(key)) {
+            session.setConfigProperty(key, value);
+        }
+    }
+
     private File getM2RepoDirectory() {
         return new File(getDefaultM2HomeDirectory(), "repository");
     }
diff --git 
a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/repository/MavenProfileRepository.groovy
 
b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/repository/MavenProfileRepository.groovy
index 4c952f1dcd..f9f0ea0fd5 100644
--- 
a/grails-shell-cli/src/main/groovy/org/grails/cli/profile/repository/MavenProfileRepository.groovy
+++ 
b/grails-shell-cli/src/main/groovy/org/grails/cli/profile/repository/MavenProfileRepository.groovy
@@ -40,8 +40,36 @@ import org.grails.cli.profile.Profile
 @CompileStatic
 class MavenProfileRepository extends AbstractJarProfileRepository {
 
-    public static final GrailsRepositoryConfiguration GRAILS_REPO = new 
GrailsRepositoryConfiguration('grailsRepo', new 
URI('https://repo.grails.org/grails/restricted'), true)
-    public static final GrailsRepositoryConfiguration APACHE_REPO = new 
GrailsRepositoryConfiguration('apacheRepository', new 
URI('https://repository.apache.org/content/groups/public'), true)
+    static final URI GRAILS_REPO_URI = new 
URI('https://repo.grails.org/grails/restricted')
+    static final URI APACHE_REPO_URI = new 
URI('https://repository.apache.org/content/groups/public')
+
+    /**
+     * Whether the given Grails version should resolve against snapshot 
repositories: only a snapshot
+     * build (or an unknown version) does. Kept as a pure function of the 
version so the decision can
+     * be made during the CLI lifecycle — and unit tested — rather than at 
class-initialisation time.
+     */
+    static boolean isSnapshotVersion(String grailsVersion) {
+        !grailsVersion || grailsVersion.endsWith('SNAPSHOT')
+    }
+
+    /**
+     * Whether snapshot resolution should be enabled for the running CLI. 
Reading the version here
+     * (rather than in a static initialiser) keeps the environment access on 
the startup lifecycle.
+     * For a milestone/release CLI this is {@code false}, which lets Aether use
+     * {@code UPDATE_POLICY_NEVER} and serve fixed versions straight from the 
local cache instead of
+     * making remote {@code maven-metadata.xml} round-trips on every resolve.
+     */
+    static boolean snapshotsEnabled() {
+        isSnapshotVersion(Environment.grailsVersion)
+    }
+
+    static GrailsRepositoryConfiguration grailsRepo() {
+        new GrailsRepositoryConfiguration('grailsRepo', GRAILS_REPO_URI, 
snapshotsEnabled())
+    }
+
+    static GrailsRepositoryConfiguration apacheRepo() {
+        new GrailsRepositoryConfiguration('apacheRepository', APACHE_REPO_URI, 
snapshotsEnabled())
+    }
 
     List<GrailsRepositoryConfiguration> repositoryConfigurations
     MavenResolverGrapeEngine grapeEngine
@@ -62,8 +90,7 @@ class MavenProfileRepository extends 
AbstractJarProfileRepository {
     MavenProfileRepository() {
         // Use apache repository with SNAPSHOTS when grailsVersion is not set 
or it ends in SNAPSHOT
         // otherwise use only mavenCentral
-        this((!Environment.grailsVersion || 
Environment.grailsVersion.endsWith('SNAPSHOT')) ? [APACHE_REPO, GRAILS_REPO] : 
[GRAILS_REPO])
-        // TODO: Remove GRAILS_REPO in snapshot section when Spring verison is 
released
+        this(snapshotsEnabled() ? [apacheRepo(), grailsRepo()] : 
[grailsRepo()])
     }
 
     @Override
diff --git 
a/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliProjectDetectionSpec.groovy
 
b/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliProjectDetectionSpec.groovy
new file mode 100644
index 0000000000..04780650e6
--- /dev/null
+++ 
b/grails-shell-cli/src/test/groovy/org/grails/cli/GrailsCliProjectDetectionSpec.groovy
@@ -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
+ *
+ *    https://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.grails.cli
+
+import spock.lang.Specification
+import spock.lang.TempDir
+
+/**
+ * Covers the project detection that lets {@code --help} include application 
(project) commands when
+ * run inside a Grails project.
+ */
+class GrailsCliProjectDetectionSpec extends Specification {
+
+    @TempDir
+    File dir
+
+    void 'a directory containing #marker is detected as a Grails project'() {
+        given:
+        create(marker)
+
+        expect:
+        GrailsCli.isGrailsProject(dir)
+
+        where:
+        marker << ['grails-app', 'Application.groovy', 'profile.yml']
+    }
+
+    void 'a directory with none of the project markers is not a Grails 
project'() {
+        expect:
+        !GrailsCli.isGrailsProject(dir)
+    }
+
+    private void create(String marker) {
+        if (marker == 'grails-app') {
+            assert new File(dir, marker).mkdirs()
+        }
+        else {
+            new File(dir, marker).text = ''
+        }
+    }
+}
diff --git 
a/grails-shell-cli/src/test/groovy/org/grails/cli/compiler/grape/DefaultRepositorySystemSessionAutoConfigurationSpec.groovy
 
b/grails-shell-cli/src/test/groovy/org/grails/cli/compiler/grape/DefaultRepositorySystemSessionAutoConfigurationSpec.groovy
new file mode 100644
index 0000000000..6317d68a46
--- /dev/null
+++ 
b/grails-shell-cli/src/test/groovy/org/grails/cli/compiler/grape/DefaultRepositorySystemSessionAutoConfigurationSpec.groovy
@@ -0,0 +1,97 @@
+/*
+ *  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
+ *
+ *    https://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.grails.cli.compiler.grape
+
+import org.apache.maven.repository.internal.MavenRepositorySystemUtils
+import org.eclipse.aether.DefaultRepositorySystemSession
+import org.eclipse.aether.RepositorySystem
+import org.eclipse.aether.repository.LocalRepositoryManager
+
+import spock.lang.Specification
+import spock.util.environment.RestoreSystemProperties
+
+@RestoreSystemProperties
+class DefaultRepositorySystemSessionAutoConfigurationSpec extends 
Specification {
+
+    void 'parallel resolution defaults enable the breadth-first collector and 
widen the thread pools'() {
+        given:
+        DefaultRepositorySystemSession session = newSession()
+
+        when:
+        new DefaultRepositorySystemSessionAutoConfiguration().apply(session, 
Stub(RepositorySystem))
+
+        then:
+        session.configProperties['aether.dependencyCollector.impl'] == 'bf'
+        (session.configProperties['aether.dependencyCollector.bf.threads'] as 
int) >= 8
+        (session.configProperties['aether.metadataResolver.threads'] as int) 
>= 8
+        (session.configProperties['aether.connector.basic.threads'] as int) >= 
8
+    }
+
+    void 'the thread count is taken from the configurable property when set to 
a positive integer'() {
+        given:
+        
System.setProperty(DefaultRepositorySystemSessionAutoConfiguration.RESOLUTION_THREADS_PROPERTY,
 '3')
+        DefaultRepositorySystemSession session = newSession()
+
+        when:
+        new DefaultRepositorySystemSessionAutoConfiguration().apply(session, 
Stub(RepositorySystem))
+
+        then:
+        session.configProperties['aether.dependencyCollector.bf.threads'] == 
'3'
+        session.configProperties['aether.metadataResolver.threads'] == '3'
+        session.configProperties['aether.connector.basic.threads'] == '3'
+    }
+
+    void 'a #description property value falls back to the computed default'() {
+        given:
+        
System.setProperty(DefaultRepositorySystemSessionAutoConfiguration.RESOLUTION_THREADS_PROPERTY,
 value)
+        DefaultRepositorySystemSession session = newSession()
+
+        when:
+        new DefaultRepositorySystemSessionAutoConfiguration().apply(session, 
Stub(RepositorySystem))
+
+        then:
+        (session.configProperties['aether.connector.basic.threads'] as int) >= 
8
+
+        where:
+        description         | value
+        'zero'              | '0'
+        'negative'          | '-4'
+        'non-numeric'       | 'lots'
+        'blank'             | '   '
+    }
+
+    void 'an explicit session configuration is preserved rather than 
overridden'() {
+        given:
+        DefaultRepositorySystemSession session = newSession()
+        session.setConfigProperty('aether.dependencyCollector.impl', 'df')
+
+        when:
+        new DefaultRepositorySystemSessionAutoConfiguration().apply(session, 
Stub(RepositorySystem))
+
+        then:
+        session.configProperties['aether.dependencyCollector.impl'] == 'df'
+    }
+
+    private DefaultRepositorySystemSession newSession() {
+        DefaultRepositorySystemSession session = 
MavenRepositorySystemUtils.newSession()
+        // a pre-set local repository manager keeps apply() from needing a 
real RepositorySystem
+        session.setLocalRepositoryManager(Stub(LocalRepositoryManager))
+        session
+    }
+}
diff --git 
a/grails-shell-cli/src/test/groovy/org/grails/cli/profile/repository/MavenProfileRepositorySnapshotSpec.groovy
 
b/grails-shell-cli/src/test/groovy/org/grails/cli/profile/repository/MavenProfileRepositorySnapshotSpec.groovy
new file mode 100644
index 0000000000..ee49be5bce
--- /dev/null
+++ 
b/grails-shell-cli/src/test/groovy/org/grails/cli/profile/repository/MavenProfileRepositorySnapshotSpec.groovy
@@ -0,0 +1,53 @@
+/*
+ *  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
+ *
+ *    https://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.grails.cli.profile.repository
+
+import grails.util.Environment
+import spock.lang.Specification
+import spock.lang.Unroll
+
+class MavenProfileRepositorySnapshotSpec extends Specification {
+
+    @Unroll
+    void 'version #version isSnapshotVersion == #expected'() {
+        expect: 'only snapshot (or unknown) versions resolve against snapshot 
repositories'
+        MavenProfileRepository.isSnapshotVersion(version) == expected
+
+        where:
+        version           || expected
+        '8.0.0-SNAPSHOT'  || true
+        null              || true
+        ''                || true
+        '8.0.0-M4'        || false
+        '8.0.0'           || false
+        '8.0.0-RC1'       || false
+    }
+
+    void 'the snapshot gate is derived from the running Grails version'() {
+        expect:
+        MavenProfileRepository.snapshotsEnabled() ==
+                
MavenProfileRepository.isSnapshotVersion(Environment.grailsVersion)
+    }
+
+    void 'both built-in repositories honour the snapshot gate'() {
+        expect: 'a milestone/release CLI does not pay for snapshot metadata 
checks against the built-in repos'
+        MavenProfileRepository.grailsRepo().snapshotsEnabled == 
MavenProfileRepository.snapshotsEnabled()
+        MavenProfileRepository.apacheRepo().snapshotsEnabled == 
MavenProfileRepository.snapshotsEnabled()
+    }
+}

Reply via email to