diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 7c28a1e05b..42f92cc983 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -9,6 +9,7 @@ Release with new features and bugfixes: * https://github.com/devonfw/IDEasy/issues/2176[#2176]: Support 7z archive extraction * https://github.com/devonfw/IDEasy/issues/2100[#2100]: Fix Python not available for Mac x64 * https://github.com/devonfw/IDEasy/issues/2134[#2134]: Add auto-completion for icd command +* https://github.com/devonfw/IDEasy/issues/1953[#1953]: Implement base functionality of cleanup commandlet The full list of changes for this release can be found in https://github.com/devonfw/IDEasy/milestone/48?closed=1[milestone 2026.08.001]. 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 56cd9fa3cf..ee6c6bbf84 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 @@ -13,6 +13,7 @@ import com.devonfw.tools.ide.cli.CliArgument; import com.devonfw.tools.ide.cli.CliArguments; +import com.devonfw.tools.ide.commandlet.cleanup.CleanupCommandlet; import com.devonfw.tools.ide.completion.CompletionCandidateCollector; import com.devonfw.tools.ide.context.IdeContext; import com.devonfw.tools.ide.git.repository.RepositoryCommandlet; @@ -118,6 +119,7 @@ public CommandletManagerImpl(IdeContext context) { add(new UninstallCommandlet(context)); add(new LnCommandlet(context)); add(new UpdateCommandlet(context)); + add(new CleanupCommandlet(context)); add(new UpgradeSettingsCommandlet(context)); add(new CreateCommandlet(context)); add(new BuildCommandlet(context)); diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/AbstractInstalledSoftwareItem.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/AbstractInstalledSoftwareItem.java new file mode 100644 index 0000000000..31ece0b728 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/AbstractInstalledSoftwareItem.java @@ -0,0 +1,43 @@ +package com.devonfw.tools.ide.commandlet.cleanup; + +import java.nio.file.Path; + +/** + * Abstract base class for an item in the installed software hierarchy. + */ +public abstract class AbstractInstalledSoftwareItem { + + /** The name of this item. */ + private final String name; + + /** The installation path of this item. */ + private final Path path; + + /** + * Constructor. + * + * @param name the name of this item. + * @param path the installation path of this item. + */ + protected AbstractInstalledSoftwareItem(String name, Path path) { + + this.name = name; + this.path = path; + } + + /** + * @return the name of this item. + */ + public String getName() { + + return this.name; + } + + /** + * @return the installation path of this item. + */ + public Path getPath() { + + return this.path; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java new file mode 100644 index 0000000000..8ffa92648a --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/CleanupCommandlet.java @@ -0,0 +1,347 @@ +package com.devonfw.tools.ide.commandlet.cleanup; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.devonfw.tools.ide.commandlet.Commandlet; +import com.devonfw.tools.ide.context.IdeContext; +import com.devonfw.tools.ide.log.IdeLogLevel; +import com.devonfw.tools.ide.step.Step; +import com.devonfw.tools.ide.tool.mvn.MvnRepository; +import com.devonfw.tools.ide.tool.repository.ToolRepository; + +/** + * Commandlet which scans your IDE installation for unused software (tools not currently used by any project) and removes them. + */ +public class CleanupCommandlet extends Commandlet { + + private static final Logger LOG = LoggerFactory.getLogger(CleanupCommandlet.class); + + /** + * Constructor. + * + * @param context the {@link IdeContext}. + */ + public CleanupCommandlet(IdeContext context) { + + super(context); + addKeyword(getName()); + } + + @Override + public String getName() { + + return "cleanup"; + } + + @Override + public boolean isIdeHomeRequired() { + + return false; + } + + @Override + protected void doRun() { + + LOG.debug("Start cleanup commandlet"); + + // Identify and remove unused tools. + Step step = context.newStep("Identify and remove unused software"); + step.run(this::discoverAndDeleteUnusedSoftware); + + LOG.debug("Finished cleanup commandlet"); + } + + /** + * This method specifies the primary flow for the discovery of installed and unused software, and its deletion. + */ + private void discoverAndDeleteUnusedSoftware() { + // Iterate over software in $IDE_ROOT/_ide/software repositories and save installed software to a list + List installedSoftwareTools = discoverInstalledSoftware(); + + // Scan for IDEasy projects + List ideasyProjects = this.context.findProjects(); + + // Iterate through IDEasy projects and scan software in software folder. Save found software to list + for (Path ideasyProject : ideasyProjects) { + String projectName = ideasyProject.getFileName().toString(); + Path ideasyProjectSoftware = ideasyProject.resolve(IdeContext.FOLDER_SOFTWARE); + discoverUsedSoftware(installedSoftwareTools, ideasyProjectSoftware, projectName); + discoverUsedSoftware(installedSoftwareTools, ideasyProjectSoftware.resolve(IdeContext.FOLDER_EXTRA), projectName); + } + + // Mark unused software for deletion + markUnusedSoftwareForDeletion(installedSoftwareTools); + // Log summary report and proceed with deletion if user confirms + logSoftwareToBeDeleted(installedSoftwareTools); + } + + /** + * Discovers all installed tools in the default, Maven, and custom software repositories. + * + * @return the list of discovered installed tools. + */ + private List discoverInstalledSoftware() { + + List installedSoftwareTools = new ArrayList<>(); + Path softwareRepositoryPath = this.context.getSoftwareRepositoryPath(); + + discoverInstalledSoftwareRepository(installedSoftwareTools, + softwareRepositoryPath.resolve(ToolRepository.ID_DEFAULT)); + + discoverInstalledSoftwareRepository(installedSoftwareTools, + softwareRepositoryPath.resolve(MvnRepository.ID)); + + discoverInstalledSoftwareRepository(installedSoftwareTools, + softwareRepositoryPath.resolve(this.context.getCustomToolRepository().getId())); + + return installedSoftwareTools; + } + + /** + * This method discovers all installed tools in one software repository. Installed editions are then recursively discovered. + * + * @param installedSoftwareTools the list to populate with discovered tools. + * @param repositoryFolder The software repository folder to scan. + */ + private void discoverInstalledSoftwareRepository(List installedSoftwareTools, Path repositoryFolder) { + if (!Files.isDirectory(repositoryFolder)) { + return; + } + List toolFolders = this.context.getFileAccess().listChildren(repositoryFolder, Files::isDirectory); + for (Path toolFolder : toolFolders) { + Path toolPath = this.context.getFileAccess().toRealPath(toolFolder); + InstalledSoftwareTool tool = new InstalledSoftwareTool(toolFolder.getFileName().toString(), toolPath); + installedSoftwareTools.add(tool); + discoverInstalledEditions(toolFolder, tool); + } + } + + /** + * This method discovers all installed editions of a tool at $IDE_ROOT/_ide/software// and saves them to the edition list of the tool. + * Installed versions of the edition are then recursively discovered. + * + * @param toolFolder The folder where the editions are saved in ($IDE_ROOT/_ide/software//). + * @param tool The respective tool for which we are discovering editions. + */ + private void discoverInstalledEditions(Path toolFolder, InstalledSoftwareTool tool) { + List editionFolders = this.context.getFileAccess().listChildren(toolFolder, Files::isDirectory); + for (Path editionFolder : editionFolders) { + Path editionPath = this.context.getFileAccess().toRealPath(editionFolder); + InstalledSoftwareEdition edition = new InstalledSoftwareEdition(editionFolder.getFileName().toString(), editionPath); + tool.getEditions().add(edition); + discoverInstalledVersions(editionFolder, edition); + } + } + + /** + * This method discovers all installed versions of an edition of a tool at $IDE_ROOT/_ide/software/// and saves them to the version + * list of the edition. + * + * @param editionFolder The folder where the versions are saved in ($IDE_ROOT/_ide/software///). + * @param edition The respective edition for which we are discovering versions. + */ + private void discoverInstalledVersions(Path editionFolder, InstalledSoftwareEdition edition) { + List versionFolders = this.context.getFileAccess().listChildren(editionFolder, Files::isDirectory); + for (Path versionFolder : versionFolders) { + Path versionPath = this.context.getFileAccess().toRealPath(versionFolder); + InstalledSoftwareVersion version = new InstalledSoftwareVersion(versionFolder.getFileName().toString(), versionPath); + edition.getVersions().add(version); + } + } + + /** + * This method scans the software folder of an IDEasy project for installed tools and matches these against the global tool list created earlier. Identified + * tools are marked as used in the global tool list. + * + * @param installedSoftwareTools the list of installed tools to check against. + * @param softwareFolder The software folder of the IDEasy project to scan for used software. + * @param projectName The name of the project we are currently scanning. + */ + private void discoverUsedSoftware(List installedSoftwareTools, Path softwareFolder, String projectName) { + discoverUsedSoftware(installedSoftwareTools, softwareFolder, projectName, 0); + } + + /** + * This method scans the software folder of an IDEasy project recursively for installed tools and matches these against the global tool list created earlier. + * + * @param installedSoftwareTools the list of installed tools to check against. + * @param softwareFolder The software folder of the IDEasy project to scan for used software. + * @param projectName The name of the project we are currently scanning. + * @param depth The current recursion depth. + */ + private void discoverUsedSoftware(List installedSoftwareTools, Path softwareFolder, String projectName, int depth) { + // Get all installed tools for this project + List subfolders = this.context.getFileAccess().listChildren(softwareFolder, Files::isDirectory); + for (Path currentFolder : subfolders) { + // Converts the path of the tool installation to the real path by eliminating symlinks. This allows us to determine + // whether a tool is installed locally for an IDEasy project or is part + // of the global software installation under $IDE_ROOT/_ide/software + Path referencedPath = this.context.getFileAccess().toRealPath(currentFolder); + // Check if the resolved project software path belongs to a discovered global software version. + boolean matchingVersionFound = markMatchingVersionAsUsed(installedSoftwareTools, referencedPath, projectName); + // For ide-extra-tools.json the actual software link may be located at software/extra//. + if (!matchingVersionFound && depth < 1 && !Files.isSymbolicLink(currentFolder)) { + discoverUsedSoftware(installedSoftwareTools, currentFolder, projectName, depth + 1); + } + } + } + + /** + * Marks the installed version containing the referenced project software path as used. The referenced path may point directly to a version folder or to one + * of its subfolders. + * + * @param installedSoftwareTools the list of installed tools to check against. + * @param referencedPath The resolved path referenced by the IDEasy project. + * @param projectName The name of the project we are currently scanning. + * @return {@code true} if a matching installed version was found. + */ + private boolean markMatchingVersionAsUsed(List installedSoftwareTools, Path referencedPath, String projectName) { + for (InstalledSoftwareTool tool : installedSoftwareTools) { + for (InstalledSoftwareEdition edition : tool.getEditions()) { + for (InstalledSoftwareVersion version : edition.getVersions()) { + Path versionPath = version.getPath(); + if (referencedPath.equals(versionPath) || referencedPath.startsWith(versionPath)) { + version.addUsedBy(projectName); + return true; + } + } + } + } + return false; + } + + /** + * Sets the delete flag for all unused software versions to {@code true}. + * + * @param installedSoftwareTools the list of installed tools containing the versions to mark. + */ + private void markUnusedSoftwareForDeletion(List installedSoftwareTools) { + for (InstalledSoftwareTool tool : installedSoftwareTools) { + for (InstalledSoftwareEdition edition : tool.getEditions()) { + for (InstalledSoftwareVersion version : edition.getVersions()) { + if (version.isUnused()) { + version.setDelete(true); + } + } + } + } + } + + /** + * Generates a summary report for software versions to be deleted and prompts the user for confirmation. If the user agrees, the unused software versions and + * empty parent folders are deleted. + * + * @param installedSoftwareTools the list of installed tools containing versions with deletion flags. + */ + private void logSoftwareToBeDeleted(List installedSoftwareTools) { + String logOutput = ""; + int totalAffectedTools = 0; + int totalAffectedEditions = 0; + int totalVersionsDeleted = 0; + for (InstalledSoftwareTool tool : installedSoftwareTools) { + String logOutputEdition = ""; + for (InstalledSoftwareEdition edition : tool.getEditions()) { + String logOutputVersion = ""; + int versionsDeleted = 0; + for (InstalledSoftwareVersion version : edition.getVersions()) { + if (version.isDelete()) { + logOutputVersion += "\t\t - " + version.getName() + "\n"; + versionsDeleted++; + totalVersionsDeleted++; + } + } + if (!logOutputVersion.isBlank()) { + // If at least one version of the edition should be deleted + if (versionsDeleted < edition.getVersions().size()) { + logOutputVersion += "\t\t + " + (edition.getVersions().size() - versionsDeleted) + " more version(s) of this edition will not be deleted\n"; + } + logOutputEdition += "\t - " + edition.getName() + "\n" + logOutputVersion; + totalAffectedEditions++; + } + } + if (!logOutputEdition.isBlank()) { + logOutput += " - " + tool.getName() + "\n" + logOutputEdition; + totalAffectedTools++; + } + } + + if (logOutput.isBlank()) { + LOG.info("No installed tools will be deleted. All installed software is used by at least one project."); + } else { + LOG.info("The following installed tool versions will be deleted: \n" + logOutput); + LOG.info("Summary: {} installed tool versions across {} affected editions of {} affected tools will be deleted.", totalVersionsDeleted, + totalAffectedEditions, totalAffectedTools); + + // Ask for confirmation. Automatically confirmed in batch mode with the global force option. + this.context.askToContinue("Do you want to continue?"); + deleteUnusedSoftware(installedSoftwareTools); + } + } + + /** + * Deletes software versions marked for deletion and removes their parent edition and tool folders if they become empty. + * + * @param installedSoftwareTools the list of installed tools containing the versions to delete. + */ + private void deleteUnusedSoftware(List installedSoftwareTools) { + int failedDeletion = 0; + for (InstalledSoftwareTool tool : installedSoftwareTools) { + for (InstalledSoftwareEdition edition : tool.getEditions()) { + for (InstalledSoftwareVersion version : edition.getVersions()) { + if (version.isDelete()) { + LOG.debug("Deleting version {} of edition {} of tool {} in {}", version.getName(), edition.getName(), tool.getName(), version.getPath()); + failedDeletion += deleteFolder(version.getPath()); + } + } + if (isEmptyFolder(edition.getPath())) { + LOG.debug("Deleting empty edition {} of tool {} in {}", edition.getName(), tool.getName(), edition.getPath()); + failedDeletion += deleteFolder(edition.getPath()); + } + } + if (isEmptyFolder(tool.getPath())) { + LOG.debug("Deleting empty tool {} in {}", tool.getName(), tool.getPath()); + failedDeletion += deleteFolder(tool.getPath()); + } + } + + // Log completion message + if (failedDeletion > 0) { + LOG.warn("Unused tools have been deleted.\nFailed to delete {} tools/editions/versions. Please check the log for details.", failedDeletion); + } else { + IdeLogLevel.SUCCESS.log(LOG, "Unused tools have been deleted successfully."); + } + } + + /** + * Checks whether the given folder exists and has no remaining children. + * + * @param folder the folder to check. + * @return {@code true} if the folder exists and is empty. + */ + private boolean isEmptyFolder(Path folder) { + + return Files.isDirectory(folder) && this.context.getFileAccess().listChildren(folder, child -> true).isEmpty(); + } + + /** + * Deletes a folder at a given path. Logs an error message if unsuccessful. + * + * @param path The path of the folder to delete. + * @return 0 if deletion was successful, 1 if deletion failed. + */ + private int deleteFolder(Path path) { + try { + this.context.getFileAccess().delete(path); + } catch (Exception e) { + LOG.error("Failed to delete {}: {}", path, e.getMessage()); + return 1; + } + return 0; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/InstalledSoftwareEdition.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/InstalledSoftwareEdition.java new file mode 100644 index 0000000000..1cf6619b94 --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/InstalledSoftwareEdition.java @@ -0,0 +1,43 @@ +package com.devonfw.tools.ide.commandlet.cleanup; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents an edition of an IDE tool in the global software folder as discovered by the {@code cleanup} commandlet. + *

