Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ The full list of changes for this release can be found in https://github.com/dev

Release with new features and bugfixes:

* https://github.com/devonfw/IDEasy/issues/2178[#2178]: Make ReleaseCommandlet independent of specific build commandlet

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — the entry only advertises the ReleaseCommandlet refactoring, but the most visible effect of this PR for existing users is on ide build: yarn projects were being built with npm, and polyglot repos were being built with the wrong tool and the wrong default options. That is a user-facing bugfix and per DoD.adoc belongs in the changelog, otherwise nobody upgrading will connect a changed ide build behaviour to this issue.

Suggested change
* https://github.com/devonfw/IDEasy/issues/2178[#2178]: Make ReleaseCommandlet independent of specific build commandlet
* https://github.com/devonfw/IDEasy/issues/2178[#2178]: Make ReleaseCommandlet independent of specific build commandlet and fix `ide build` using npm instead of yarn

* https://github.com/devonfw/IDEasy/issues/2197[#2197]: Fix broken python integration
* https://github.com/devonfw/IDEasy/issues/2131[#2131]: Improve `ide upgrade --mode=` auto-completion
* https://github.com/devonfw/IDEasy/issues/1870[#1870]: Add generic get-version implementation for global tools under windows
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,23 +50,35 @@ protected void doRun() {
throw new CliException("Missing current working directory!");
}

LocalToolCommandlet commandlet = findBuildCommandlet(this.context, buildPath);
if (commandlet == null) {
throw new CliException("Could not find a build descriptor in " + buildPath + " - no supported build tool detected.");
}
List<String> args = this.arguments.asList();
LocalToolCommandlet commandlet = null;
if (args.isEmpty()) {
String variableName = commandlet.getName().toUpperCase(Locale.ROOT) + "_BUILD_OPTS";
args = getDefaultToolOptions(variableName);
}
commandlet.runTool(args);
}

/**
* Detects the applicable build tool for the given {@code buildPath} by {@link LocalToolCommandlet#findBuildDescriptor(Path) querying} the available build
* commandlets (in order of priority) for a matching build descriptor (e.g. {@code pom.xml}, {@code build.gradle} or {@code package.json}).
*
* @param context the {@link IdeContext}.
* @param buildPath the {@link Path} to the directory to build.
* @return the applicable build {@link LocalToolCommandlet} or {@code null} if no build descriptor was found.
*/
static LocalToolCommandlet findBuildCommandlet(IdeContext context, Path buildPath) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix — placement. Two things about this signature:

  1. It takes IdeContext context as a parameter although both call sites already hold this.context. Passing the context around when the owning object already has it injected is the pattern we avoid across the codebase.
  2. It makes ReleaseCommandlet depend on a package-private static inside a sibling commandlet. Commandlets are peers dispatched by the same manager, so one reaching into another's internals is exactly the separation-of-concerns smell Make ReleaseCommandlet independent of specific build commandlet #2178 set out to remove.

#2178 names the better home directly: "Ideally we should ask the commandlet manager for all commandlets that are build commandlets and then ask them for the build descriptor." CommandletManager already exposes getCommandlets(), so a LocalToolCommandlet findBuildCommandlet(Path) there would keep BUILD_TOOLS and the priority ordering as an implementation detail of the manager, and both callers collapse to:

LocalToolCommandlet commandlet = this.context.getCommandletManager().findBuildCommandlet(projectPath);

That would also give Intellij#importRepository (Intellij.java:158-172) — which today runs a third copy of this "iterate build tools, first descriptor wins" loop over its own BUILD_TOOL_TO_IJ_TEMPLATE map — somewhere to converge later.

If you would rather not touch CommandletManager in this PR, the minimum I would ask for is dropping the context parameter and making this an instance method, with ReleaseCommandlet obtaining the commandlet the normal way (getCommandlet(BuildCommandlet.class)). Not blocking on the exact home — but the reach-across plus redundant parameter should go.

