diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index a1bd2f9ea6..26add12a04 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -6,6 +6,7 @@ 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/2273[#2273]: Integrate Git as global tool * https://github.com/devonfw/IDEasy/issues/2635[#2635]: Abort with a clear message instead of a NullPointerException when a tool with CVEs is installed in batch mode without force * https://github.com/devonfw/IDEasy/issues/2226[#2226]: Show IDEasy project, workspace and repository in the Git Bash window title * https://github.com/devonfw/IDEasy/issues/2373[#2373]: GlobalToolCommandlet: determine the edition together with the version for native package manager installs (Linux/macOS) diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java index 608d0c4e9c..fc8ba1316e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/CommandletManagerImpl.java @@ -39,6 +39,7 @@ import com.devonfw.tools.ide.tool.gcviewer.GcViewer; import com.devonfw.tools.ide.tool.gemini.Gemini; import com.devonfw.tools.ide.tool.gh.Gh; +import com.devonfw.tools.ide.tool.git.Git; import com.devonfw.tools.ide.tool.go.Go; import com.devonfw.tools.ide.tool.graalvm.GraalVm; import com.devonfw.tools.ide.tool.gradle.Gradle; @@ -200,6 +201,7 @@ public CommandletManagerImpl(IdeContext context) { add(new Obsidian(context)); add(new Flutter(context)); add(new Gemini(context)); + add(new Git(context)); } /** 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 80698f6fa6..e2fa9d670a 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 @@ -35,6 +35,7 @@ import com.devonfw.tools.ide.commandlet.CommandletManagerImpl; import com.devonfw.tools.ide.commandlet.ContextCommandlet; import com.devonfw.tools.ide.commandlet.EnvironmentCommandlet; +import com.devonfw.tools.ide.commandlet.InstallCommandlet; import com.devonfw.tools.ide.commandlet.UpgradeCommandlet; import com.devonfw.tools.ide.commandlet.update.UpdateCommandlet; import com.devonfw.tools.ide.common.SystemPath; @@ -76,6 +77,7 @@ import com.devonfw.tools.ide.tool.ToolInstallation; import com.devonfw.tools.ide.tool.custom.CustomToolRepository; import com.devonfw.tools.ide.tool.custom.CustomToolRepositoryImpl; +import com.devonfw.tools.ide.tool.git.Git; import com.devonfw.tools.ide.tool.mvn.MvnRepository; import com.devonfw.tools.ide.tool.npm.NpmRepository; import com.devonfw.tools.ide.tool.pip.PipRepository; @@ -819,10 +821,11 @@ public UrlMetadata getUrls() { if (this.urlMetadata == null) { if (!isTest()) { - getGitContext().pullOrCloneAndResetIfNeeded(IDE_URLS_GIT, getUrlsPath(), null); + updateUrlsRepository(); } this.urlMetadata = new UrlMetadata(this); } + return this.urlMetadata; } @@ -1465,20 +1468,24 @@ private ValidationResult applyAndRun(CliArguments arguments, AbstractCommandlet if (!(cmd instanceof UpgradeCommandlet)) { verifyIdeMinVersion(false); } - Path settingsRepository = getSettingsGitRepository(); - if (settingsRepository != null) { - if (getGitContext().isRepositoryUpdateAvailable(settingsRepository, getSettingsCommitIdPath()) || ( - getGitContext().fetchIfNeeded(settingsRepository) && getGitContext().isRepositoryUpdateAvailable( - settingsRepository, getSettingsCommitIdPath()))) { - - // Inform the user that an update is available. The update message is suppressed if we are already running the update - String msg = determineSettingsUpdateMessage(cmd); - if (msg != null) { - IdeLogLevel.INTERACTION.log(LOG, msg); + + if (!isInstallingGit(cmd)) { + Path settingsRepository = getSettingsGitRepository(); + if (settingsRepository != null) { + if (getGitContext().isRepositoryUpdateAvailable(settingsRepository, getSettingsCommitIdPath()) || ( + getGitContext().fetchIfNeeded(settingsRepository) && getGitContext().isRepositoryUpdateAvailable( + settingsRepository, getSettingsCommitIdPath()))) { + + // Inform the user that an update is available. The update message is suppressed if we are already running the update + String msg = determineSettingsUpdateMessage(cmd); + if (msg != null) { + IdeLogLevel.INTERACTION.log(LOG, msg); + } } } } } + boolean success = ensureLicenseAgreement(cmd); if (!success) { return ValidationResultValid.get(); @@ -1996,4 +2003,28 @@ public String getDefaultWindowsGitPath() { return DEFAULT_WINDOWS_GIT_PATH; } + private boolean isInstallingGit(AbstractCommandlet cmd) { + + if (cmd instanceof InstallCommandlet installCommandlet) { + return installCommandlet.tool.getValue() instanceof Git; + } + return false; + } + + /** + * Updates the local {@code ide-urls} repository or, if Git is not available, falls back to the existing local metadata. + */ + public void updateUrlsRepository() { + + GitContext gitContext = getGitContext(); + + Path urlsPath = getUrlsPath(); + if (gitContext.findGit() != null) { + gitContext.pullOrCloneAndResetIfNeeded(IDE_URLS_GIT, urlsPath, null); + } else if ((urlsPath != null) && Files.isDirectory(urlsPath)) { + LOG.debug("Git is not available. Using existing URL metadata without updating it."); + } else { + gitContext.findGitRequired(); + } + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java b/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java index 7e86b16cd1..5fef1aa14e 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/git/GitContextImpl.java @@ -347,9 +347,12 @@ public Path findGitRequired() { Path gitPath = findGit(); if (gitPath == null) { - String message = "Git " + IdeContext.IS_NOT_INSTALLED_BUT_REQUIRED; + String message = "Git " + IdeContext.IS_NOT_INSTALLED_BUT_REQUIRED + + " Try running `ide install git` to install it."; + if (SystemInfoImpl.INSTANCE.isWindows()) { - message += IdeContext.PLEASE_DOWNLOAD_AND_INSTALL_GIT + ":\n " + IdeContext.WINDOWS_GIT_DOWNLOAD_URL; + message += "\nAlternatively, " + IdeContext.PLEASE_DOWNLOAD_AND_INSTALL_GIT + ":\n " + + IdeContext.WINDOWS_GIT_DOWNLOAD_URL; } throw new CliException(message); } diff --git a/cli/src/main/java/com/devonfw/tools/ide/os/WindowsHelperImpl.java b/cli/src/main/java/com/devonfw/tools/ide/os/WindowsHelperImpl.java index d2c99f9448..c8156bcf97 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/os/WindowsHelperImpl.java +++ b/cli/src/main/java/com/devonfw/tools/ide/os/WindowsHelperImpl.java @@ -3,6 +3,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -144,16 +145,62 @@ private String findUninstallKey(String appName) { if (out == null) { continue; } + for (String line : out) { - line = line.trim(); - if (line.startsWith("HKEY_")) { - return line; // exact registry path (key) for tool + String key = getMatchingUninstallKey(line, appName); + if (key != null) { + return key; } } } + return null; } + /** + * Checks whether the given registry search result represents an uninstallation key whose display name matches the requested application. + * + * @param line the registry search result line to check. + * @param appName the application name to match. + * @return the matching uninstall registry key, or {@code null} if the line does not represent a matching application. + */ + private String getMatchingUninstallKey(String line, String appName) { + + String key = line.trim(); + if (!key.startsWith("HKEY_")) { + return null; + } + + List values = runReg("query", key); + if (values == null) { + return null; + } + + String displayName = retrieveRegString("DisplayName", values); + if (matchesAppName(displayName, appName)) { + return key; + } + + return null; + } + + /** + * Checks whether a Windows application display name matches the requested application name. + * + * @param displayName the application's display name from the Windows registry. + * @param appName the application name to match. + * @return {@code true} if the names match, {@code false} otherwise. + */ + private boolean matchesAppName(String displayName, String appName) { + + if ((displayName == null) || (appName == null)) { + return false; + } + + return displayName.equalsIgnoreCase(appName) + || displayName.toLowerCase(Locale.ROOT).startsWith(appName.toLowerCase(Locale.ROOT) + " "); + } + /** * Executes a Windows registry command and returns its output. * diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java index d9551c53be..b588004224 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/GlobalToolCommandlet.java @@ -144,8 +144,8 @@ protected boolean isExtract() { protected ToolInstallation doInstall(ToolInstallRequest request) { VersionIdentifier resolvedVersion = request.getRequested().getResolvedVersion(); - if (this.context.getSystemInfo().isLinux()) { - // on Linux global tools are typically installed via the package manager of the OS + if (isPackageManagerBasedInstallation()) { + // global tools are typically installed via the package manager of the OS (e.g. apt on Linux, Homebrew on macOS) // if a global tool implements getNativePackages() to return at least one NativePackage, then we will install this way. List commands = getInstallPackageManagerCommands(resolvedVersion); if (!commands.isEmpty()) { @@ -187,7 +187,7 @@ protected ToolInstallation doInstall(ToolInstallRequest request) { fileAccess.extract(target, downloadBinaryPath); executable = fileAccess.findFirst(downloadBinaryPath, Files::isExecutable, false); } - pc = this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable(executable); + pc = this.context.newProcess().errorHandling(ProcessErrorHandling.LOG_WARNING).executable(executable).addArgs(getInstallerArguments()); int exitCode = pc.run(ProcessMode.BACKGROUND_SILENT).getExitCode(); if (tmpDir != null) { fileAccess.delete(tmpDir); @@ -266,7 +266,7 @@ protected Path getMacApplicationsPath() { @Override protected VersionIdentifier resolveVersionForInstall(String edition, GenericVersionRange version) { - if (this.context.getSystemInfo().isLinux() && !getNativePackages().isEmpty()) { + if (isPackageManagerBasedInstallation() && !getNativePackages().isEmpty()) { if (version instanceof VersionIdentifier versionIdentifier && !versionIdentifier.isPattern()) { return versionIdentifier; } @@ -286,6 +286,17 @@ protected List getNativePackages() { return List.of(); } + /** + * @return {@code true} if this tool is installed via the OS native package manager (see {@link #getNativePackages()}), so that the install, version + * resolution and installed-version detection flows of this class go through the package manager instead of the standard installer/artifact flow. + *

+ * By default this is the case on Linux only, as that is where global tools are typically installed via the package manager of the OS. Subclasses may + * override this to support the package manager on further operating systems. + */ + protected boolean isPackageManagerBasedInstallation() { + return this.context.getSystemInfo().isLinux(); + } + /** * @return the {@link List} of {@link PackageManagerCommand}s to use on Linux to install this tool. If empty, no package manager installation will be * triggered on Linux. @@ -530,4 +541,12 @@ private Path findMacApplicationBundle() { public String getMacApplicationName() { return null; } + + /** + * @return the additional arguments passed to the downloaded installer executable (e.g. silent mode flags). The default is an empty {@link List}. Override + * this for tools whose installer requires specific arguments to run unattended. + */ + protected List getInstallerArguments() { + return List.of(); + } } diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/IdeasyCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/tool/IdeasyCommandlet.java index 97f59e2239..0d00e275a0 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/tool/IdeasyCommandlet.java +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/IdeasyCommandlet.java @@ -458,7 +458,13 @@ private void createWindowsShortcut(Path lnkPath, Path targetExe, Path icoPath) { } private void setGitLongpaths() { - this.context.getGitContext().findGitRequired(); + + // Git is optional: a fresh machine may not have Git installed yet (it can be installed via `ide install git`). Enabling long paths is a nice-to-have + // only, so skip it instead of aborting the (possibly automated) IDEasy installation. + if (this.context.getGitContext().findGit() == null) { + LOG.info("Git is not installed, skipping enabling long paths in the git configuration."); + return; + } Path configPath = this.context.getUserHome().resolve(".gitconfig"); FileAccess fileAccess = this.context.getFileAccess(); IniFile iniFile = fileAccess.readIniFile(configPath); diff --git a/cli/src/main/java/com/devonfw/tools/ide/tool/git/Git.java b/cli/src/main/java/com/devonfw/tools/ide/tool/git/Git.java new file mode 100644 index 0000000000..c549ebb3dd --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/tool/git/Git.java @@ -0,0 +1,67 @@ +package com.devonfw.tools.ide.tool.git; + +import java.util.List; +import java.util.Set; + +import com.devonfw.tools.ide.common.Tag; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.tool.GlobalToolCommandlet; +import com.devonfw.tools.ide.tool.NativePackage; +import com.devonfw.tools.ide.tool.NativePackageManager; + +/** + * {@link GlobalToolCommandlet} for Git. + */ +public class Git extends GlobalToolCommandlet { + + /** + * The constructor. + * + * @param context the {@link IdeContext}. + */ + public Git(IdeContext context) { + super(context, "git", Set.of(Tag.GIT)); + } + + @Override + protected String getBinaryName() { + return "git"; + } + + @Override + protected List getNativePackages() { + return List.of( + NativePackage.of(NativePackageManager.APT, "git"), + NativePackage.of(NativePackageManager.ZYPPER, "git"), + NativePackage.of(NativePackageManager.BREW, "git")); + } + + @Override + public String getWindowsRegistryAppName() { + return "Git"; + } + + @Override + protected List getInstallerArguments() { + if (this.context.getSystemInfo().isWindows()) { + return List.of( + "/VERYSILENT", + "/NORESTART", + "/NOCANCEL", + "/SP-" + ); + } + return List.of(); + } + + /** + * On macOS Git is installed via Homebrew (see {@link #getNativePackages()}) rather than via the DMG installer artifact, so the package manager based + * installation flow is used there too - exactly like on Linux. On Windows the standard installer artifact is still used. + * + * @return {@code true} if Git is installed via the OS native package manager. + */ + @Override + protected boolean isPackageManagerBasedInstallation() { + return this.context.getSystemInfo().isLinux() || this.context.getSystemInfo().isMac(); + } +} diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties index 9aac2554e3..fb976de3b5 100644 --- a/cli/src/main/resources/nls/Help.properties +++ b/cli/src/main/resources/nls/Help.properties @@ -55,6 +55,8 @@ cmd.get-version.opt.--configured=print only the configured version cmd.get-version.opt.--installed=print only the installed version cmd.gh=Tool commandlet for GitHub CLI. cmd.gh.detail=GitHub CLI (Command Line Interface) allows to interact with GitHub repositories, issues, and pull requests from the command line. Detailed documentation can be found at https://cli.github.com/manual/ +cmd.git=Tool commandlet for Git. +cmd.git.detail=Git is a distributed version control system for tracking changes in source code. Detailed documentation can be found at https://git-scm.com/doc cmd.go=Tool commandlet for Go (programming language). cmd.go.detail=Go is an open-source programming language for building simple, reliable, and efficient software. Detailed documentation can be found at https://go.dev/doc/ cmd.graalvm=Tool commandlet for GraalVm (Java with native-image). diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties index 2da0b3787d..e8ffa70f49 100644 --- a/cli/src/main/resources/nls/Help_de.properties +++ b/cli/src/main/resources/nls/Help_de.properties @@ -55,6 +55,8 @@ cmd.get-version.opt.--configured=zeigt nur die konfigurierte Version cmd.get-version.opt.--installed=zeigt nur die installierte Version cmd.gh=Werkzeug Kommando für die Github Kommandoschnittstelle. cmd.gh.detail=GitHub CLI (Command Line Interface) ermöglicht die Interaktion mit GitHub-Repositories, Issues und Pull Requests über die Befehlszeile. Detaillierte Dokumentation ist zu finden unter https://cli.github.com/manual/ +cmd.git=Werkzeug-Kommando für Git. +cmd.git.detail=Git ist ein verteiltes Versionsverwaltungssystem zum Nachverfolgen von Änderungen am Quellcode. Ausführliche Dokumentation ist unter https://git-scm.com/doc verfügbar. cmd.go=Werkzeug Kommando für Go (Programmiersprache). cmd.go.detail=Go ist eine Open-Source-Programmiersprache zum Erstellen einfacher, zuverlässiger und effizienter Software. Detaillierte Dokumentation ist zu finden unter https://go.dev/doc/ cmd.graalvm=Werkzeug Kommando für GraalVm. diff --git a/cli/src/test/java/com/devonfw/tools/ide/git/GitOperationTest.java b/cli/src/test/java/com/devonfw/tools/ide/git/GitOperationTest.java index 9dce45b5a5..3eff2f9df2 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/git/GitOperationTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/git/GitOperationTest.java @@ -214,6 +214,61 @@ void testPullOrCloneSkippedIfRepoNotInitializedAndOfflineMode(@TempDir Path temp Mockito.verify(mock).pullOrClone(GIT_URL, repo); } + /** + * Verifies that existing URL metadata is reused when Git is not available but the local URL repository already exists. + */ + @Test + void testUpdateUrlsRepositoryUsesExistingUrlsWithoutGit() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, false); + GitContext mock = Mockito.mock(GitContext.class); + context.setGitContext(mock); + + Mockito.when(mock.findGit()).thenReturn(null); + + Path urlsPath = context.getUrlsPath(); + context.getFileAccess().mkdirs(urlsPath); + + // act + context.updateUrlsRepository(); + + // assert + assertThat(context).logAtDebug() + .hasMessage("Git is not available. Using existing URL metadata without updating it."); + + Mockito.verify(mock).findGit(); + Mockito.verify(mock, Mockito.never()).findGitRequired(); + Mockito.verify(mock, Mockito.never()) + .pullOrCloneAndResetIfNeeded(Mockito.any(), Mockito.any(), Mockito.any()); + } + + /** + * Verifies that Git is required when Git is not available and no local URL repository exists. + */ + @Test + void testUpdateUrlsRepositoryRequiresGitWithoutExistingUrls() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC, null, true); + + GitContext mock = Mockito.mock(GitContext.class); + context.setGitContext(mock); + Mockito.when(mock.findGit()).thenReturn(null); + + Path urlsPath = context.getUrlsPath(); + context.getFileAccess().delete(urlsPath); + + // act + context.updateUrlsRepository(); + + // assert + Mockito.verify(mock).findGit(); + Mockito.verify(mock).findGitRequired(); + Mockito.verify(mock, Mockito.never()) + .pullOrCloneAndResetIfNeeded(Mockito.any(), Mockito.any(), Mockito.any()); + } + private Path createFakeGitRepo(Path dir, String file) throws Exception { return createFakeGitRepo(dir, file, false); diff --git a/cli/src/test/java/com/devonfw/tools/ide/os/WindowsHelperImplTest.java b/cli/src/test/java/com/devonfw/tools/ide/os/WindowsHelperImplTest.java index d2779ccaea..448528058f 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/os/WindowsHelperImplTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/os/WindowsHelperImplTest.java @@ -119,6 +119,7 @@ protected List runReg(String... args) { if (args.length >= 2 && args[0].equalsIgnoreCase("query") && args[1].endsWith("\\Uninstall\\TestApp")) { return List.of( "HKEY_LOCAL_MACHINE\\SOFTWARE\\...\\Uninstall\\TestApp", + " DisplayName REG_SZ TestApp", " DisplayVersion REG_SZ 2.0.0", " InstallLocation REG_SZ C:\\Program Files\\TestApp" ); @@ -194,6 +195,7 @@ protected List runReg(String... args) { if (args.length >= 2 && args[0].equalsIgnoreCase("query")) { return List.of( "HKEY_LOCAL_MACHINE\\...\\Uninstall\\TestApp", + " DisplayName REG_SZ TestApp", " DisplayVersion REG_SZ ", " DisplayIcon REG_SZ " ); @@ -308,4 +310,111 @@ void testUninstallApplicationDoesNothingWithoutUninstallString() { // assert assertThat(helper.getExecutedUninstallCommand()).isNull(); } + + /** + * Tests that a registry search result with a different DisplayName is ignored. + */ + @Test + void testGetAppInstallationFromRegistryIgnoresNonMatchingDisplayName() { + AbstractIdeTestContext context = new IdeTestContext(); + WindowsHelperImpl helper = new WindowsHelperImpl(context) { + @Override + protected List runReg(String... args) { + if (args.length >= 5 && "/f".equalsIgnoreCase(args[3])) { + return List.of( + "HKEY_LOCAL_MACHINE\\SOFTWARE\\...\\Uninstall\\GitLFS"); + } + + if (args.length >= 2 + && args[0].equalsIgnoreCase("query") + && args[1].endsWith("\\Uninstall\\GitLFS")) { + return List.of( + "HKEY_LOCAL_MACHINE\\SOFTWARE\\...\\Uninstall\\GitLFS", + " DisplayName REG_SZ GitHub Desktop"); + } + + return List.of(); + } + }; + + WindowsAppInstallation installation = + helper.getAppInstallationFromRegistry("Git"); + + assertThat(installation).isNull(); + } + + /** + * Tests that a DisplayName containing the application name followed by a suffix is accepted. + */ + @Test + void testGetAppInstallationFromRegistryMatchesDisplayNameWithSuffix() { + AbstractIdeTestContext context = new IdeTestContext(); + WindowsHelperImpl helper = new WindowsHelperImpl(context) { + @Override + protected List runReg(String... args) { + if (args.length >= 5 && "/f".equalsIgnoreCase(args[3])) { + return List.of( + "HKEY_LOCAL_MACHINE\\SOFTWARE\\...\\Uninstall\\Git"); + } + + if (args.length >= 2 + && args[0].equalsIgnoreCase("query") + && args[1].endsWith("\\Uninstall\\Git")) { + return List.of( + "HKEY_LOCAL_MACHINE\\SOFTWARE\\...\\Uninstall\\Git", + " DisplayName REG_SZ Git version 2.55.0", + " DisplayVersion REG_SZ 2.55.0"); + } + + return List.of(); + } + }; + + WindowsAppInstallation installation = + helper.getAppInstallationFromRegistry("Git"); + + assertThat(installation).isNotNull(); + assertThat(installation.version()).isEqualTo("2.55.0"); + } + + /** + * Tests that registry lookup continues with the next registry base path if a query fails. + */ + @Test + void testGetAppInstallationFromRegistryContinuesAfterRegistryQueryFailure() { + AbstractIdeTestContext context = new IdeTestContext(); + + WindowsHelperImpl helper = new WindowsHelperImpl(context) { + private int searchCount; + + @Override + protected List runReg(String... args) { + if (args.length >= 5 && "/f".equalsIgnoreCase(args[3])) { + this.searchCount++; + + if (this.searchCount == 1) { + return null; + } + + return List.of( + "HKEY_LOCAL_MACHINE\\SOFTWARE\\...\\Uninstall\\TestApp"); + } + + if (args.length >= 2 && args[0].equalsIgnoreCase("query")) { + return List.of( + "HKEY_LOCAL_MACHINE\\SOFTWARE\\...\\Uninstall\\TestApp", + " DisplayName REG_SZ TestApp", + " DisplayVersion REG_SZ 1.0"); + } + + return List.of(); + } + }; + + WindowsAppInstallation installation = + helper.getAppInstallationFromRegistry("TestApp"); + + assertThat(installation).isNotNull(); + assertThat(installation.version()).isEqualTo("1.0"); + } } diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/IdeasyCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/IdeasyCommandletTest.java index e0164e533c..d8789db6a0 100644 --- a/cli/src/test/java/com/devonfw/tools/ide/tool/IdeasyCommandletTest.java +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/IdeasyCommandletTest.java @@ -7,10 +7,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.Mockito; 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.git.GitContext; import com.devonfw.tools.ide.io.FileAccess; import com.devonfw.tools.ide.io.FileAccessImpl; import com.devonfw.tools.ide.migration.v2026.Mig202610001; @@ -81,6 +83,10 @@ void testInstallIdeasy(String os) { context.setIdeRoot(null); context.setSystemInfo(systemInfo); context.getStartContext().setForceMode(true); + // Git longpaths is only configured when Git is installed; model that Git is present for this test + GitContext mockGitContext = Mockito.mock(GitContext.class); + Mockito.when(mockGitContext.findGit()).thenReturn(Path.of("git")); + context.setGitContext(mockGitContext); WindowsHelper helper = context.getWindowsHelper(); String originalPath = helper.getUserEnvironmentValue("PATH"); if (systemInfo.isWindows()) { @@ -211,6 +217,10 @@ void testInstallIdeasyTwiceDoesNotDuplicateGitLongpaths() { context.setIdeRoot(null); context.setSystemInfo(systemInfo); context.getStartContext().setForceMode(true); + // Git longpaths is only configured when Git is installed; model that Git is present for this test + GitContext mockGitContext = Mockito.mock(GitContext.class); + Mockito.when(mockGitContext.findGit()).thenReturn(Path.of("git")); + context.setGitContext(mockGitContext); Path gitconfigPath = context.getUserHome().resolve(".gitconfig"); context.getFileAccess().copy(Path.of("src/main/package/gui"), context.getUserHome().resolve("Downloads/ide-cli")); IdeasyCommandlet ideasy = new IdeasyCommandlet(context); diff --git a/cli/src/test/java/com/devonfw/tools/ide/tool/git/GitTest.java b/cli/src/test/java/com/devonfw/tools/ide/tool/git/GitTest.java new file mode 100644 index 0000000000..99b07c704e --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/tool/git/GitTest.java @@ -0,0 +1,254 @@ +package com.devonfw.tools.ide.tool.git; + +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.devonfw.tools.ide.context.AbstractIdeContextTest; +import com.devonfw.tools.ide.context.IdeTestContext; +import com.devonfw.tools.ide.os.SystemInfoMock; +import com.devonfw.tools.ide.os.WindowsAppInstallation; +import com.devonfw.tools.ide.os.WindowsHelperMock; +import com.devonfw.tools.ide.process.OutputMessage; +import com.devonfw.tools.ide.process.ProcessContext; +import com.devonfw.tools.ide.process.ProcessMode; +import com.devonfw.tools.ide.process.ProcessResult; +import com.devonfw.tools.ide.process.ProcessResultImpl; +import com.devonfw.tools.ide.tool.EditionAndVersion; +import com.devonfw.tools.ide.tool.NativePackageManager; +import com.devonfw.tools.ide.version.VersionIdentifier; + +/** + * Test of {@link Git}. + */ +class GitTest extends AbstractIdeContextTest { + + private static final String TOOL_VERSION = "2.45.0"; + + /** The version query command the package manager based version detection issues on macOS (see {@link NativePackageManager#BREW}). */ + private static final String BREW_VERSION_QUERY = "brew list --versions git"; + + /** The version query command the package manager based version detection issues on Linux (see {@link NativePackageManager#APT}). */ + private static final String APT_VERSION_QUERY = "dpkg-query -W -f=${db:Status-Status}|${Version} git"; + + /** + * A {@link Git} that reports only the given native package managers as available, so a specific OS flow (apt on Linux, brew on macOS) is exercised. + * + * @param context the {@link IdeTestContext}. + * @param availablePackageManagers the package managers {@code isPackageManagerAvailable} should report as available. + * @return the {@link Git}. + */ + private static Git git(IdeTestContext context, NativePackageManager... availablePackageManagers) { + + return new Git(context) { + @Override + protected boolean isPackageManagerAvailable(NativePackageManager packageManager) { + return Arrays.asList(availablePackageManagers).contains(packageManager); + } + }; + } + + /** + * Returns a mocked {@link ProcessContext} (as returned by {@code newProcess()}) whose version query yields the given single-line output, so that the package + * manager based version detection can be simulated without running the real package manager. + * + * @param context the {@link IdeTestContext}. + * @param command the command whose output should be simulated (for readability of the returned result). + * @param output the single output line the version query should yield, or {@code null} to simulate that nothing is installed. + * @return the mocked {@link ProcessContext}. + */ + private ProcessContext processContextWithVersionOutput(IdeTestContext context, String command, String output) { + + ProcessContext processContext = Mockito.mock(ProcessContext.class, Mockito.RETURNS_SELF); + List outputMessages = (output == null) ? List.of() : List.of(new OutputMessage(false, output)); + ProcessResult result = new ProcessResultImpl("bash", command, ProcessResult.SUCCESS, true, outputMessages); + Mockito.when(processContext.run(ProcessMode.DEFAULT_CAPTURE)).thenReturn(result); + context.setProcessContext(processContext); + return processContext; + } + + /** + * Verifies that Git declares a Homebrew formula as one of its native packages, so that on macOS it can be installed via {@code brew} the same way it is + * installed via {@code apt}/{@code zypper} on Linux. + */ + @Test + void testGetNativePackagesContainsBrew() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + Git git = new Git(context); + + // act + assert + assertThat(git.getNativePackages().stream().map(pkg -> pkg.getPackageManager())).contains(NativePackageManager.BREW); + } + + /** + * Verifies the binary name of Git. + */ + @Test + void testGetBinaryName() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + Git git = new Git(context); + + // act + assert + assertThat(git.getBinaryName()).isEqualTo("git"); + } + + /** + * Verifies that the Windows registry app name of Git is {@code "Git"} (which differs from the lower case tool name). + */ + @Test + void testGetWindowsRegistryAppName() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + Git git = new Git(context); + + // act + assert + assertThat(git.getWindowsRegistryAppName()).isEqualTo("Git"); + assertThat(git.getWindowsRegistryAppNames()).containsEntry("git", "Git"); + } + + /** + * Verifies that the installed Git edition and version are resolved from Homebrew ({@code brew list --versions git}) on macOS, mirroring the Linux flow. + */ + @Test + void testGetInstalledEditionAndVersionOnMac() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + processContextWithVersionOutput(context, BREW_VERSION_QUERY, "git " + TOOL_VERSION); + Git git = git(context, NativePackageManager.BREW); + + // act + EditionAndVersion editionAndVersion = git.getInstalledEditionAndVersion(); + + // assert + assertThat(editionAndVersion).isNotNull(); + assertThat(editionAndVersion.edition()).isEqualTo("git"); + assertThat(editionAndVersion.version()).isEqualTo(VersionIdentifier.of(TOOL_VERSION)); + } + + /** + * Verifies that on macOS the installed Git version is {@code null} when no git formula is installed. + */ + @Test + void testGetInstalledEditionAndVersionOnMacWhenNotInstalled() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.MAC_X64); + processContextWithVersionOutput(context, BREW_VERSION_QUERY, null); + Git git = git(context, NativePackageManager.BREW); + + // act + assert + assertThat(git.getInstalledEditionAndVersion()).isNull(); + assertThat(git.getInstalledVersion()).isNull(); + } + + /** + * Verifies that the installed Git edition and version are resolved from apt ({@code dpkg-query}) on Linux. + */ + @Test + void testGetInstalledEditionAndVersionOnLinux() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + processContextWithVersionOutput(context, APT_VERSION_QUERY, "installed|" + TOOL_VERSION); + Git git = git(context, NativePackageManager.APT); + + // act + EditionAndVersion editionAndVersion = git.getInstalledEditionAndVersion(); + + // assert + assertThat(editionAndVersion).isNotNull(); + assertThat(editionAndVersion.edition()).isEqualTo("git"); + assertThat(editionAndVersion.version()).isEqualTo(VersionIdentifier.of(TOOL_VERSION)); + } + + /** + * Verifies that on Linux the installed Git version is {@code null} when git is not installed (the apt version query yields no installed version). + */ + @Test + void testGetInstalledEditionAndVersionOnLinuxWhenNotInstalled() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.LINUX_X64); + processContextWithVersionOutput(context, APT_VERSION_QUERY, null); + Git git = git(context, NativePackageManager.APT); + + // act + assert + assertThat(git.getInstalledEditionAndVersion()).isNull(); + assertThat(git.getInstalledVersion()).isNull(); + } + + /** + * Verifies that on Windows the installed Git edition and version are resolved from the Windows registry using the app name {@code Git}. + */ + @Test + void testGetInstalledEditionAndVersionOnWindows() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + WindowsHelperMock helper = (WindowsHelperMock) context.getWindowsHelper(); + helper.setAppInstallationFromRegistry("Git", new WindowsAppInstallation(TOOL_VERSION, null, null, null)); + Git git = new Git(context); + + // act + EditionAndVersion editionAndVersion = git.getInstalledEditionAndVersion(); + + // assert + assertThat(editionAndVersion).isNotNull(); + assertThat(editionAndVersion.edition()).isEqualTo("git"); + assertThat(editionAndVersion.version()).isEqualTo(VersionIdentifier.of(TOOL_VERSION)); + } + + /** + * Verifies that on Windows Git is not installed via a package manager but via the standard installer artifact, so the installer is launched with the expected + * silent arguments (see {@link Git#getInstallerArguments()}). + */ + @Test + void testGetInstallerArgumentsOnWindows() { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + context.setSystemInfo(SystemInfoMock.WINDOWS_X64); + Git git = new Git(context); + + //act + List gitArguments = git.getInstallerArguments(); + + // assert + assertThat(gitArguments).containsExactly("/VERYSILENT", "/NORESTART", "/NOCANCEL", "/SP-"); + } + + /** + * Verifies that on macOS and Linux Git is installed via the package manager, so no installer arguments are produced. + */ + @Test + void testGetInstallerArgumentsEmptyOnMacAndLinux() { + + // arrange + IdeTestContext macContext = newContext(PROJECT_BASIC); + macContext.setSystemInfo(SystemInfoMock.MAC_X64); + + IdeTestContext linuxContext = newContext(PROJECT_BASIC); + linuxContext.setSystemInfo(SystemInfoMock.LINUX_X64); + + //act + List macInstallerArguments = new Git(macContext).getInstallerArguments(); + List linuxInstallerArguments = new Git(linuxContext).getInstallerArguments(); + + //assert + assertThat(macInstallerArguments).isEmpty(); + assertThat(linuxInstallerArguments).isEmpty(); + } +} diff --git a/documentation/LICENSE.adoc b/documentation/LICENSE.adoc index 8e2315edd3..28f6897174 100644 --- a/documentation/LICENSE.adoc +++ b/documentation/LICENSE.adoc @@ -100,6 +100,7 @@ The column `inclusion` indicates the way the component is included: |https://dotnet.microsoft.com/[Dotnet]|Optional|https://github.com/dotnet/core/blob/master/LICENSE.TXT[MIT] (https://www.microsoft.com/en-us/legal/intellectualproperty/copyright/default.aspx[Terms]) |https://github.com/pypa/pip/[Pip] |Optional|https://github.com/pypa/pip/blob/main/LICENSE.txt[MIT] |https://github.com/openshift/oc[OpenShiftCLI]|Optional|https://github.com/openshift/oc/blob/master/LICENSE[Apache 2.0] +|https://git-scm.com/[Git]|Optional|https://github.com/git/git/blob/master/COPYING[GPL 2.0] |https://github.com/cli/cli/[GitHubCLI]|Optional|https://github.com/cli/cli/blob/trunk/LICENSE[MIT] |https://quarkus.io/guides/cli-tooling[QuarkusCLI]|Optional|https://github.com/quarkusio/quarkus/blob/main/LICENSE.txt[Apache 2.0] |https://cloud.google.com/sdk/gcloud[GCloudCLI]|Optional|https://github.com/twistedpair/google-cloud-sdk/blob/master/google-cloud-sdk/LICENSE[Apache 2.0]