From a99cce0a1f117ded68753393d41bf1349b34364a Mon Sep 17 00:00:00 2001 From: krystynaShatkovska Date: Tue, 18 Aug 2026 10:07:48 +0200 Subject: [PATCH 1/4] #2312: Fix NPE in Uv.setEnvironment and suppress errors during auto-completion --- CHANGELOG.adoc | 2 ++ .../tools/ide/context/AbstractIdeContext.java | 23 +++++++++++++++---- .../com/devonfw/tools/ide/tool/uv/Uv.java | 6 ++++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 8866a010de..1675ff9746 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,8 @@ This file documents all notable changes to https://github.com/devonfw/IDEasy[IDE Release with new features and bugfixes: +* https://github.com/devonfw/IDEasy/issues/2312[#2312]: Fix NPE in Uv.setEnvironment and suppress errors during auto-completion + The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/49?closed=1[milestone 2026.08.002]. diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index f885cc5d64..bd0ba62073 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -1274,11 +1274,24 @@ public int run(CliArguments arguments) { IdeLogLevel.INTERACTION.log(LOG, "For additional details run ide help {}", cmd == null ? "" : cmd.getName()); return 1; } catch (Throwable t) { - activateLogging(cmd); - step.error(t, true); - if (this.logfile != null) { - // point the user to the logfile directly (does not make sense via logger) - System.err.println("Logfile can be found at " + this.logfile); // checkstyle:ignore SystemOut + if (cmd != null) { + // Do not log errors for processable output commandlets (e.g. CompleteCommandlet) — the output is consumed + // automatically and errors would appear as completion suggestions to the user. + if (!cmd.isProcessableOutput()) { + activateLogging(cmd); + step.error(t, true); + } + if ((this.logfile != null) && !cmd.isProcessableOutput()) { + // point the user to the logfile directly (does not make sense via logger) + System.err.println("Logfile can be found at " + this.logfile); // checkstyle:ignore SystemOut + } + } else { + activateLogging(cmd); + step.error(t, true); + if (this.logfile != null) { + // point the user to the logfile directly (does not make sense via logger) + System.err.println("Logfile can be found at " + this.logfile); // checkstyle:ignore SystemOut + } } throw t; } finally { diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/uv/Uv.java b/cli/src/main/java/com/devonfw/tools/ide/tool/uv/Uv.java index de20c22b9f..990533e468 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/uv/Uv.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/uv/Uv.java @@ -87,7 +87,11 @@ public List parsePythonListJson(List jsonLines) { public void setEnvironment(EnvironmentContext environmentContext, ToolInstallation toolInstallation, boolean additionalInstallation) { super.setEnvironment(environmentContext, toolInstallation, additionalInstallation); - Path pythonPath = this.context.getSoftwarePath().resolve("python"); + Path softwarePath = this.context.getSoftwarePath(); + if (softwarePath == null) { + return; + } + Path pythonPath = softwarePath.resolve("python"); environmentContext.withEnvVar("UV_TOOL_DIR", pythonPath.resolve("tools").toString()); environmentContext.withEnvVar("UV_TOOL_BIN_DIR", pythonPath.resolve("bin").toString()); environmentContext.withPathEntry(pythonPath.resolve("bin")); From 01ca14ef293d1298e88463e99f868fa7ade9f9d6 Mon Sep 17 00:00:00 2001 From: krystynaShatkovska Date: Tue, 18 Aug 2026 10:07:49 +0200 Subject: [PATCH 2/4] Fix: always call step.error() in catch block for processable output commandlets The original fix wrapped both activateLogging() and step.error() inside the isProcessableOutput check. However, step.error(t, true) logs at DEBUG level (not ERROR) and is needed for proper step tracking and test assertions. Only activateLogging() should be skipped to prevent errors from appearing in the terminal during auto-completion. --- .../com/devonfw/tools/ide/context/AbstractIdeContext.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index bd0ba62073..8ecb447fd8 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -1275,12 +1275,12 @@ public int run(CliArguments arguments) { return 1; } catch (Throwable t) { if (cmd != null) { - // Do not log errors for processable output commandlets (e.g. CompleteCommandlet) — the output is consumed - // automatically and errors would appear as completion suggestions to the user. + // Do not activate logging for processable output commandlets (e.g. CompleteCommandlet) — errors would appear + // in the terminal as completion suggestions to the user. step.error() still needs to be called for proper step tracking. if (!cmd.isProcessableOutput()) { activateLogging(cmd); - step.error(t, true); } + step.error(t, true); if ((this.logfile != null) && !cmd.isProcessableOutput()) { // point the user to the logfile directly (does not make sense via logger) System.err.println("Logfile can be found at " + this.logfile); // checkstyle:ignore SystemOut From 3aa30e4f4a83d101d3e60ca2cca4ab52f0b4ca56 Mon Sep 17 00:00:00 2001 From: krystynaShatkovska Date: Tue, 18 Aug 2026 12:18:40 +0200 Subject: [PATCH 3/4] #2312: Fix NPE in Uv.setEnvironment and suppress errors during auto-completion Address the two bugs reported on #2312 and add regression tests for both. 1) NPE in Uv.setEnvironment when the software path is null. - Guard the software-path resolution with a null check (same idiom as LocalToolCommandlet.getToolPath). - Add UvTest#testSetEnvironmentWithNullSoftwarePath: a genuine regression test that throws a NullPointerException before the fix and passes after. 2) Error leak during auto-completion / processable output. - When a processable-output commandlet (e.g. complete) throws, the previous code rethrew the exception, which made Ideasy.run() log the 'An unexpected error occurred! ... please file a bug' block at ERROR level into the machine-consumed output. - Rework the catch block in AbstractIdeContext.run(): for a processable-output commandlet, record the failure via step.error(t, true) (still marks the step as failed) and fail quietly instead of rethrowing, so no ERROR block or 'Logfile can be found at ...' line is emitted into the captured output. - Add IdeasyTest#testProcessableOutputCommandletFailureDoesNotLogError: a regression test that leaks the ERROR block before the fix and passes after. Verified: full 'mvn clean test' passes (all modules, 0 failures). Both new tests fail against the pre-fix behavior and pass with the fix. --- .../tools/ide/context/AbstractIdeContext.java | 31 ++++---- .../com/devonfw/tools/ide/cli/IdeasyTest.java | 71 +++++++++++++++++++ .../com/devonfw/tools/ide/tool/uv/UvTest.java | 25 +++++++ 3 files changed, 110 insertions(+), 17 deletions(-) diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java index 8ecb447fd8..8dfcabaa17 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/AbstractIdeContext.java @@ -1274,24 +1274,21 @@ public int run(CliArguments arguments) { IdeLogLevel.INTERACTION.log(LOG, "For additional details run ide help {}", cmd == null ? "" : cmd.getName()); return 1; } catch (Throwable t) { - if (cmd != null) { - // Do not activate logging for processable output commandlets (e.g. CompleteCommandlet) — errors would appear - // in the terminal as completion suggestions to the user. step.error() still needs to be called for proper step tracking. - if (!cmd.isProcessableOutput()) { - activateLogging(cmd); - } - step.error(t, true); - if ((this.logfile != null) && !cmd.isProcessableOutput()) { - // point the user to the logfile directly (does not make sense via logger) - System.err.println("Logfile can be found at " + this.logfile); // checkstyle:ignore SystemOut - } - } else { - activateLogging(cmd); + if (cmd != null && cmd.isProcessableOutput()) { + // Processable output commandlets (auto-completion, env) write machine-consumed output to stdout. A failure + // there must not pollute that output with an error block and "file a bug" screen — so we record the failure + // (step.error still logs "Step ... ended with failure" for step tracking) and fail quietly instead of + // rethrowing, which would make Ideasy.run() log the error at ERROR level into the captured output. step.error(t, true); - if (this.logfile != null) { - // point the user to the logfile directly (does not make sense via logger) - System.err.println("Logfile can be found at " + this.logfile); // checkstyle:ignore SystemOut - } + return 1; + } + // Do not activate logging for processable output commandlets (e.g. CompleteCommandlet) — errors would appear + // in the terminal as completion suggestions to the user. + activateLogging(cmd); + step.error(t, true); + if (this.logfile != null) { + // point the user to the logfile directly (does not make sense via logger) + System.err.println("Logfile can be found at " + this.logfile); // checkstyle:ignore SystemOut } throw t; } finally { diff --git a/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java b/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java index 0b1e9f3dc1..e4c8fc1299 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java @@ -7,7 +7,9 @@ import org.junit.jupiter.api.Test; +import com.devonfw.tools.ide.commandlet.Commandlet; import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.context.IdeTestContext; import com.devonfw.tools.ide.version.IdeVersion; @@ -34,6 +36,75 @@ void testEnvOutsideProjectDoesNotLogCliExitException() { assertThat(context).log().hasNoEntryWithException(); } + /** + * Test that a {@link Commandlet#isProcessableOutput() processable-output} commandlet that throws inside {@link Commandlet#run() run} does not leak + * an ERROR-level error block ("An unexpected error occurred! … please file a bug") nor a "Logfile can be found at …" line into the captured log, while + * still marking the step as failed. + *