@hohwille hohwille Sep 1, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BuildCommandlet is abstract and cannot be instantiated.
For 1. this could be moved to CommandletManager, or keep the IdeContext and move it to BuildTool better following SoC.
For 2. I would simply make the method public or you add a public method to CommandletManager that delegates to this method with limited visibility - but according to my experience with limited Java visibilities without using modules (JPMS) this is not really making it better.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the method be renamed to findBuildTool and return type be BuildTool?
IMHO the current API is confusing.


for (Class<? extends LocalToolCommandlet> toolClass : BUILD_TOOLS) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix — null contract. Mvn.findBuildDescriptor does directory.resolve(POM_XML), so a null buildPath produces a raw NullPointerException from inside the loop rather than a clean CLI error.

BuildCommandlet#doRun guards this at line 49 before calling, but ReleaseCommandlet#doRun now calls findBuildCommandlet(this.context, this.context.getCwd()) as its very first use of the cwd with no such guard. Previously git.hasUntrackedFiles(projectPath) was the first consumer, so this is not a regression you introduced — but extracting the shared helper is precisely the moment to fix the asymmetry, rather than leaving each caller to remember.

Moving the check into the helper covers both callers and keeps doRun shorter:

static LocalToolCommandlet findBuildCommandlet(IdeContext context, Path buildPath) {

  if (buildPath == null) {
    throw new CliException("Missing current working directory!");
  }
  for (Class<? extends LocalToolCommandlet> toolClass : BUILD_TOOLS) {

The existing testBuildWithNoCwd keeps passing, and ide release with no cwd stops NPE-ing. See coding-conventions.adoc § Avoid catching NPE — the rule there is to check for null explicitly instead of letting an NPE happen, and constructing an exception with a full stack collect is the expensive path we should not take for a condition a single if covers.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we already return null if nothing was found, we can even return null if buildPath was null since we cannot find anything in a non-existent path.

LocalToolCommandlet toolCommandlet = this.context.getCommandletManager().getCommandlet(toolClass);
Path buildDescriptor = toolCommandlet.findBuildDescriptor(buildPath);
if (buildDescriptor != null) {
commandlet = toolCommandlet;
if (args.isEmpty()) {
String variableName = commandlet.getName().toUpperCase(Locale.ROOT) + "_BUILD_OPTS";
args = getDefaultToolOptions(variableName);
}
LocalToolCommandlet toolCommandlet = context.getCommandletManager().getCommandlet(toolClass);
if (toolCommandlet.findBuildDescriptor(buildPath) != null) {
return toolCommandlet;
}
}
if (commandlet == null) {
throw new CliException("Could not find build descriptor - no pom.xml, build.gradle, or package.json found!");
}
commandlet.runTool(args);
return null;
}

private List<String> getDefaultToolOptions(String buildOptionName) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package com.devonfw.tools.ide.commandlet;

import java.nio.file.Files;
import java.nio.file.Path;

import org.slf4j.Logger;
Expand All @@ -11,7 +10,8 @@
import com.devonfw.tools.ide.git.GitContext;
import com.devonfw.tools.ide.process.ProcessResult;
import com.devonfw.tools.ide.property.StringProperty;
import com.devonfw.tools.ide.tool.mvn.Mvn;
import com.devonfw.tools.ide.tool.BuildTool;
import com.devonfw.tools.ide.tool.LocalToolCommandlet;
import com.devonfw.tools.ide.version.VersionIdentifier;

/**
Expand Down Expand Up @@ -41,15 +41,23 @@ protected void doRun() {

Path projectPath = this.context.getCwd();
GitContext git = this.context.getGitContext();
Mvn buildTool = this.context.getCommandletManager().getCommandlet(Mvn.class);

LocalToolCommandlet commandlet = BuildCommandlet.findBuildCommandlet(this.context, projectPath);
if (commandlet == null) {
throw new CliException("Could not find a build descriptor in " + projectPath + ". There is nothing to release here.");
}
if (!(commandlet instanceof BuildTool buildTool)) {
throw new CliException("The build tool " + commandlet.getName() + " detected in " + projectPath + " does not support releasing.");
}
Comment thread
laert-ll marked this conversation as resolved.

if (git.hasUntrackedFiles(projectPath)) {
throw new CliException("Your local git repository has uncommitted changes. Please use 'git stash' and rerun on clean repo.");
}
if (warnIfFork(git, projectPath)) {
confirmWarning("You seem to work on a fork. Releases should be done on the original repository!\nWe strongly recommend to abort and rerun on original repository.");
confirmWarning("You seem to work on a fork. Releases should be done on the original repository!\n"
+ "We strongly recommend to abort and rerun on original repository.");
}
if (!this.context.isForceMode() && !isTopLevelProject(projectPath)) {
if (!this.context.isForceMode() && !isTopLevelProject(commandlet, projectPath)) {
throw new CliException("Release has to be performed from the top-level project or using force option.");
}

Expand Down Expand Up @@ -94,14 +102,15 @@ private boolean warnIfFork(GitContext git, Path projectPath) {
return false;
}

private boolean isTopLevelProject(Path projectPath) {
private boolean isTopLevelProject(LocalToolCommandlet buildTool, Path projectPath) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — naming. This parameter is called buildTool but is typed LocalToolCommandlet, while doRun binds a different variable also named buildTool — of type BuildTool (line 46) — to the very same instance. Two names for one object, and the shared name is the one that does not match this type. Reading isTopLevelProject in isolation, buildTool suggests you are holding the BuildTool interface, which is the type that cannot answer findBuildDescriptor.

Suggested change
private boolean isTopLevelProject(LocalToolCommandlet buildTool, Path projectPath) {
private boolean isTopLevelProject(LocalToolCommandlet buildCommandlet, Path projectPath) {

(and the two usages in the body). coding-conventions.adoc § Naming — "always use short but speaking names"; here the name actively points at the wrong abstraction.


// returns false in case there's no pom.xml present or if parent directory has a pom.xml
return Files.exists(projectPath.resolve("pom.xml"))
&& !Files.exists(projectPath.getParent().resolve("pom.xml"));
// top-level if a build descriptor is present here but not in the parent directory
Path parent = projectPath.getParent();
return (buildTool.findBuildDescriptor(projectPath) != null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor — the first operand is dead. At the only call site (line 57) commandlet came out of findBuildCommandlet(this.context, projectPath), which returns a commandlet only when findBuildDescriptor(projectPath) != null. So this re-runs a filesystem Files.exists check whose answer is already known to be true.

The method reduces to the question it is actually asking:

Suggested change
return (buildTool.findBuildDescriptor(projectPath) != null)
// top-level if the build descriptor found here is not also present in the parent directory
Path parent = projectPath.getParent();
return (parent == null) || (buildCommandlet.findBuildDescriptor(parent) == null);

(uses the renamed parameter from the comment above). Non-blocking — the current code is correct, just doing redundant I/O.

&& ((parent == null) || (buildTool.findBuildDescriptor(parent) == null));
}

private void buildAndDeploy(Mvn buildTool) {
private void buildAndDeploy(BuildTool buildTool) {

while (true) {
ProcessResult result = buildTool.buildAndDeploy(this.arguments.asList());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import static org.junit.jupiter.api.Assertions.assertThrows;

import java.nio.file.Path;

import org.junit.jupiter.api.Test;

import com.devonfw.tools.ide.cli.CliException;
Expand All @@ -11,6 +13,10 @@
import com.devonfw.tools.ide.log.IdeLogLevel;
import com.devonfw.tools.ide.os.SystemInfo;
import com.devonfw.tools.ide.os.SystemInfoMock;
import com.devonfw.tools.ide.tool.gradle.Gradle;
import com.devonfw.tools.ide.tool.mvn.Mvn;
import com.devonfw.tools.ide.tool.npm.Npm;
import com.devonfw.tools.ide.tool.yarn.Yarn;
import com.github.tomakehurst.wiremock.junit5.WireMockRuntimeInfo;
import com.github.tomakehurst.wiremock.junit5.WireMockTest;

Expand Down Expand Up @@ -118,4 +124,22 @@ void testBuildWithNoBuildFile() {
context.setCwd(context.getWorkspacePath().resolve("empty"), context.getWorkspacePath().toString(), context.getIdeHome());
assertThrows(CliException.class, buildCommandlet::run);
}

/**
* Tests {@link BuildCommandlet#findBuildCommandlet(com.devonfw.tools.ide.context.IdeContext, Path)} detecting the applicable build tool by its build
* descriptor and preferring {@link Yarn} over {@link Npm} when a {@code yarn.lock} is present.
*/
@Test
void testFindBuildCommandlet() {

IdeTestContext context = newContext(PROJECT_BUILD);
Path workspace = context.getWorkspacePath();

assertThat(BuildCommandlet.findBuildCommandlet(context, workspace.resolve("mvn"))).isInstanceOf(Mvn.class);
assertThat(BuildCommandlet.findBuildCommandlet(context, workspace.resolve("gradle"))).isInstanceOf(Gradle.class);
assertThat(BuildCommandlet.findBuildCommandlet(context, workspace.resolve("npm"))).isInstanceOf(Npm.class);
// both npm and yarn match package.json, but yarn.lock is present so yarn must take precedence over npm
assertThat(BuildCommandlet.findBuildCommandlet(context, workspace.resolve("yarn"))).isInstanceOf(Yarn.class);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should-fix — coverage gap on the behaviour that actually changed for users.

This asserts the helper returns Yarn, which is good, but the previously broken behaviour was one level up: because the old loop in doRun had no break, a yarn project was really built by npm. Nothing here asserts that ide build in workspaces/main/yarn now logs yarn run build. The existing testNpmBuildWithProvidedArguments shows the shape (IdeLogEntry.ofInfo("npm start test")); a yarn equivalent would pin the fix so a future reordering of BUILD_TOOLS cannot silently undo it. I appreciate that needs _ide/urls/yarn + repository fixtures in the build test project, so it is more work than one line — but it is the assertion that proves the bug is dead.

The second uncovered flip is quieter and worth at least a helper-level assertion here, since it costs nothing: a directory containing both pom.xml and package.json. Old code ran npm with MVN_BUILD_OPTS (npm clean install); new code correctly picks Mvn. Adding a mvn-and-npm fixture folder and one more line:

// a polyglot project must be built by the highest-priority tool, not the last one that matches
assertThat(BuildCommandlet.findBuildCommandlet(context, workspace.resolve("mvn-and-npm"))).isInstanceOf(Mvn.class);

would document the priority contract that BUILD_TOOLS now carries.

assertThat(BuildCommandlet.findBuildCommandlet(context, workspace.resolve("empty"))).isNull();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,22 @@ void testReleaseWithoutBuildDescriptor() {
context.setCwd(context.getWorkspacePath().resolve("empty"), context.getWorkspacePath().toString(), context.getIdeHome());
ReleaseCommandlet releaseCommandlet = context.getCommandletManager().getCommandlet(ReleaseCommandlet.class);

assertThrows(CliException.class, releaseCommandlet::run);
CliException exception = assertThrows(CliException.class, releaseCommandlet::run);
assertThat(exception).hasMessageContaining("Could not find a build descriptor");
}

/**
* Tests that the release fails gracefully if a build descriptor is found but its build tool does not support releasing (does not implement
* {@link com.devonfw.tools.ide.tool.BuildTool}), e.g. a gradle project (only maven currently supports releasing).
*/
@Test
void testReleaseWithUnsupportedBuildToolThrowsException() {

IdeTestContext context = newReleaseContext(false);
context.setCwd(context.getWorkspacePath().resolve("gradle"), context.getWorkspacePath().toString(), context.getIdeHome());
ReleaseCommandlet releaseCommandlet = context.getCommandletManager().getCommandlet(ReleaseCommandlet.class);

CliException exception = assertThrows(CliException.class, releaseCommandlet::run);
assertThat(exception).hasMessageContaining("gradle").hasMessageContaining("does not support releasing");
}
}