+ * For example, for IntelliJ, the editions could be "community" and "ultimate". This class holds a list of + * {@link InstalledSoftwareVersion versions} belonging to this edition. + */ +public class InstalledSoftwareEdition extends AbstractInstalledSoftwareItem { + + /** A list of {@link InstalledSoftwareVersion versions} belonging to this edition. */ + private final List versions; + + /** + * Constructor. + * + * @param name the name of the edition. + * @param path the installation {@link Path} of this edition. + */ + public InstalledSoftwareEdition(String name, Path path) { + + super(name, path); + this.versions = new ArrayList<>(); + } + + /** + * @return the list of {@link InstalledSoftwareVersion versions} belonging to this edition. + */ + public List getVersions() { + + return this.versions; + } + + @Override + public String toString() { + + return "InstalledSoftwareEdition[" + getName() + "]"; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/InstalledSoftwareTool.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/InstalledSoftwareTool.java new file mode 100644 index 0000000000..c6960f450a --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/InstalledSoftwareTool.java @@ -0,0 +1,42 @@ +package com.devonfw.tools.ide.commandlet.cleanup; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents an installed IDE tool in the global software folder as discovered by the {@code cleanup} commandlet. + *

+ * Contains a list of {@link InstalledSoftwareEdition editions} belonging to this tool. + */ +public class InstalledSoftwareTool extends AbstractInstalledSoftwareItem { + + /** A list of {@link InstalledSoftwareEdition editions} belonging to this tool. */ + private final List editions; + + /** + * Constructor. + * + * @param name the name of the tool. + * @param path the installation {@link Path} of this tool. + */ + public InstalledSoftwareTool(String name, Path path) { + + super(name, path); + this.editions = new ArrayList<>(); + } + + /** + * @return the list of {@link InstalledSoftwareEdition editions} belonging to this tool. + */ + public List getEditions() { + + return this.editions; + } + + @Override + public String toString() { + + return "InstalledSoftwareTool[" + getName() + "]"; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/InstalledSoftwareVersion.java b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/InstalledSoftwareVersion.java new file mode 100644 index 0000000000..6dd812827f --- /dev/null +++ b/cli/src/main/java/com/devonfw/tools/ide/commandlet/cleanup/InstalledSoftwareVersion.java @@ -0,0 +1,85 @@ +package com.devonfw.tools.ide.commandlet.cleanup; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents a version of an IDE tool edition in the global software folder as discovered by the {@code cleanup} commandlet. + *

+ * For example, for IntelliJ's "ultimate" edition, versions could be "2022.3" and "2023.1". This class contains a list + * of projects that use this version and a flag indicating whether the version is marked for deletion. + */ +public class InstalledSoftwareVersion extends AbstractInstalledSoftwareItem { + + /** A list of project names that currently use this version. */ + private final List usedBy; + + /** A flag indicating whether the version is marked for deletion. */ + private boolean delete; + + /** + * Constructor. + * + * @param name the name of the version. + * @param path the installation {@link Path} of this version. + */ + public InstalledSoftwareVersion(String name, Path path) { + + super(name, path); + this.usedBy = new ArrayList<>(); + this.delete = false; + } + + /** + * @return the list of project names that currently use this version. + */ + public List getUsedBy() { + + return this.usedBy; + } + + /** + * Marks this version as used by the given project. + * + * @param projectName the name of the project using this version. + */ + public void addUsedBy(String projectName) { + + if (!this.usedBy.contains(projectName)) { + this.usedBy.add(projectName); + } + } + + /** + * @return {@code true} if this version is marked for deletion. + */ + public boolean isDelete() { + + return this.delete; + } + + /** + * Sets the deletion flag. + * + * @param delete {@code true} to mark this version for deletion. + */ + public void setDelete(boolean delete) { + + this.delete = delete; + } + + /** + * @return {@code true} if no project currently uses this version. + */ + public boolean isUnused() { + + return this.usedBy.isEmpty(); + } + + @Override + public String toString() { + + return "InstalledSoftwareVersion[" + getName() + "]"; + } +} diff --git a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java index 3443b60d68..d02df2c4ef 100644 --- a/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java +++ b/cli/src/main/java/com/devonfw/tools/ide/context/IdeContext.java @@ -1,7 +1,11 @@ package com.devonfw.tools.ide.context; +import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; import com.devonfw.tools.ide.cli.CliAbortException; import com.devonfw.tools.ide.cli.CliException; @@ -30,8 +34,8 @@ import com.devonfw.tools.ide.tool.npm.Npm; import com.devonfw.tools.ide.tool.npm.NpmRepository; import com.devonfw.tools.ide.tool.pip.PipRepository; -import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.tool.python.PythonRepository; +import com.devonfw.tools.ide.tool.repository.ToolRepository; import com.devonfw.tools.ide.tool.uv.UvRepository; import com.devonfw.tools.ide.url.model.UrlMetadata; import com.devonfw.tools.ide.variable.IdeVariables; @@ -416,9 +420,41 @@ default void requireOnline(String purpose, boolean explicitOnlineCheck) { Path getIdeRoot(); /** - * @param ideRoot the new value of {@link #getIdeRoot() IDE_ROOT}. Typically detected automatically from the environment and working directory, but may need - * to be set explicitly (e.g. during the initial installation where the {@code IDE_ROOT} environment variable is not yet available but the installation - * target is already known). + * Finds all IDEasy projects below the configured IDE root. + * + * @return the paths of all detected IDEasy projects. + */ + default List findProjects() { + + return findProjects(getIdeRoot()); + } + + /** + * Finds all IDEasy projects below the given IDE root. + * + * @param ideRoot the IDE root containing the IDEasy projects. + * @return the paths of all detected IDEasy projects. + */ + static List findProjects(Path ideRoot) { + + if ((ideRoot == null) || !Files.isDirectory(ideRoot)) { + return List.of(); + } + + try (Stream children = Files.list(ideRoot)) { + return children.filter(Files::isDirectory) + .filter(project -> !FOLDER_UNDERSCORE_IDE.equals(project.getFileName().toString())) + .filter(project -> Files.isDirectory(project.resolve(FOLDER_WORKSPACES))) + .toList(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to find IDEasy projects in " + ideRoot, e); + } + } + + /** + * @param ideRoot the new value of {@link #getIdeRoot() IDE_ROOT}. Typically detected automatically from the environment and working directory, but may + * need to be set explicitly (e.g. during the initial installation where the {@code IDE_ROOT} environment variable is not yet available but the + * installation target is already known). */ void setIdeRoot(Path ideRoot); diff --git a/cli/src/main/resources/nls/Help.properties b/cli/src/main/resources/nls/Help.properties index 5bbac94170..981faf1bab 100644 --- a/cli/src/main/resources/nls/Help.properties +++ b/cli/src/main/resources/nls/Help.properties @@ -11,6 +11,8 @@ cmd.cdk=Tool commandlet for AWS CDK. cmd.cdk.detail=The AWS Cloud Development Kit (AWS CDK) is an open-source software development framework for defining cloud infrastructure in code and provisioning it through AWS CloudFormation. Detailed documentation can be found at https://docs.aws.amazon.com/cdk/v2/guide/home.html cmd.claude=Tool commandlet for Claude Code CLI. cmd.claude.detail=Claude Code CLI is a command-line interface for interacting with the Claude AI assistant. Detailed documentation can be found at https://code.claude.com/docs/en/overview +cmd.cleanup=Commandlet to clean up the IDEasy installation by uninstalling all unused tools. +cmd.cleanup.detail=This will remove any installed tools that are currently not in use by an IDEasy project. Before anything is deleted you are asked for confirmation. Run "ide -b -f cleanup" to skip the confirmation. cmd.complete=Internal commandlet for bash auto-completion. cmd.complete.detail=Run 'ide complete ' to activate the non-interactive autocompletion, replace with the arguments you want to autocomplete.\nE.g. type: 'ide complete in' to get 'install' and 'intellij' suggestions. cmd.copilot=Tool commandlet for GitHub Copilot CLI. @@ -60,7 +62,7 @@ cmd.inso=Tool commandlet for Inso CLI (Insomnia). cmd.inso.detail=Inso CLI is a command line tool that allows you to use Insomnia features in your terminal and CI/CD environments for automation. Detailed documentation can be found at https://docs.insomnia.rest/inso-cli/introduction cmd.install=Install the selected tool. cmd.install-plugin=Install the selected plugin for the selected tool. -cmd.install-plugin.detail=Plugins can be installed for tools that support such. Plugins must be defined in the project settings to be available to the user for installation. If a desired plugin is not available to install, please contact your ide-admin. +cmd.install-plugin.detail=Plugins can be installed for tools that support such. Plugins must be defined in the project settings to be available to the user for installation. If a desired plugin is not available to install, please contact your ide-admin. cmd.install.detail=To see all tools available for installation, call "ide help" cmd.intellij=Tool commandlet for IntelliJ (IDE) cmd.intellij.detail=IntelliJ is a popular Integrated Development Environment for Java developed by JetBrains. Detailed documentation can be found at https://www.jetbrains.com/idea/documentation/ diff --git a/cli/src/main/resources/nls/Help_de.properties b/cli/src/main/resources/nls/Help_de.properties index 78188514ae..16132a0726 100644 --- a/cli/src/main/resources/nls/Help_de.properties +++ b/cli/src/main/resources/nls/Help_de.properties @@ -11,6 +11,8 @@ cmd.cdk=Werkzeug Kommando für AWS CDK. cmd.cdk.detail=Das AWS Cloud Development Kit (AWS CDK) ist ein Open-Source-Softwareentwicklungsframework zur Modellierung und Bereitstellung von Cloud-Anwendungen mithilfe von bekannten Programmiersprachen. Detaillierte Dokumentation ist zu finden unter https://docs.aws.amazon.com/cdk/v2/guide/home.html cmd.claude=Werkzeug Kommando für Claude Code CLI. cmd.claude.detail=Claude Code CLI ist ein KI-gestützter Programmierassistent, der über die Befehlszeile ausgeführt wird. Detaillierte Dokumentation ist zu finden unter https://code.claude.com/docs/de/overview +cmd.cleanup=Werkzeug zum Aufräumen der IDEasy-Installation durch Deinstallieren aller ungenutzten Werkzeuge. +cmd.cleanup.detail=Dies wird alle installierten Werkzeuge entfernen, die derzeit von keinem IDEasy-Projekt verwendet werden. Bevor etwas gelöscht wird, wirst du um Bestätigung gebeten. Führe "ide -b -f cleanup" aus, um die Bestätigung zu überspringen. cmd.complete=Internes Werkzeug für bash Autovervollständigung. cmd.complete.detail=Geben Sie 'ide complete ' in die Konsole ein um die einfache Autovervollständigung zu aktivieren, ersetzen Sie mit dem Ausdruck, der automatisch vervollständigt werden soll.\nZ.B. geben Sie einfach 'ide complete in' in die Konsole ein um 'install' und 'intellij' als Vorschläge zu erhalten. cmd.copilot=Werkzeug Kommando für GitHub Copilot CLI. diff --git a/cli/src/test/java/com/devonfw/tools/ide/commandlet/CleanupCommandletTest.java b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CleanupCommandletTest.java new file mode 100644 index 0000000000..e032ae9cce --- /dev/null +++ b/cli/src/test/java/com/devonfw/tools/ide/commandlet/CleanupCommandletTest.java @@ -0,0 +1,238 @@ +package com.devonfw.tools.ide.commandlet; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +import com.devonfw.tools.ide.commandlet.cleanup.CleanupCommandlet; +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.io.WindowsSymlinkTestHelper; + +/** + * Test of {@link CleanupCommandlet}. + */ +class CleanupCommandletTest extends AbstractIdeContextTest { + + private static final String PROJECT_BASIC = "basic"; + + /** + * Tests that unused software is deleted while software used by a project is retained. + * + * @throws IOException if the test setup cannot be created. + */ + @Test + void testCleanupDeletesUnusedAndKeepsUsedGlobalSoftware() throws IOException { + + WindowsSymlinkTestHelper.assumeSymlinksSupported(); + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + + Path usedVersion = createInstalledVersion(context, "default", "cleanup-test-java", "default", "21"); + + Path unusedVersion = createInstalledVersion(context, "default", "cleanup-test-java", "default", "17"); + + Path projectSoftware = context.getIdeHome().resolve(IdeContext.FOLDER_SOFTWARE); + + createSoftwareLink(projectSoftware.resolve("cleanup-test-java"), usedVersion); + + CleanupCommandlet cleanup = getCleanupWithConfirmation(context); + + // act + cleanup.run(); + + // assert + assertThat(usedVersion).as("Software used by a project must not be deleted").exists(); + + assertThat(unusedVersion).as("Unused software should be deleted").doesNotExist(); + + assertThat(context).logAtSuccess().hasMessage("Unused tools have been deleted successfully."); + } + + /** + * Tests that a version is retained when a project links to a subdirectory of the installation, for example {@code /Contents/MacOS}. + * + * @throws IOException if the test setup cannot be created. + */ + @Test + void testCleanupKeepsVersionReferencedBySubdirectory() throws IOException { + + WindowsSymlinkTestHelper.assumeSymlinksSupported(); + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + + Path usedVersion = createInstalledVersion(context, "default", "cleanup-test-macos", "default", "1.0"); + + Path macOsFolder = usedVersion.resolve("Contents").resolve("MacOS"); + Files.createDirectories(macOsFolder); + + Path unusedVersion = createInstalledVersion(context, "default", "cleanup-test-macos", "default", "2.0"); + + Path projectSoftware = context.getIdeHome().resolve(IdeContext.FOLDER_SOFTWARE); + + createSoftwareLink(projectSoftware.resolve("cleanup-test-macos"), macOsFolder); + + CleanupCommandlet cleanup = getCleanupWithConfirmation(context); + + // act + cleanup.run(); + + // assert + assertThat(usedVersion) + .as("The version must be retained when the project links to a subdirectory of that version") + .exists(); + + assertThat(unusedVersion).as("The unrelated unused version should be deleted").doesNotExist(); + } + + /** + * Tests that a version referenced by {@code software/extra//} is retained. + * + * @throws IOException if the test setup cannot be created. + */ + @Test + void testCleanupKeepsVersionReferencedByNestedExtraTool() throws IOException { + + WindowsSymlinkTestHelper.assumeSymlinksSupported(); + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + + Path usedVersion = createInstalledVersion(context, "default", "cleanup-test-extra", "default", "1.0"); + + Path unusedVersion = createInstalledVersion(context, "default", "cleanup-test-extra", "default", "2.0"); + + Path nestedExtraToolLink = context.getIdeHome() + .resolve(IdeContext.FOLDER_SOFTWARE) + .resolve(IdeContext.FOLDER_EXTRA) + .resolve("cleanup-test-extra") + .resolve("custom-installation"); + + createSoftwareLink(nestedExtraToolLink, usedVersion); + + CleanupCommandlet cleanup = getCleanupWithConfirmation(context); + + // act + cleanup.run(); + + // assert + assertThat(usedVersion) + .as("Software referenced by software/extra// must not be deleted") + .exists(); + + assertThat(unusedVersion).as("The unrelated unused extra-tool version should be deleted").doesNotExist(); + } + + /** + * Tests that unused installations in the configured custom repository are discovered and deleted. + * + * @throws IOException if the test setup cannot be created. + */ + @Test + void testCleanupDeletesUnusedSoftwareFromCustomRepository() throws IOException { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + String customRepositoryId = context.getCustomToolRepository().getId(); + + Path unusedVersion = createInstalledVersion(context, customRepositoryId, "cleanup-test-tool", "default", "1.0"); + + CleanupCommandlet cleanup = getCleanupWithConfirmation(context); + + // act + cleanup.run(); + + // assert + assertThat(unusedVersion) + .as("Unused software from the configured custom repository should be discovered and deleted") + .doesNotExist(); + } + + /** + * Tests that batch mode combined with force mode skips the confirmation prompt. + * + * @throws IOException if the test setup cannot be created. + */ + @Test + void testCleanupSkipsConfirmationInBatchForceMode() throws IOException { + + // arrange + IdeTestContext context = newContext(PROJECT_BASIC); + + Path unusedVersion = createInstalledVersion( + context, "default", "cleanup-test-force", "default", "1.0"); + + context.getStartContext().setBatchMode(true); + context.getStartContext().setForceMode(true); + + CleanupCommandlet cleanup = context.getCommandletManager().getCommandlet(CleanupCommandlet.class); + + // act + cleanup.run(); + + // assert + assertThat(unusedVersion) + .as("Unused software should be deleted without an interactive confirmation in batch force mode") + .doesNotExist(); + + assertThat(context).logAtSuccess().hasMessage("Unused tools have been deleted successfully."); + } + + /** + * Creates an installed software version with the structure {@code _ide/software////}. + * + * @param context the test context. + * @param repositoryId the repository ID. + * @param toolName the tool name. + * @param editionName the edition name. + * @param versionName the version name. + * @return the real path of the created version directory. + * @throws IOException if the directory or version marker cannot be created. + */ + private Path createInstalledVersion(IdeTestContext context, String repositoryId, String toolName, + String editionName, String versionName) throws IOException { + + Path versionFolder = context.getSoftwareRepositoryPath() + .resolve(repositoryId) + .resolve(toolName) + .resolve(editionName) + .resolve(versionName); + + Files.createDirectories(versionFolder); + + Files.writeString(versionFolder.resolve(IdeContext.FILE_SOFTWARE_VERSION), versionName); + + return versionFolder.toRealPath(); + } + + /** + * Creates a symbolic link from a project software location to an installed software version or one of its subdirectories. + * + * @param link the project-side link. + * @param target the link target. + * @throws IOException if the link cannot be created. + */ + private void createSoftwareLink(Path link, Path target) throws IOException { + + Files.createDirectories(link.getParent()); + Files.deleteIfExists(link); + Files.createSymbolicLink(link, target); + } + + /** + * Gets the cleanup commandlet and configures an affirmative confirmation answer. + * + * @param context the test context. + * @return the configured cleanup commandlet. + */ + private CleanupCommandlet getCleanupWithConfirmation(IdeTestContext context) { + + context.setAnswers("yes"); + return context.getCommandletManager().getCommandlet(CleanupCommandlet.class); + } +} diff --git a/gui/src/main/java/com/devonfw/ide/gui/context/ProjectManager.java b/gui/src/main/java/com/devonfw/ide/gui/context/ProjectManager.java index a6272ebbb4..68290aa369 100644 --- a/gui/src/main/java/com/devonfw/ide/gui/context/ProjectManager.java +++ b/gui/src/main/java/com/devonfw/ide/gui/context/ProjectManager.java @@ -48,17 +48,10 @@ protected ProjectManager(Path ideRootDirectory) { */ public List getProjectNames() { - try (Stream subPaths = Files.list(ideRootDirectory)) { - return subPaths - .filter(Files::isDirectory) - .map(Path::getFileName) - .map(Path::toString) - .filter(name -> !name.equals(IdeContext.FOLDER_UNDERSCORE_IDE) && Files.exists(ideRootDirectory.resolve(name).resolve("workspaces"))) - .toList(); - - } catch (IOException e) { - throw new RuntimeException("Failed to read project list!", e); - } + return IdeContext.findProjects(this.ideRootDirectory).stream() + .map(Path::getFileName) + .map(Path::toString) + .toList(); } /**