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