+ * Regression test: rethrowing the exception made {@link Ideasy#run(String...)} log the error at ERROR level into the machine-consumed (auto-completion) + * output. The fix swallows the failure for processable-output commandlets instead of rethrowing it. + */ + @Test + void testProcessableOutputCommandletFailureDoesNotLogError() { + + // arrange + IdeTestContext context = newContext(Path.of("/")); + context.addCommandlet(new ThrowingProcessableCommandlet(context)); + Ideasy ideasy = new Ideasy(context); + + // act + int exitCode = ideasy.run("throw"); + + // assert - the step is marked as failed + assertThat(context).logAtDebug().hasMessage("Step 'ide' ended with failure."); + // assert - no ERROR-level error block and no "Logfile can be found at" line leaked into the captured output + assertThat(exitCode).isNotEqualTo(0); + assertThat(context).logAtError().hasNoMessageContaining("An unexpected error occurred"); + assertThat(context).log().hasNoMessageContaining("An unexpected error occurred"); + assertThat(context).log().hasNoMessageContaining("Logfile can be found at"); + assertThat(context).log().hasNoEntryWithException(); + } + + /** + * A minimal {@link Commandlet} that produces processable output (like {@code complete}) but always fails, used to verify how a failure in such a + * commandlet is reported. + */ + private static final class ThrowingProcessableCommandlet extends Commandlet { + + /** + * @param context the {@link IdeContext}. + */ + ThrowingProcessableCommandlet(IdeContext context) { + + super(context); + addKeyword("throw"); + } + + @Override + public String getName() { + + return "throw"; + } + + @Override + public boolean isIdeRootRequired() { + + return false; + } + + @Override + public boolean isProcessableOutput() { + + return true; + } + + @Override + protected void doRun() { + + throw new IllegalStateException("boom"); + } + } + /** * Test of {@code ide --version}. */ diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/uv/UvTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/uv/UvTest.java index 736d60b934..cb413a6ecc 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/uv/UvTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/uv/UvTest.java @@ -41,6 +41,31 @@ public void testSetEnvironment() { assertThat(variables.get("UV_TOOL_BIN_DIR").getValue().replace('\\', '/')).endsWith("software/python/bin"); } + @Test + public void testSetEnvironmentWithNullSoftwarePath() { + + // arrange — force getSoftwarePath() to return null to reproduce the condition of #2312 + IdeTestContext context = new IdeTestContext() { + @Override + public Path getSoftwarePath() { + return null; + } + }; + Uv uv = new Uv(context); + Path toolDir = Path.of("/software/uv"); + ToolInstallation toolInstallation = new ToolInstallation(toolDir, toolDir, toolDir, VersionIdentifier.of("0.1.0"), true); + Map variables = new HashMap<>(); + EnvironmentVariableCollectorContext environmentContext = new EnvironmentVariableCollectorContext(variables, + new VariableSource(EnvironmentVariablesType.WORKSPACE, null), WindowsPathSyntax.MSYS); + + // act — must not throw a NullPointerException when the software path is null + assertThatCode(() -> uv.setEnvironment(environmentContext, toolInstallation, false)).doesNotThrowAnyException(); + + // assert — the uv tool directories are not registered when the software path is null + assertThat(variables).doesNotContainKey("UV_TOOL_DIR"); + assertThat(variables).doesNotContainKey("UV_TOOL_BIN_DIR"); + } + @Test public void testParsePythonListJson() { From 9a875d0294019b0c9f47211da6d59f9302bba553 Mon Sep 17 00:00:00 2001 From: krystynaShatkovska Date: Wed, 19 Aug 2026 08:27:23 +0200 Subject: [PATCH 4/4] Remove trailing whitespace in IdeasyTest to satisfy spotless:check --- .../java/com/devonfw/tools/ide/cli/IdeasyTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java b/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java index e4c8fc1299..19b7227ac4 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/cli/IdeasyTest.java @@ -154,7 +154,7 @@ public void testRunWithoutArgumentsDoesNotTriggerInstallation() { String path = "project/workspaces/foo-test"; IdeTestContext context = newContext("environment", path, false); Ideasy ideasy = new Ideasy(context); - + // Take snapshot of software directory before running ide command Path softwarePath = context.getSoftwarePath(); Set existingToolsBefore = new HashSet<>(); @@ -165,7 +165,7 @@ public void testRunWithoutArgumentsDoesNotTriggerInstallation() { fail("Failed to list software directory: " + e.getMessage()); } } - + // Take snapshot of _ide/software repository before running ide command Path ideaSoftwarePath = context.getIdeRoot().resolve("_ide").resolve("software"); Set existingIdeToolsBefore = new HashSet<>(); @@ -189,7 +189,7 @@ public void testRunWithoutArgumentsDoesNotTriggerInstallation() { fail("Failed to list software directory after ide: " + e.getMessage()); } } - + Set existingIdeToolsAfter = new HashSet<>(); if (Files.exists(ideaSoftwarePath)) { try (var stream = Files.list(ideaSoftwarePath)) { @@ -198,10 +198,10 @@ public void testRunWithoutArgumentsDoesNotTriggerInstallation() { fail("Failed to list _ide/software directory after ide: " + e.getMessage()); } } - + // Verify no new tools were added to software directory assertThat(existingToolsAfter).as("No new tools should be installed in software directory").isEqualTo(existingToolsBefore); - + // Verify no new tools were added to _ide/software repository assertThat(existingIdeToolsAfter).as("No new tools should be installed in _ide/software repository").isEqualTo(existingIdeToolsBefore); }